diff --git a/bin/recs-tui.ts b/bin/recs-tui.ts deleted file mode 100644 index 0ce72ec..0000000 --- a/bin/recs-tui.ts +++ /dev/null @@ -1,65 +0,0 @@ -#!/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 d523fb9..8bc0ac1 100644 --- a/bin/recs.ts +++ b/bin/recs.ts @@ -99,19 +99,79 @@ 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 Explorer subcommand +if (command === "explorer") { + const explorerArgs = args.slice(1); + // Dynamically import the explorer entry point so it works in compiled binaries + const explorerModule = await import("../src/explorer/index.tsx"); + const launchExplorer = explorerModule.launchExplorer; + type ExplorerOptions = import("../src/explorer/index.tsx").ExplorerOptions; + const { SessionManager } = await import("../src/explorer/session/session-manager.ts"); + + const sessionManager = new SessionManager(); + + // Handle --list + if (explorerArgs.includes("--list")) { + const sessions = await sessionManager.list(); + if (sessions.length === 0) { + console.log("No saved sessions."); + } else { + console.log("Saved sessions:\n"); + for (const s of sessions) { + const label = s.name || s.sessionId; + console.log(` ${label}`); + console.log(` ${s.stageCount} stages, last used ${formatAge(Date.now() - s.lastAccessedAt)}`); + console.log(); + } + } + process.exit(0); + } + + // Handle --clean + if (explorerArgs.includes("--clean")) { + const removed = await sessionManager.clean(); + console.log(removed === 0 + ? "No sessions to clean up." + : `Removed ${removed} session${removed === 1 ? "" : "s"} older than 7 days.`); + process.exit(0); + } + + // Parse explorer options + const explorerOptions: ExplorerOptions = {}; + for (let i = 0; i < explorerArgs.length; i++) { + const arg = explorerArgs[i]!; + if (arg === "--session" || arg === "-s") { + explorerOptions.sessionId = explorerArgs[++i]; + } else if (arg === "--pipeline" || arg === "-p") { + explorerOptions.pipeline = explorerArgs[++i]; + } else if (arg === "--help" || arg === "-h") { + console.log(`Usage: recs explorer [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 + +Supported file types: .csv, .tsv, .xml, .jsonl, .json, .ndjson`); + process.exit(0); + } else if (!arg.startsWith("-")) { + explorerOptions.inputFile = arg; + } + } + + try { + await launchExplorer(explorerOptions); + } catch (err) { + process.stderr.write( + `\nExplorer error: ${err instanceof Error ? err.message : String(err)}\n`, + ); + if (err instanceof Error && err.stack) { + process.stderr.write(err.stack + "\n"); + } + process.exit(1); + } + process.exit(0); } // Handle alias management subcommand @@ -194,3 +254,10 @@ if (!noUpdateCheck && shouldCheck(getConfigDir())) { } process.exit(exitCode); + +function formatAge(ms: number): string { + if (ms < 60_000) return "just now"; + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`; + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`; + return `${Math.floor(ms / 86_400_000)}d ago`; +} diff --git a/bun.lock b/bun.lock index cf4d96f..683ad0a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,13 @@ "": { "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", + "ink": "^6.8.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "mongodb": "^7.1.0", "nanoid": "^5.1.6", "openai": "^6.22.0", @@ -30,6 +32,8 @@ }, }, "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + "@algolia/abtesting": ["@algolia/abtesting@1.15.0", "", { "dependencies": { "@algolia/client-common": "5.49.0", "@algolia/requester-browser-xhr": "5.49.0", "@algolia/requester-fetch": "5.49.0", "@algolia/requester-node-http": "5.49.0" } }, "sha512-D1QZ8dQx5zC9yrxNao9ER9bojmmzUdL1i2P9waIRiwnZ5fI26YswcCd6VHR/Q4W3PASfVf2My4YQ2FhGGDewTQ=="], "@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="], @@ -74,8 +78,6 @@ "@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=="], @@ -132,82 +134,10 @@ "@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=="], @@ -312,8 +242,6 @@ "@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=="], @@ -380,19 +308,17 @@ "@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-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], @@ -404,40 +330,40 @@ "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=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -454,7 +380,7 @@ "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": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], @@ -462,21 +388,21 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "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=="], + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], @@ -490,8 +416,6 @@ "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=="], @@ -504,17 +428,27 @@ "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=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "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=="], + "ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + + "ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="], + + "ink-spinner": ["ink-spinner@5.0.0", "", { "dependencies": { "cli-spinners": "^2.7.0" }, "peerDependencies": { "ink": ">=4.0.0", "react": ">=18.0.0" } }, "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA=="], + + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - "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=="], + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], "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=="], @@ -542,8 +476,6 @@ "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=="], @@ -558,7 +490,7 @@ "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-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], @@ -580,46 +512,30 @@ "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=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "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=="], "openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="], "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=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], "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=="], @@ -632,27 +548,25 @@ "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=="], + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], "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=="], "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rollup": ["rollup@4.58.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.58.0", "@rollup/rollup-android-arm64": "4.58.0", "@rollup/rollup-darwin-arm64": "4.58.0", "@rollup/rollup-darwin-x64": "4.58.0", "@rollup/rollup-freebsd-arm64": "4.58.0", "@rollup/rollup-freebsd-x64": "4.58.0", "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", "@rollup/rollup-linux-arm-musleabihf": "4.58.0", "@rollup/rollup-linux-arm64-gnu": "4.58.0", "@rollup/rollup-linux-arm64-musl": "4.58.0", "@rollup/rollup-linux-loong64-gnu": "4.58.0", "@rollup/rollup-linux-loong64-musl": "4.58.0", "@rollup/rollup-linux-ppc64-gnu": "4.58.0", "@rollup/rollup-linux-ppc64-musl": "4.58.0", "@rollup/rollup-linux-riscv64-gnu": "4.58.0", "@rollup/rollup-linux-riscv64-musl": "4.58.0", "@rollup/rollup-linux-s390x-gnu": "4.58.0", "@rollup/rollup-linux-x64-gnu": "4.58.0", "@rollup/rollup-linux-x64-musl": "4.58.0", "@rollup/rollup-openbsd-x64": "4.58.0", "@rollup/rollup-openharmony-arm64": "4.58.0", "@rollup/rollup-win32-arm64-msvc": "4.58.0", "@rollup/rollup-win32-ia32-msvc": "4.58.0", "@rollup/rollup-win32-x64-gnu": "4.58.0", "@rollup/rollup-win32-x64-msvc": "4.58.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw=="], "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=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="], @@ -662,11 +576,13 @@ "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=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "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=="], + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -678,7 +594,7 @@ "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=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], @@ -692,21 +608,19 @@ "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=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], "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=="], + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="], "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], @@ -714,6 +628,8 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -728,8 +644,6 @@ "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=="], @@ -742,44 +656,38 @@ "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=="], + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + "wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="], "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "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=="], + "cli-truncate/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], - "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "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=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9d86800..7b4ed1a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -38,6 +38,7 @@ export default defineConfig({ logo: "/logo-small.png", nav: [ { text: "Guide", link: "/guide/getting-started" }, + { text: "Explorer", link: "/explorer/" }, { text: "Reference", link: "/reference/" }, ], sidebar: { @@ -69,6 +70,17 @@ export default defineConfig({ ], }, ], + "/explorer/": [ + { + text: "Explorer", + items: [ + { text: "Overview", link: "/explorer/" }, + { text: "Getting Started", link: "/explorer/getting-started" }, + { text: "Keyboard Reference", link: "/explorer/keyboard-reference" }, + { text: "Feature Guide", link: "/explorer/features" }, + ], + }, + ], "/reference/": [ { text: "Input Operations", diff --git a/docs/explorer/features.md b/docs/explorer/features.md new file mode 100644 index 0000000..f6ed536 --- /dev/null +++ b/docs/explorer/features.md @@ -0,0 +1,132 @@ +# Feature Guide + +A deeper look at Explorer's features beyond the basics. + +## Quick Actions + +Quick actions let you build stages directly from the data you're looking at. In table view, press `h`/`l` to highlight a column, then: + +- **`g`** — grep: creates a `grep` stage filtering on the highlighted field's current value +- **`s`** — sort: creates a `sort` stage ordered by the highlighted field +- **`c`** — collate: creates a `collate` stage grouping by the highlighted field with a count aggregator + +Each quick action opens the edit modal so you can adjust arguments before confirming. This is the fastest way to go from "I wonder what's in this column" to a working pipeline stage. + +## Field Spotlight + +Press `F` on a highlighted column to open the Field Spotlight overlay. It shows the value distribution for that field across all records in the current stage's output — unique values, counts, and percentages. Useful for understanding your data before deciding what to filter or group by. + +From the spotlight view, you can add stages directly based on what you see. + +## Record Detail + +Press `Enter` in the inspector to open a tree view of the current record. This expands nested objects and arrays for full visibility into deeply structured data. Navigate between records with arrow keys inside the detail view. + +## View Modes + +Press `t` in the inspector to cycle through four view modes: + +| Mode | Description | +|------|-------------| +| **table** | Columnar ASCII table with column highlighting and quick actions | +| **prettyprint** | Pretty-printed JSON, one record at a time | +| **json** | Raw JSON lines | +| **schema** | Field names, inferred types, sample values, and % populated | + +The schema view is particularly useful for understanding unfamiliar data — it shows you what fields exist, what types they contain, and how complete each field is across the dataset. + +## Forks + +Forks let you branch your pipeline to try different approaches without losing your work. + +Press `f` to fork at the cursor stage. Everything up to that point is shared; the new fork gets its own stages from there on. Press `b` to switch between forks or manage them. + +When you have more than one fork, a tab bar appears above the pipeline list showing all fork names. + +Forks share cached results for their common prefix stages, so switching between branches is fast. + +## Undo/Redo + +Every structural change to your pipeline is tracked: + +- Adding, deleting, editing, reordering, or toggling stages +- Creating or deleting forks +- Adding or removing input sources + +Press `u` to undo, `Ctrl+R` to redo. The status bar shows the current undo depth. History is capped at 200 entries per session and persists across saves. + +Undo tracks pipeline structure only — not UI state like scroll position or panel focus. This means undo restores *what you built*, not where you were looking. + +## Named Sessions + +Explorer auto-saves your pipeline state, undo history, and cached results to disk. Sessions are identified by the input file path by default. + +Press `S` to give your session a name. Named sessions appear in the session list (`recs explorer --list`) with their name rather than just a file path. + +Session management from the command line: + +```bash +recs explorer --list # Show all sessions +recs explorer --session # Resume a specific session +recs explorer --clean # Remove sessions older than 7 days +``` + +When you open a file that matches an existing session, Explorer asks if you want to resume or start fresh. + +## File Type Auto-Detection + +Explorer detects the file type from its extension and inserts the correct `from*` stage automatically: + +- `.csv` → `fromcsv --header` +- `.tsv` → `fromcsv --header --delim \t` +- `.xml` → `fromxml` +- `.jsonl` / `.json` / `.ndjson` → read directly (no stage needed) + +## Caching + +Explorer caches the output of each stage so navigating between stages is instant. Cache status is shown per stage in the pipeline list: + +- **Cached**: results are available immediately +- **Stale**: a dependency changed; will re-execute when inspected +- **Computing**: currently executing +- **Error**: stage failed; downstream stages are greyed out + +### Large file handling + +When you open a file larger than 100 MB, Explorer shows a warning. Files over 1 GB prompt you to choose a cache policy: + +| Policy | Behavior | +|--------|----------| +| **Cache all** | Default. Every stage's output is cached. | +| **Cache selectively** | Only stages you pin with `p` are cached. | +| **No caching** | Always re-execute from source. Lowest memory usage. | + +### Selective caching + +In selective mode, press `p` to pin or unpin individual stages. Only pinned stages keep their cached results; everything else re-executes on demand. + +## Export Formats + +Press `x` to copy your pipeline as a shell pipe script: + +```bash +#!/usr/bin/env bash +recs fromcsv --header data.csv \ + | recs grep '{{status}} >= 500' \ + | recs sort --key count=-n \ + | recs totable +``` + +Press `X` to choose between formats: + +| Format | Description | +|--------|-------------| +| **Pipe script** | Multi-line shell script with `\|` pipes (default) | +| **Chain command** | Single `recs chain` command | +| **Save to file** | Write the pipe script to a `.sh` file | + +Export copies to your clipboard automatically (via OSC 52, `pbcopy`, or `xclip`). + +## Vim/$EDITOR Integration + +Press `v` to open the current stage's output records in your `$EDITOR`. Explorer writes the records to a temporary file, launches your editor, and resumes the TUI when you close it. Useful for detailed inspection or quick edits outside the Explorer interface. diff --git a/docs/explorer/getting-started.md b/docs/explorer/getting-started.md new file mode 100644 index 0000000..5773ffe --- /dev/null +++ b/docs/explorer/getting-started.md @@ -0,0 +1,133 @@ +# Getting Started with Explorer + +## Opening a File + +Pass a file path to `recs explorer`: + +```bash +recs explorer data.csv +``` + +Explorer auto-detects file types by extension and inserts the appropriate input stage: + +| Extension | Auto-inserted stage | +|-----------|-------------------| +| `.csv` | `fromcsv --header` | +| `.tsv` | `fromcsv --header --delim \t` | +| `.xml` | `fromxml` | +| `.jsonl`, `.json`, `.ndjson` | *(none — native format)* | + +For JSONL files, Explorer reads records directly. For everything else, it adds a `from*` stage as the first step in your pipeline. + +You can also launch Explorer with no arguments to see a welcome screen with your recent sessions: + +```bash +recs explorer +``` + +## The Core Loop + +Working in Explorer follows a simple pattern: + +### 1. Add a stage + +Press `a` to add a stage after the cursor. A categorized picker appears with fuzzy search — type a few characters to filter the 40+ available operations. Select one and you'll be prompted for its arguments. + +``` ++------------------------------------------------------------------+ +| Add Stage (after: fromcsv) [Esc] cancel | ++------------------------------------------------------------------+ +| Search: [gre ] | +| | +| TRANSFORM | grep | +| > grep | Filter records matching | +| | an expression. | +| | | +| | Options: | +| | -e Filter expr | +| | | +| | Example: | +| | recs grep '{{age}} > 21' | ++------------------------------------------------------------------+ +``` + +### 2. Inspect the output + +Move the cursor to any stage with `j`/`k` (or arrow keys). The inspector panel on the right immediately shows that stage's output — record count, field names, and data in table format. + +Press `t` to cycle through view modes: **table** → **prettyprint** → **json** → **schema**. + +### 3. Tweak and repeat + +- `e` to edit a stage's arguments +- `Space` to toggle a stage on/off (disable without deleting) +- `d` to delete a stage +- `J`/`K` to reorder stages +- `u` to undo any change + +Every structural change (add, delete, edit, reorder) is undoable. Undo history is preserved across sessions. + +### 4. Export your pipeline + +When you're happy with the result, press `x` to copy the pipeline as a shell script to your clipboard: + +```bash +#!/usr/bin/env bash +recs fromcsv --header data.csv \ + | recs grep '{{status}} >= 500' \ + | recs collate --key host -a count \ + | recs sort --key count=-n \ + | recs totable +``` + +Press `X` for more export options — you can also export as a `recs chain` command or save to a file. + +## Sessions + +Explorer auto-saves your work. Every 30 seconds — and on every structural change — your pipeline state, undo history, and cached results are written to disk. + +### Quitting and resuming + +Press `q` to quit. Your session is saved automatically. Next time you open the same file, Explorer offers to resume where you left off: + +```bash +recs explorer data.csv +# "Resume previous session? (5 stages, last used 2h ago)" +``` + +### Named sessions + +Press `S` to name your session. Named sessions are easier to find in the session list. + +### Session management + +```bash +# List all saved sessions +recs explorer --list + +# Resume a specific session by ID +recs explorer --session + +# Remove sessions older than 7 days +recs explorer --clean +``` + +## CLI Reference + +``` +Usage: recs explorer [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 help +``` + +Supported file types: `.csv`, `.tsv`, `.xml`, `.jsonl`, `.json`, `.ndjson` + +## Next Steps + +- **[Keyboard Reference](./keyboard-reference)** — Full list of keyboard shortcuts +- **[Feature Guide](./features)** — Quick actions, forks, field spotlight, and more diff --git a/docs/explorer/index.md b/docs/explorer/index.md new file mode 100644 index 0000000..75d547c --- /dev/null +++ b/docs/explorer/index.md @@ -0,0 +1,76 @@ +# Explorer + +Explorer is an interactive terminal UI for building RecordStream pipelines. Instead of writing shell one-liners and re-running them every time you tweak something, you build your pipeline step by step — adding stages, inspecting output, and iterating — all inside a single terminal session. + +## Why Explorer? + +The classic recs workflow is powerful: + +```bash +recs fromcsv --header data.csv \ + | recs grep '{{status}} >= 500' \ + | recs collate --key host -a count \ + | recs sort --key count=-n \ + | recs totable +``` + +But exploratory data work means constant iteration — change the filter, re-run, check the output, add a sort, re-run again. With shell pipelines, every tweak means hitting up-arrow and editing a long command. + +Explorer replaces that cycle with a live, interactive loop: + +1. Add a stage +2. See its output instantly +3. Tweak, reorder, or delete stages +4. Export the finished pipeline as a shell script + +## The Interface + +Explorer uses a split-pane layout: a pipeline list on the left, a data inspector on the right. + +``` ++==============================================================================+ +| recs explorer input: access.log (5000 rec) fork: main [?] | ++=========================+====================================================+ +| Pipeline | Inspector: grep (2847 records, cached 3s ago) | +| | | +| 1 fromre 5000 | # ip status time path size | +| '^(\S+)...' | 1 192.168.1.1 200 161.. /index 1234 | +| 2 grep + 2847 | 2 10.0.0.5 200 161.. /api/u 5678 | +| > status=200 <-- | 3 192.168.1.1 200 161.. /style 910 | +| 3 sort ! ---- | 4 172.16.0.12 200 161.. /favic 234 | +| --key time=n | 5 10.0.0.5 200 161.. /api/d 8901 | +| 4 collate ! ---- | 6 192.168.1.100 200 161.. /image 4567 | +| --key host | 7 10.0.0.22 200 161.. /js/ap 2345 | +| -a count | 8 172.16.0.12 200 161.. /api/c 6789 | +| 5 totable ! ---- | 9 192.168.1.1 200 161.. /fonts 123 | +| (output) | 10 10.0.0.5 200 161.. /login 4560 | +| | ... (2847 total) | ++-------------------------+----------------------------------------------------+ +| a:add d:del e:edit u:undo x:export f:fork v:vim ?:help q:quit | undo:3 | ++==============================================================================+ +``` + +**Left panel**: Your pipeline stages, with cursor navigation. Each stage shows its operation name, arguments, cache status, and record count. + +**Right panel**: The output of the currently selected stage. Move the cursor and the inspector updates immediately — cached results appear instantly, uncached stages execute on the fly. + +**Status bar**: Quick-reference keybindings and undo count. + +## Shell Pipelines vs Explorer + +| | Shell Pipelines | Explorer | +|---|---|---| +| **Iteration** | Edit command, re-run, check output | Move cursor, see output instantly | +| **Visibility** | See one stage's output at a time | Jump between any stage to inspect | +| **Experimentation** | Copy-paste to try alternatives | Fork your pipeline into branches | +| **Undo** | Hope you remember what you had before | `u` to undo, `Ctrl+R` to redo | +| **Export** | Already a shell command | `x` to copy as shell script | +| **Sessions** | Terminal history | Named sessions, auto-saved to disk | + +Explorer isn't a replacement for shell pipelines — it's where you figure out what your pipeline should be. Once you're happy, export it and drop it into a script. + +## Next Steps + +- **[Getting Started](./getting-started)** — Open your first file and build a pipeline +- **[Keyboard Reference](./keyboard-reference)** — Every key, organized by context +- **[Feature Guide](./features)** — Deep dives into forks, sessions, quick actions, and more diff --git a/docs/explorer/keyboard-reference.md b/docs/explorer/keyboard-reference.md new file mode 100644 index 0000000..f8ad0d1 --- /dev/null +++ b/docs/explorer/keyboard-reference.md @@ -0,0 +1,68 @@ +# Keyboard Reference + +All keyboard shortcuts in Explorer, organized by context. Press `?` at any time to see this reference inside the app. + +## Pipeline Panel (left) + +These keys are active when the pipeline list has focus. + +| Key | Action | +|-----|--------| +| `↑` / `k` | Move cursor up | +| `↓` / `j` | Move cursor down | +| `a` | Add stage after cursor | +| `A` | Add stage before cursor | +| `d` | Delete stage (with confirmation) | +| `e` | Edit stage arguments | +| `Space` | Toggle stage enabled/disabled | +| `J` | Reorder stage down | +| `K` | Reorder stage up | +| `r` | Re-run from cursor stage (invalidate cache) | +| `Enter` / `Tab` | Focus inspector panel | + +## Inspector Panel (right) + +These keys are active when the inspector has focus. + +| Key | Action | +|-----|--------| +| `↑` / `k` | Scroll records up | +| `↓` / `j` | Scroll records down | +| `t` | Cycle view mode: table → prettyprint → json → schema | +| `←` / `h` | Move column highlight left (table view) | +| `→` / `l` | Move column highlight right (table view) | +| `Enter` | Open record detail (tree view) | +| `Esc` | Clear column highlight, or return focus to pipeline | + +## Quick Actions + +When a column is highlighted in table view (navigate with `h`/`l`), these one-key shortcuts create a new stage targeting the highlighted field: + +| Key | Action | +|-----|--------| +| `g` | Add **grep** stage filtering on the highlighted field | +| `s` | Add **sort** stage sorting by the highlighted field | +| `c` | Add **collate** stage grouping by the highlighted field (with count) | +| `F` | Open **Field Spotlight** — value distribution for the highlighted field | + +After pressing `g`, `s`, or `c`, the edit modal opens so you can fine-tune the stage arguments before confirming. + +## Global + +These keys work regardless of which panel is focused. + +| Key | Action | +|-----|--------| +| `Tab` | Toggle focus between pipeline and inspector | +| `u` | Undo last pipeline edit | +| `Ctrl+R` | Redo last undone edit | +| `v` | Open current stage's records in `$EDITOR` | +| `x` | Export pipeline as shell script → clipboard | +| `X` | Export pipeline (choose format) | +| `S` | Save/rename session | +| `f` | Fork pipeline at cursor stage | +| `b` | Switch/manage forks | +| `i` | Switch input source | +| `p` | Pin/unpin stage for selective caching | +| `?` | Toggle help panel | +| `q` / `Ctrl+C` | Quit (auto-saves session) | diff --git a/man/man1/recs-annotate.1 b/man/man1/recs-annotate.1 index 01db44a..f3d96c4 100644 --- a/man/man1/recs-annotate.1 +++ b/man/man1/recs-annotate.1 @@ -1,4 +1,4 @@ -.TH RECS\-ANNOTATE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-ANNOTATE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-annotate \- Evaluate an expression on each record and cache the resulting changes by key grouping diff --git a/man/man1/recs-assert.1 b/man/man1/recs-assert.1 index 41b459e..843e8ae 100644 --- a/man/man1/recs-assert.1 +++ b/man/man1/recs-assert.1 @@ -1,4 +1,4 @@ -.TH RECS\-ASSERT 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-ASSERT 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-assert \- Asserts that every record in the stream must pass the given expression diff --git a/man/man1/recs-chain.1 b/man/man1/recs-chain.1 index b72fd56..f99be0f 100644 --- a/man/man1/recs-chain.1 +++ b/man/man1/recs-chain.1 @@ -1,4 +1,4 @@ -.TH RECS\-CHAIN 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-CHAIN 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-chain \- Creates an in\-memory chain of recs operations diff --git a/man/man1/recs-collate.1 b/man/man1/recs-collate.1 index 98d2743..b31c36d 100644 --- a/man/man1/recs-collate.1 +++ b/man/man1/recs-collate.1 @@ -1,4 +1,4 @@ -.TH RECS\-COLLATE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-COLLATE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-collate \- Take records, grouped together by \-\-keys, and compute statistics (like average, count, sum, concat, etc diff --git a/man/man1/recs-decollate.1 b/man/man1/recs-decollate.1 index b9911d5..688e66d 100644 --- a/man/man1/recs-decollate.1 +++ b/man/man1/recs-decollate.1 @@ -1,4 +1,4 @@ -.TH RECS\-DECOLLATE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-DECOLLATE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-decollate \- Reverse of collate: takes a single record and produces multiple records using deaggregators diff --git a/man/man1/recs-delta.1 b/man/man1/recs-delta.1 index e168bd0..44d8487 100644 --- a/man/man1/recs-delta.1 +++ b/man/man1/recs-delta.1 @@ -1,4 +1,4 @@ -.TH RECS\-DELTA 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-DELTA 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-delta \- Transforms absolute values into deltas between adjacent records diff --git a/man/man1/recs-eval.1 b/man/man1/recs-eval.1 index 82a55d4..b7cf1cf 100644 --- a/man/man1/recs-eval.1 +++ b/man/man1/recs-eval.1 @@ -1,4 +1,4 @@ -.TH RECS\-EVAL 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-EVAL 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-eval \- Evaluate an expression on each record and print the result as a line of text diff --git a/man/man1/recs-expandjson.1 b/man/man1/recs-expandjson.1 index a346909..532d469 100644 --- a/man/man1/recs-expandjson.1 +++ b/man/man1/recs-expandjson.1 @@ -1,4 +1,4 @@ -.TH RECS\-EXPANDJSON 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-EXPANDJSON 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-expandjson \- Expand JSON strings embedded in record fields into actual JSON values diff --git a/man/man1/recs-flatten.1 b/man/man1/recs-flatten.1 index a4e6226..2a0501e 100644 --- a/man/man1/recs-flatten.1 +++ b/man/man1/recs-flatten.1 @@ -1,4 +1,4 @@ -.TH RECS\-FLATTEN 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FLATTEN 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-flatten \- Flatten nested hash/array structures in records into top\-level fields diff --git a/man/man1/recs-fromapache.1 b/man/man1/recs-fromapache.1 index 9a7bc53..650fbe6 100644 --- a/man/man1/recs-fromapache.1 +++ b/man/man1/recs-fromapache.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMAPACHE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMAPACHE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromapache \- Each line of input (or lines of ) is parsed to produce an output record from Apache access logs diff --git a/man/man1/recs-fromatomfeed.1 b/man/man1/recs-fromatomfeed.1 index 59ffbc8..ba9b9d3 100644 --- a/man/man1/recs-fromatomfeed.1 +++ b/man/man1/recs-fromatomfeed.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMATOMFEED 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMATOMFEED 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromatomfeed \- Produce records from atom feed entries diff --git a/man/man1/recs-fromcsv.1 b/man/man1/recs-fromcsv.1 index ee3838c..0e659fb 100644 --- a/man/man1/recs-fromcsv.1 +++ b/man/man1/recs-fromcsv.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMCSV 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMCSV 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromcsv \- Each line of input (or lines of ) is split on commas to produce an output record diff --git a/man/man1/recs-fromdb.1 b/man/man1/recs-fromdb.1 index c3a822d..ca00119 100644 --- a/man/man1/recs-fromdb.1 +++ b/man/man1/recs-fromdb.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMDB 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMDB 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromdb \- Execute a select statement on a database and create a record stream from the results diff --git a/man/man1/recs-fromjsonarray.1 b/man/man1/recs-fromjsonarray.1 index 50d07e5..2f838ad 100644 --- a/man/man1/recs-fromjsonarray.1 +++ b/man/man1/recs-fromjsonarray.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMJSONARRAY 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMJSONARRAY 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromjsonarray \- Import JSON objects from within a JSON array diff --git a/man/man1/recs-fromkv.1 b/man/man1/recs-fromkv.1 index 3db0315..dbb5fcd 100644 --- a/man/man1/recs-fromkv.1 +++ b/man/man1/recs-fromkv.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMKV 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMKV 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromkv \- Records are generated from character input with the form " diff --git a/man/man1/recs-frommongo.1 b/man/man1/recs-frommongo.1 index 82eb92f..e5ef4a5 100644 --- a/man/man1/recs-frommongo.1 +++ b/man/man1/recs-frommongo.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMMONGO 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMMONGO 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-frommongo \- Generate records from a MongoDB query diff --git a/man/man1/recs-frommultire.1 b/man/man1/recs-frommultire.1 index 3372d0d..d792b3d 100644 --- a/man/man1/recs-frommultire.1 +++ b/man/man1/recs-frommultire.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMMULTIRE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMMULTIRE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-frommultire \- Match multiple regexes against each line of input (or lines of ) diff --git a/man/man1/recs-fromps.1 b/man/man1/recs-fromps.1 index 490e7d5..b771359 100644 --- a/man/man1/recs-fromps.1 +++ b/man/man1/recs-fromps.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMPS 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMPS 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromps \- Generate records from the process table diff --git a/man/man1/recs-fromre.1 b/man/man1/recs-fromre.1 index de0a22a..77f86be 100644 --- a/man/man1/recs-fromre.1 +++ b/man/man1/recs-fromre.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMRE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMRE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromre \- The regex is matched against each line of input (or lines of ) diff --git a/man/man1/recs-fromsplit.1 b/man/man1/recs-fromsplit.1 index e56030f..fbcf39d 100644 --- a/man/man1/recs-fromsplit.1 +++ b/man/man1/recs-fromsplit.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMSPLIT 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMSPLIT 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromsplit \- Each line of input (or lines of ) is split on the provided delimiter to produce an output record diff --git a/man/man1/recs-fromtcpdump.1 b/man/man1/recs-fromtcpdump.1 index 7921290..133b98f 100644 --- a/man/man1/recs-fromtcpdump.1 +++ b/man/man1/recs-fromtcpdump.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMTCPDUMP 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMTCPDUMP 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromtcpdump \- Runs tcpdump and puts out records, one for each packet diff --git a/man/man1/recs-fromxferlog.1 b/man/man1/recs-fromxferlog.1 index 837e968..e5fd391 100644 --- a/man/man1/recs-fromxferlog.1 +++ b/man/man1/recs-fromxferlog.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMXFERLOG 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMXFERLOG 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromxferlog \- Each line of input (or lines of ) is parsed as an FTP transfer log (xferlog format) to produce an output record diff --git a/man/man1/recs-fromxls.1 b/man/man1/recs-fromxls.1 index 621bc66..99719a3 100644 --- a/man/man1/recs-fromxls.1 +++ b/man/man1/recs-fromxls.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMXLS 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMXLS 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromxls \- Parse Excel files (xls, xlsx, xlsb, xlsm) into records diff --git a/man/man1/recs-fromxml.1 b/man/man1/recs-fromxml.1 index c8e010e..67b14e5 100644 --- a/man/man1/recs-fromxml.1 +++ b/man/man1/recs-fromxml.1 @@ -1,4 +1,4 @@ -.TH RECS\-FROMXML 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-FROMXML 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-fromxml \- Reads either from STDIN or from the specified URIs diff --git a/man/man1/recs-generate.1 b/man/man1/recs-generate.1 index 03d52c9..ad7dab9 100644 --- a/man/man1/recs-generate.1 +++ b/man/man1/recs-generate.1 @@ -1,4 +1,4 @@ -.TH RECS\-GENERATE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-GENERATE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-generate \- Execute an expression for each record to generate new records diff --git a/man/man1/recs-grep.1 b/man/man1/recs-grep.1 index 38d1c3e..ad86dcf 100644 --- a/man/man1/recs-grep.1 +++ b/man/man1/recs-grep.1 @@ -1,4 +1,4 @@ -.TH RECS\-GREP 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-GREP 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-grep \- Filter records where an expression evaluates to true diff --git a/man/man1/recs-join.1 b/man/man1/recs-join.1 index 8f89709..615f0af 100644 --- a/man/man1/recs-join.1 +++ b/man/man1/recs-join.1 @@ -1,4 +1,4 @@ -.TH RECS\-JOIN 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-JOIN 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-join \- Join two record streams on a key diff --git a/man/man1/recs-multiplex.1 b/man/man1/recs-multiplex.1 index 33b3bac..33fc0c9 100644 --- a/man/man1/recs-multiplex.1 +++ b/man/man1/recs-multiplex.1 @@ -1,4 +1,4 @@ -.TH RECS\-MULTIPLEX 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-MULTIPLEX 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-multiplex \- Take records, grouped together by \-\-keys, and run a separate operation instance for each group diff --git a/man/man1/recs-normalizetime.1 b/man/man1/recs-normalizetime.1 index 328c87d..b44f987 100644 --- a/man/man1/recs-normalizetime.1 +++ b/man/man1/recs-normalizetime.1 @@ -1,4 +1,4 @@ -.TH RECS\-NORMALIZETIME 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-NORMALIZETIME 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-normalizetime \- Given a single key field containing a date/time value, construct a normalized version of the value and place it into a field named \'n_\' diff --git a/man/man1/recs-parsedate.1 b/man/man1/recs-parsedate.1 index f888365..67b6b82 100644 --- a/man/man1/recs-parsedate.1 +++ b/man/man1/recs-parsedate.1 @@ -1,4 +1,4 @@ -.TH RECS\-PARSEDATE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-PARSEDATE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-parsedate \- Parse date/time strings from a field and output them in a normalized format diff --git a/man/man1/recs-sort.1 b/man/man1/recs-sort.1 index 01a5e35..40c10e9 100644 --- a/man/man1/recs-sort.1 +++ b/man/man1/recs-sort.1 @@ -1,4 +1,4 @@ -.TH RECS\-SORT 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-SORT 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-sort \- Sort records from input or from files diff --git a/man/man1/recs-stream2table.1 b/man/man1/recs-stream2table.1 index d59f647..ee44e7f 100644 --- a/man/man1/recs-stream2table.1 +++ b/man/man1/recs-stream2table.1 @@ -1,4 +1,4 @@ -.TH RECS\-STREAM2TABLE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-STREAM2TABLE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-stream2table \- Transforms a list of records, combining records based on a column field diff --git a/man/man1/recs-substream.1 b/man/man1/recs-substream.1 index d3b47b0..6d406ce 100644 --- a/man/man1/recs-substream.1 +++ b/man/man1/recs-substream.1 @@ -1,4 +1,4 @@ -.TH RECS\-SUBSTREAM 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-SUBSTREAM 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-substream \- Filter to a range of records delimited from when the begin snippet becomes true to when the end snippet becomes true, i diff --git a/man/man1/recs-tochart.1 b/man/man1/recs-tochart.1 index d1a87ca..e38fd07 100644 --- a/man/man1/recs-tochart.1 +++ b/man/man1/recs-tochart.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOCHART 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOCHART 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-tochart \- Render charts in the terminal from a record stream diff --git a/man/man1/recs-tocsv.1 b/man/man1/recs-tocsv.1 index 7c3175f..7657fe9 100644 --- a/man/man1/recs-tocsv.1 +++ b/man/man1/recs-tocsv.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOCSV 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOCSV 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-tocsv \- Outputs records as CSV formatted lines diff --git a/man/man1/recs-todb.1 b/man/man1/recs-todb.1 index 79c05d0..b8de6f6 100644 --- a/man/man1/recs-todb.1 +++ b/man/man1/recs-todb.1 @@ -1,4 +1,4 @@ -.TH RECS\-TODB 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TODB 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-todb \- Dumps a stream of input records into a database diff --git a/man/man1/recs-togdgraph.1 b/man/man1/recs-togdgraph.1 index 50ceae8..16a2da8 100644 --- a/man/man1/recs-togdgraph.1 +++ b/man/man1/recs-togdgraph.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOGDGRAPH 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOGDGRAPH 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-togdgraph \- Create a bar, scatter, or line graph diff --git a/man/man1/recs-tognuplot.1 b/man/man1/recs-tognuplot.1 index 086f146..eef23b4 100644 --- a/man/man1/recs-tognuplot.1 +++ b/man/man1/recs-tognuplot.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOGNUPLOT 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOGNUPLOT 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-tognuplot \- Create a graph of points from a record stream using GNU Plot diff --git a/man/man1/recs-tohtml.1 b/man/man1/recs-tohtml.1 index 50bcf51..4b445ab 100644 --- a/man/man1/recs-tohtml.1 +++ b/man/man1/recs-tohtml.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOHTML 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOHTML 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-tohtml \- Prints out an HTML table for the records from input or from files diff --git a/man/man1/recs-tojsonarray.1 b/man/man1/recs-tojsonarray.1 index bec8dc3..3c71180 100644 --- a/man/man1/recs-tojsonarray.1 +++ b/man/man1/recs-tojsonarray.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOJSONARRAY 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOJSONARRAY 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-tojsonarray \- Outputs the record stream as a single JSON array diff --git a/man/man1/recs-topn.1 b/man/man1/recs-topn.1 index 025e99b..aa11a04 100644 --- a/man/man1/recs-topn.1 +++ b/man/man1/recs-topn.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOPN 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOPN 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-topn \- Output the top N records from the input stream or from files diff --git a/man/man1/recs-toprettyprint.1 b/man/man1/recs-toprettyprint.1 index 7266790..9be21c5 100644 --- a/man/man1/recs-toprettyprint.1 +++ b/man/man1/recs-toprettyprint.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOPRETTYPRINT 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOPRETTYPRINT 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-toprettyprint \- Pretty print records, one key to a line, with a line of dashes separating records diff --git a/man/man1/recs-toptable.1 b/man/man1/recs-toptable.1 index cd2f603..f00a0ea 100644 --- a/man/man1/recs-toptable.1 +++ b/man/man1/recs-toptable.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOPTABLE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOPTABLE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-toptable \- Creates a multi\-dimensional pivot table with any number of x and y axes diff --git a/man/man1/recs-totable.1 b/man/man1/recs-totable.1 index 7c988e9..a4cd55e 100644 --- a/man/man1/recs-totable.1 +++ b/man/man1/recs-totable.1 @@ -1,4 +1,4 @@ -.TH RECS\-TOTABLE 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-TOTABLE 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-totable \- Pretty prints a table of records to the screen diff --git a/man/man1/recs-xform.1 b/man/man1/recs-xform.1 index 29ade04..9260cd9 100644 --- a/man/man1/recs-xform.1 +++ b/man/man1/recs-xform.1 @@ -1,4 +1,4 @@ -.TH RECS\-XFORM 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS\-XFORM 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs\-xform \- Transform records with a JS snippet diff --git a/man/man1/recs.1 b/man/man1/recs.1 index 4b09d80..07a717f 100644 --- a/man/man1/recs.1 +++ b/man/man1/recs.1 @@ -1,4 +1,4 @@ -.TH RECS 1 "2026-02-22" "recs 0.1.0" "RecordStream Manual" +.TH RECS 1 "2026-02-23" "recs 0.1.0" "RecordStream Manual" .SH NAME recs \- a toolkit for taming JSON record streams diff --git a/package.json b/package.json index 88377d1..e650075 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "scripts": { "build": "bun build ./bin/recs.ts --compile --outfile=./bin/recs", "test": "bun test", + "test:e2e": "RUN_E2E=1 bun test tests/explorer/e2e/", "bench": "bun tests/perf/run.ts", - "lint": "bunx oxlint src/ tests/ bin/ scripts/", + "lint": "bunx oxlint src/ tests/ bin/ scripts/ && bun scripts/check-no-private.ts", "typecheck": "tsc --noEmit", "prepare": "lefthook install", "check-docs": "bun scripts/check-docs.ts", @@ -28,11 +29,13 @@ "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", + "ink": "^6.8.0", + "ink-select-input": "^6.2.0", + "ink-spinner": "^5.0.0", + "ink-text-input": "^6.0.0", "mongodb": "^7.1.0", "nanoid": "^5.1.6", "openai": "^6.22.0", diff --git a/scripts/bench-executor.ts b/scripts/bench-executor.ts new file mode 100644 index 0000000..5305f55 --- /dev/null +++ b/scripts/bench-executor.ts @@ -0,0 +1,287 @@ +#!/usr/bin/env bun +/** + * Benchmark script for explorer executor performance. + * Measures time spent in key operations: clone, estimateSize, LRU eviction, + * cascade invalidation, and disk spill. + */ + +import "../src/cli/dispatcher.ts"; + +import { Record } from "../src/Record.ts"; +import { InterceptReceiver } from "../src/explorer/executor/intercept-receiver.ts"; +import { CacheManager } from "../src/explorer/executor/cache-manager.ts"; +import { executeToStage } from "../src/explorer/executor/executor.ts"; +import type { + PipelineState, + Stage, + InputSource, + CacheConfig, + CachedResult, + Fork, + InspectorState, +} from "../src/explorer/model/types.ts"; +import { rmSync, existsSync } from "node:fs"; + +// ── Helpers ───────────────────────────────────────────────────────── + +function generateRecords(count: number, fieldsPerRecord: number): Record[] { + const records: Record[] = []; + for (let i = 0; i < count; i++) { + const data: { [key: string]: unknown } = { id: i, name: `user_${i}` }; + for (let f = 0; f < fieldsPerRecord; f++) { + data[`field_${f}`] = `value_${i}_${f}_${"x".repeat(50)}`; + } + records.push(new Record(data as any)); + } + return records; +} + +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); + 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: 512 * 1024 * 1024, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + const inspector: InspectorState = { + viewMode: "table", + scrollOffset: 0, + searchQuery: null, + highlightedColumn: 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: "bench", + sessionDir: "/tmp/recs-bench", + }; +} + +function makeCachedResult(inputId: string, stageId: string, sizeBytes: number): CachedResult { + return { + key: `${inputId}:${stageId}`, + stageId, inputId, records: [], lines: [], spillFile: null, + recordCount: 10, fieldNames: ["a", "b"], + computedAt: Date.now(), sizeBytes, computeTimeMs: 1, + }; +} + +function bench(label: string, fn: () => void, iterations = 1): number { + // Warmup + for (let i = 0; i < Math.min(3, iterations); i++) fn(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) fn(); + const elapsed = performance.now() - start; + const perIter = elapsed / iterations; + console.log(` ${label}: ${elapsed.toFixed(1)}ms total (${perIter.toFixed(3)}ms/iter, ${iterations} iters)`); + return elapsed; +} + +async function benchAsync(label: string, fn: () => Promise, iterations = 1): Promise { + for (let i = 0; i < Math.min(3, iterations); i++) await fn(); + const start = performance.now(); + for (let i = 0; i < iterations; i++) await fn(); + const elapsed = performance.now() - start; + const perIter = elapsed / iterations; + console.log(` ${label}: ${elapsed.toFixed(1)}ms total (${perIter.toFixed(3)}ms/iter, ${iterations} iters)`); + return elapsed; +} + +// ── Benchmarks ────────────────────────────────────────────────────── + +const RECORD_COUNTS = [1000, 5000, 10000]; + +console.log("=== Explorer Executor Performance Benchmark ===\n"); + +// 1. InterceptReceiver.acceptRecord (clone cost) +console.log("--- 1. InterceptReceiver clone() cost ---"); +for (const count of RECORD_COUNTS) { + const records = generateRecords(count, 5); + bench(`InterceptReceiver ${count} records (with clone)`, () => { + const receiver = new InterceptReceiver(); + for (const r of records) receiver.acceptRecord(r); + }, 5); +} + +// 1b. Baseline: without clone (push only) +console.log("\n--- 1b. Baseline: push without clone ---"); +for (const count of RECORD_COUNTS) { + const records = generateRecords(count, 5); + bench(`Push-only ${count} records (no clone)`, () => { + const arr: Record[] = []; + const fieldNames = new Set(); + for (const r of records) { + for (const key of r.keys()) fieldNames.add(key); + arr.push(r); + } + }, 5); +} + +// 2. estimateSize cost +console.log("\n--- 2. estimateSize() cost (JSON.stringify every record) ---"); +for (const count of RECORD_COUNTS) { + const records = generateRecords(count, 5); + bench(`estimateSize ${count} records (toString per record)`, () => { + let _size = 0; + for (const r of records) _size += r.toString().length * 2; + }, 5); +} + +// 2b. Alternative: rough estimate from record count +console.log("\n--- 2b. Alternative estimateSize (sampling) ---"); +for (const count of RECORD_COUNTS) { + const records = generateRecords(count, 5); + bench(`estimateSize-sampling ${count} records`, () => { + if (records.length === 0) return; + const sampleSize = Math.min(10, records.length); + let sampleTotal = 0; + for (let i = 0; i < sampleSize; i++) { + sampleTotal += records[i]!.toString().length * 2; + } + void ((sampleTotal / sampleSize) * records.length); + }, 5); +} + +// 3. LRU eviction with many cache entries +console.log("\n--- 3. LRU eviction O(n) scan ---"); +for (const entryCount of [100, 500, 1000]) { + const config: CacheConfig = { + maxMemoryBytes: entryCount * 1000 + 500, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + bench(`LRU eviction with ${entryCount} entries`, () => { + const mgr = new CacheManager(config); + for (let i = 0; i < entryCount; i++) { + mgr.put(makeCachedResult("in1", `s${i}`, 1000)); + } + // Trigger an eviction + mgr.put(makeCachedResult("in1", "trigger", 1000)); + }, 10); +} + +// 4. Cascade invalidation with many entries +console.log("\n--- 4. Cascade invalidation ---"); +for (const stageCount of [20, 50, 100]) { + const stageIds = Array.from({ length: stageCount }, (_, i) => `s${i}`); + const stages = new Map(); + for (let i = 0; i < stageCount; i++) { + stages.set(stageIds[i]!, makeStage(stageIds[i]!, "grep", [], i === 0 ? null : stageIds[i - 1]!, i)); + } + const forks = new Map([ + ["main", { id: "main", name: "main", forkPointStageId: null, parentForkId: null, stageIds, createdAt: Date.now() }], + ]); + const config: CacheConfig = { + maxMemoryBytes: 512 * 1024 * 1024, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + + bench(`Cascade invalidation ${stageCount} stages (from middle)`, () => { + const mgr = new CacheManager(config); + for (let i = 0; i < stageCount; i++) { + mgr.put(makeCachedResult("in1", stageIds[i]!, 1000)); + } + // Invalidate from the middle + mgr.invalidateCascade(stageIds[Math.floor(stageCount / 2)]!, forks, stages); + }, 20); +} + +// 5. Disk spill (synchronous I/O) +console.log("\n--- 5. Disk spill (sync writeFileSync) ---"); +const spillDir = "/tmp/recs-bench-spill"; +for (const count of [100, 1000, 5000]) { + const records = generateRecords(count, 5); + bench(`spillToDisk ${count} records`, () => { + const mgr = new CacheManager( + { maxMemoryBytes: 512 * 1024 * 1024, cachePolicy: "all", pinnedStageIds: new Set() }, + spillDir, + ); + mgr.spillToDisk("bench-key", records as any); + }, 5); +} +if (existsSync(spillDir)) rmSync(spillDir, { recursive: true, force: true }); + +// 6. Full pipeline execution (end-to-end) +console.log("\n--- 6. Full pipeline execution (grep → sort → xform) ---"); +for (const count of RECORD_COUNTS) { + const inputRecords = generateRecords(count, 5); + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "bench", + }; + const stages = [ + makeStage("s1", "grep", ["{{id}} > 10"], null, 0), + makeStage("s2", "sort", ["--key", "id=n"], "s1", 1), + makeStage("s3", "xform", ["{{doubled}} = {{id}} * 2"], "s2", 2), + ]; + + await benchAsync(`Pipeline (3 stages) ${count} records`, async () => { + const state = makePipelineState(stages, input); + await executeToStage(state, "s3"); + }, 3); +} + +// 7. SHA-256 cache key computation +console.log("\n--- 7. SHA-256 cache key computation ---"); +for (const depth of [5, 10, 20]) { + const stageArr: Stage[] = []; + for (let i = 0; i < depth; i++) { + stageArr.push(makeStage(`s${i}`, "grep", [`{{id}} > ${i}`], i === 0 ? null : `s${i - 1}`, i)); + } + const config: CacheConfig = { + maxMemoryBytes: 512 * 1024 * 1024, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + const mgr = new CacheManager(config); + + bench(`computeCacheKey depth=${depth}`, () => { + mgr.computeCacheKey("in1", stageArr, depth - 1); + }, 100); +} + +console.log("\n=== Benchmark Complete ==="); diff --git a/scripts/check-no-private.ts b/scripts/check-no-private.ts new file mode 100644 index 0000000..59fa93f --- /dev/null +++ b/scripts/check-no-private.ts @@ -0,0 +1,110 @@ +/** + * CI/lint script that ensures no TypeScript visibility modifiers (private, + * protected, public) or JavaScript private class fields (#) appear in the + * Explorer codebase. + * + * Usage: bun scripts/check-no-private.ts + * Exit code 0 = all good, non-zero = violations found. + */ + +import { readdirSync, statSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = join(import.meta.dir, ".."); +const SCAN_DIRS = [ + join(ROOT, "src", "explorer"), + join(ROOT, "tests", "explorer"), +]; + +const EXTENSIONS = new Set([".ts", ".tsx"]); + +/** + * Patterns to detect: + * 1. TypeScript visibility modifiers on class members: private, protected, public + * 2. JavaScript private class fields: #fieldName (but not hex color literals like "#FFFFFF") + */ +const VISIBILITY_RE = + /^\s*(?:(?:static|readonly|abstract|override|async)\s+)*(?:private|protected|public)\s+/; +const PRIVATE_FIELD_RE = /(?:this\.#\w|^\s*#\w+[\s:;(=])/; + +interface Violation { + file: string; + line: number; + text: string; + kind: string; +} + +function collectFiles(dir: string): string[] { + const results: string[] = []; + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return results; + } + for (const entry of entries) { + const full = join(dir, entry); + const st = statSync(full, { throwIfNoEntry: false }); + if (!st) continue; + if (st.isDirectory()) { + results.push(...collectFiles(full)); + } else if (EXTENSIONS.has(full.slice(full.lastIndexOf(".")))) { + results.push(full); + } + } + return results; +} + +function scan(): Violation[] { + const violations: Violation[] = []; + + for (const dir of SCAN_DIRS) { + for (const file of collectFiles(dir)) { + const content = readFileSync(file, "utf-8"); + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + + // Skip comment lines + if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) continue; + + if (VISIBILITY_RE.test(line)) { + violations.push({ + file: relative(ROOT, file), + line: i + 1, + text: line.trimStart(), + kind: "visibility modifier (private/protected/public)", + }); + } + + if (PRIVATE_FIELD_RE.test(line)) { + violations.push({ + file: relative(ROOT, file), + line: i + 1, + text: line.trimStart(), + kind: "JS private class field (#)", + }); + } + } + } + } + + return violations; +} + +const violations = scan(); + +if (violations.length === 0) { + console.log("check-no-private: OK — no visibility modifiers or private fields found."); + process.exit(0); +} else { + console.error( + `check-no-private: FAIL — found ${violations.length} violation(s):\n`, + ); + for (const v of violations) { + console.error(` ${v.file}:${v.line} [${v.kind}]`); + console.error(` ${v.text}\n`); + } + process.exit(1); +} diff --git a/src/Record.ts b/src/Record.ts index db0e23e..e73c6d5 100644 --- a/src/Record.ts +++ b/src/Record.ts @@ -78,9 +78,10 @@ export class Record { /** * Return a deep clone of this record. + * Uses a fast JSON-specific clone (no circular-ref handling needed). */ clone(): Record { - return new Record(structuredClone(this.#data)); + return new Record(cloneJsonObject(this.#data)); } /** @@ -294,6 +295,49 @@ const COMPARATOR_TYPES: { numeric: cmpNumeric, }; +// --- Fast JSON-specific deep clone --- + +/** + * Deep-clone a JsonObject. Uses a fast path for flat objects (all primitive + * values) which is the common case in RecordStream pipelines. + */ +function cloneJsonObject(obj: JsonObject): JsonObject { + const keys = Object.keys(obj); + const out: JsonObject = {}; + let needsDeep = false; + for (let i = 0; i < keys.length; i++) { + const v = obj[keys[i]!]!; + if (v !== null && typeof v === "object") { + needsDeep = true; + break; + } + out[keys[i]!] = v; + } + if (!needsDeep) return out; + + // Slow path: some values are objects/arrays, deep-clone everything + const result: JsonObject = {}; + for (let i = 0; i < keys.length; i++) { + result[keys[i]!] = cloneJsonValue(obj[keys[i]!]!); + } + return result; +} + +function cloneJsonValue(val: JsonValue): JsonValue { + if (val === null || typeof val !== "object") return val; + if (Array.isArray(val)) { + const arr: JsonValue[] = Array.from({ length: val.length }); + for (let i = 0; i < val.length; i++) arr[i] = cloneJsonValue(val[i]!); + return arr; + } + const keys = Object.keys(val); + const out: JsonObject = {}; + for (let i = 0; i < keys.length; i++) { + out[keys[i]!] = cloneJsonValue(val[keys[i]!]!); + } + return out; +} + /** * Nested value access using pre-split key parts. * Supports "foo/bar" for nested hash and "#N" for array indices. diff --git a/src/explorer/components/App.tsx b/src/explorer/components/App.tsx new file mode 100644 index 0000000..2009e3c --- /dev/null +++ b/src/explorer/components/App.tsx @@ -0,0 +1,766 @@ +/** + * Root Explorer application component. + * + * Renders the WelcomeScreen when no input is provided, + * or the MainLayout when a pipeline is active. + * + * Integrates useReducer for state management, useInput for global + * keyboard handling, and wires together all sub-components. + * + * ─── Ink ↔ OpenTUI Mapping ─────────────────────────────────────── + * | OpenTUI | Ink | + * |--------------------------------|-------------------------------| + * | import { useKeyboard } | import { useInput } | + * | from "@opentui/react" | from "ink" | + * | useKeyboard((key) => {}) | useInput((input, key) => {}) | + * | key.name === "escape" | key.escape | + * | key.name === "return" | key.return | + * | key.name === "up" | key.upArrow | + * | key.name === "down" | key.downArrow | + * | key.name === "left" | key.leftArrow | + * | key.name === "right" | key.rightArrow | + * | key.name === "tab" | key.tab | + * | key.raw === "k" | input === "k" | + * | key.name === "c" && key.ctrl | input === "c" && key.ctrl | + * | | (from ink) | + * | | (from ink) | + * | | | + * | | | + * | | + manual scroll state | + * | renderer.destroy() | useApp().exit() | + * | createCliRenderer() | render() from ink | + * ────────────────────────────────────────────────────────────────── + */ + +import { useReducer, useState, useCallback, useRef } from "react"; +import { Box, useInput, useApp } from "ink"; +import type { ExplorerOptions } from "../index.tsx"; +import type { PipelineAction, StageConfig, FileSizeWarning } from "../model/types.ts"; +import { pipelineReducer, createInitialState } from "../model/reducer.ts"; +import { getCursorStage, getCursorOutput, getStageOutput, getDownstreamStages } from "../model/selectors.ts"; +import { exportAsPipeScript, exportAsChainCommand, exportAsOneLiner, copyToClipboard, shellEscape } from "../model/serialization.ts"; +import { detectInputOperation } from "../utils/file-detect.ts"; +import { useExecution } from "../hooks/useExecution.ts"; +import { useUndoRedo } from "../hooks/useUndoRedo.ts"; +import { useAutoSave } from "../hooks/useAutoSave.ts"; +import { useVimIntegration } from "../hooks/useVimIntegration.ts"; +import { ExportPicker, type ExportFormat } from "./modals/ExportPicker.tsx"; +import { WelcomeScreen, type SessionSummary } from "./WelcomeScreen.tsx"; +import { TitleBar } from "./TitleBar.tsx"; +import { PipelineBar } from "./PipelineBar.tsx"; +import { ForkTabs } from "./ForkTabs.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"; +import { ForkManager } from "./modals/ForkManager.tsx"; +import { InputSwitcher } from "./modals/InputSwitcher.tsx"; +import { LargeFileWarning, type CachePolicy } from "./modals/LargeFileWarning.tsx"; +import { SessionPicker, type SessionMatch } from "./modals/SessionPicker.tsx"; +import { SaveSessionModal } from "./modals/SaveSessionModal.tsx"; +import { RecordDetail } from "./modals/RecordDetail.tsx"; +import { FieldSpotlight } from "./modals/FieldSpotlight.tsx"; +import { SessionManager } from "../session/session-manager.ts"; + +export interface AppProps { + options: ExplorerOptions; + /** Pre-loaded session summaries for the welcome screen */ + sessions?: SessionSummary[]; + /** Session matches for the current input file (for resume prompt) */ + sessionMatches?: SessionMatch[]; +} + +type ModalState = + | { kind: "none" } + | { kind: "addStage"; position: "after" | "before" } + | { kind: "editStage" } + | { kind: "confirmDelete"; stageId: string } + | { kind: "help" } + | { kind: "exportPicker" } + | { kind: "forkManager" } + | { kind: "inputSwitcher" } + | { kind: "largeFileWarning"; warning: FileSizeWarning } + | { kind: "sessionPicker" } + | { kind: "saveSession" } + | { kind: "recordDetail"; recordIndex: number } + | { kind: "fieldSpotlight"; fieldName: string }; + +export function App({ options, sessions = [], sessionMatches = [] }: AppProps) { + const { exit } = useApp(); + const hasInput = Boolean(options.inputFile || options.sessionId); + const [welcomeDismissed, setWelcomeDismissed] = useState(false); + + const [state, dispatch] = useReducer(pipelineReducer, undefined, () => { + const initial = createInitialState(); + // If input file provided, add it as an input source + if (options.inputFile) { + let s = pipelineReducer(initial, { + type: "ADD_INPUT", + source: { kind: "file", path: options.inputFile }, + label: options.inputFile.split("/").pop() ?? options.inputFile, + }); + // Auto-detect file type and insert the appropriate fromXXX stage + const autoStage = detectInputOperation(options.inputFile); + if (autoStage) { + s = pipelineReducer(s, { + type: "ADD_STAGE", + afterStageId: null, + config: autoStage, + }); + } + return s; + } + return initial; + }); + + const [modal, setModal] = useState(() => + // Show session picker on launch if there are matching sessions + hasInput && sessionMatches.length > 0 + ? { kind: "sessionPicker" as const } + : { kind: "none" as const }, + ); + const [statusMessage, setStatusMessage] = useState(null); + + // Undo/redo derived state for the StatusBar + const undoRedo = useUndoRedo(state); + + // Auto-save hook + const autoSave = useAutoSave(state); + + // Vim/$EDITOR integration + const { openInEditor } = useVimIntegration(); + + // Wrap dispatch to notify auto-save of actions. + // Use a ref for state so wrappedDispatch identity stays stable — this is + // critical because useExecution has `dispatch` in its dependency array. + const stateRef = useRef(state); + stateRef.current = state; + const autoSaveRef = useRef(autoSave); + autoSaveRef.current = autoSave; + + const wrappedDispatch = useCallback( + (action: PipelineAction) => { + dispatch(action); + // Notify auto-save after dispatching (state will be stale here, + // but auto-save debounces and uses latestState on save) + autoSaveRef.current.onAction(action, stateRef.current); + }, + [], + ); + + // Automatic pipeline execution: triggers on cursor/input/cache changes + useExecution(state, wrappedDispatch); + + const showStatus = useCallback((msg: string) => { + setStatusMessage(msg); + setTimeout(() => setStatusMessage(null), 3000); + }, []); + + // Global keyboard handler (Ink useInput) + useInput((input, key) => { + // Ctrl+C: always active — close modal if open, otherwise exit + if (input === "c" && key.ctrl) { + if (modal.kind !== "none") { + setModal({ kind: "none" }); + } else { + void autoSave.saveNow(state).finally(() => { + exit(); + }); + } + return; + } + + // Skip when a modal is open — modals handle their own input + if (modal.kind !== "none") return; + + // Global keys (always active) + // When the terminal event loop is busy, separate keystrokes can arrive + // batched in a single DATA event (e.g., "nn" instead of two "n" events). + // Use includes() for single-key commands to handle this case. + if (input.includes("q") && !key.ctrl) { + // Save session before quitting + void autoSave.saveNow(state).finally(() => { + exit(); + }); + return; + } + if (input.includes("?")) { + setModal({ kind: "help" }); + return; + } + if (input.includes("u")) { + wrappedDispatch({ type: "UNDO" }); + return; + } + if (input === "r" && key.ctrl) { + wrappedDispatch({ type: "REDO" }); + return; + } + if (key.tab) { + wrappedDispatch({ type: "TOGGLE_FOCUS" }); + return; + } + if (input.includes("x") && !key.shift) { + const script = exportAsPipeScript(state); + void copyToClipboard(script).then((ok) => { + showStatus(ok ? "Copied pipe script!" : "Export: clipboard failed"); + }); + return; + } + if (input.includes("X")) { + setModal({ kind: "exportPicker" }); + return; + } + if (input.includes("c") && !key.ctrl) { + // Yield to inspector's collate when a column is highlighted + if (!(state.focusedPanel === "inspector" && state.inspector.highlightedColumn !== null)) { + const oneLiner = exportAsOneLiner(state); + void copyToClipboard(oneLiner).then((ok) => { + showStatus(ok ? "Copied pipeline!" : "Clipboard failed"); + }); + return; + } + } + if (input.includes("v")) { + const output = getCursorOutput(state); + if (output && output.records.length > 0) { + void openInEditor(output.records); + } else { + showStatus("No records to export"); + } + return; + } + + // Save session + if (input.includes("S")) { + setModal({ kind: "saveSession" }); + return; + } + + // Fork/input global keys + if (input.includes("f")) { + // Create fork at cursor directly with auto-name + if (state.cursorStageId) { + const forkName = `fork-${state.forks.size}`; + wrappedDispatch({ type: "CREATE_FORK", name: forkName, atStageId: state.cursorStageId }); + showStatus(`Created fork "${forkName}" at cursor`); + } else { + showStatus("No cursor stage — cannot fork"); + } + return; + } + if (input.includes("b")) { + setModal({ kind: "forkManager" }); + return; + } + if (input.includes("i")) { + setModal({ kind: "inputSwitcher" }); + return; + } + if (input.includes("p")) { + // Pin/unpin stage for selective caching + if (state.cursorStageId) { + wrappedDispatch({ type: "PIN_STAGE", stageId: state.cursorStageId }); + showStatus("Toggled stage pin"); + } + return; + } + + // Pipeline panel keys + if (state.focusedPanel === "pipeline") { + if (key.leftArrow || key.upArrow || input.includes("h") || input.includes("k")) { + wrappedDispatch({ type: "MOVE_CURSOR", direction: "up" }); + return; + } + if (key.rightArrow || key.downArrow || input.includes("l") || input.includes("j")) { + wrappedDispatch({ type: "MOVE_CURSOR", direction: "down" }); + return; + } + if (input.includes("a") && !key.shift) { + setModal({ kind: "addStage", position: "after" }); + return; + } + if (input.includes("A")) { + if (state.cursorStageId) { + setModal({ kind: "addStage", position: "before" }); + } + return; + } + if (input.includes("d")) { + if (state.cursorStageId) { + setModal({ kind: "confirmDelete", stageId: state.cursorStageId }); + } + return; + } + if (input.includes("e")) { + if (state.cursorStageId) { + setModal({ kind: "editStage" }); + } + return; + } + if (input.includes(" ")) { + if (state.cursorStageId) { + wrappedDispatch({ type: "TOGGLE_STAGE", stageId: state.cursorStageId }); + } + return; + } + if (input.includes("r") && !key.ctrl) { + if (state.cursorStageId) { + wrappedDispatch({ type: "INVALIDATE_STAGE", stageId: state.cursorStageId }); + const downstream = getDownstreamStages(state, state.cursorStageId); + for (const s of downstream) { + wrappedDispatch({ type: "INVALIDATE_STAGE", stageId: s.id }); + } + showStatus("Re-running from cursor..."); + } + return; + } + if (input.includes("J")) { + if (state.cursorStageId) { + wrappedDispatch({ type: "REORDER_STAGE", stageId: state.cursorStageId, direction: "down" }); + } + return; + } + if (input.includes("K")) { + if (state.cursorStageId) { + wrappedDispatch({ type: "REORDER_STAGE", stageId: state.cursorStageId, direction: "up" }); + } + return; + } + if (key.return) { + wrappedDispatch({ type: "TOGGLE_FOCUS" }); + return; + } + } + + // Inspector panel keys + if (state.focusedPanel === "inspector") { + if (key.escape) { + // If a column is highlighted, clear it first; otherwise return to pipeline + if (state.inspector.highlightedColumn !== null) { + wrappedDispatch({ type: "CLEAR_COLUMN_HIGHLIGHT" }); + } else { + wrappedDispatch({ type: "TOGGLE_FOCUS" }); + } + return; + } + if (input === "t") { + const modes = ["table", "prettyprint", "json", "schema"] as const; + const currentIdx = modes.indexOf(state.inspector.viewMode as typeof modes[number]); + const nextIdx = (currentIdx + 1) % modes.length; + wrappedDispatch({ type: "SET_VIEW_MODE", viewMode: modes[nextIdx]! }); + return; + } + if (key.return) { + const output = getCursorOutput(state); + if (output && output.records.length > 0) { + setModal({ kind: "recordDetail", recordIndex: state.inspector.scrollOffset }); + } + return; + } + + // Column highlight navigation (table view only) + if (state.inspector.viewMode === "table") { + const output = getCursorOutput(state); + const fieldCount = output?.fieldNames.length ?? 0; + + if (key.leftArrow || input === "h") { + if (fieldCount > 0) { + wrappedDispatch({ type: "MOVE_COLUMN_HIGHLIGHT", direction: "left", fieldCount }); + } + return; + } + if (key.rightArrow || input === "l") { + if (fieldCount > 0) { + wrappedDispatch({ type: "MOVE_COLUMN_HIGHLIGHT", direction: "right", fieldCount }); + } + return; + } + + // Quick action keys — only when a column is highlighted + if (state.inspector.highlightedColumn !== null && output) { + const fieldName = output.fieldNames[state.inspector.highlightedColumn]; + if (fieldName) { + // g → grep: filter records where this field matches current value + if (input === "g") { + const record = output.records[state.inspector.scrollOffset]; + const value = record ? String(record.get(fieldName) ?? "") : ""; + const config: StageConfig = { + operationName: "grep", + args: [`\${${fieldName}} eq "${value}"`], + enabled: true, + }; + wrappedDispatch({ + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + }); + wrappedDispatch({ type: "CLEAR_COLUMN_HIGHLIGHT" }); + setModal({ kind: "editStage" }); + showStatus(`Added grep on ${fieldName}`); + return; + } + // s → sort by this field + if (input === "s") { + const config: StageConfig = { + operationName: "sort", + args: ["--key", fieldName], + enabled: true, + }; + wrappedDispatch({ + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + }); + wrappedDispatch({ type: "CLEAR_COLUMN_HIGHLIGHT" }); + setModal({ kind: "editStage" }); + showStatus(`Added sort on ${fieldName}`); + return; + } + // c → collate by this field with count aggregator + if (input === "c" && !key.ctrl) { + const config: StageConfig = { + operationName: "collate", + args: ["--key", fieldName, "--aggregator", "count,countAll"], + enabled: true, + }; + wrappedDispatch({ + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + }); + wrappedDispatch({ type: "CLEAR_COLUMN_HIGHLIGHT" }); + setModal({ kind: "editStage" }); + showStatus(`Added collate on ${fieldName}`); + return; + } + // F → open field spotlight + if (input === "F") { + setModal({ kind: "fieldSpotlight", fieldName }); + return; + } + } + } + } + } + }); + + const handleAddStageSelect = useCallback( + (operationName: string, initialArgs?: string[]) => { + const config: StageConfig = { + operationName, + args: initialArgs ?? [], + enabled: true, + }; + if (modal.kind === "addStage" && modal.position === "before" && state.cursorStageId) { + wrappedDispatch({ + type: "INSERT_STAGE_BEFORE", + beforeStageId: state.cursorStageId, + config, + }); + } else { + wrappedDispatch({ + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + }); + } + // Chain to edit args modal so user can configure the new stage + setModal({ kind: "editStage" }); + showStatus(`Added ${operationName} stage`); + }, + [modal, state.cursorStageId, showStatus, wrappedDispatch], + ); + + const handleEditStageSubmit = useCallback( + (args: string[]) => { + if (state.cursorStageId) { + wrappedDispatch({ + type: "UPDATE_STAGE_ARGS", + stageId: state.cursorStageId, + args, + }); + } + setModal({ kind: "none" }); + }, + [state.cursorStageId, wrappedDispatch], + ); + + const handleEditStagePipe = useCallback( + (args: string[]) => { + if (state.cursorStageId) { + wrappedDispatch({ + type: "UPDATE_STAGE_ARGS", + stageId: state.cursorStageId, + args, + }); + } + setModal({ kind: "addStage", position: "after" }); + }, + [state.cursorStageId, wrappedDispatch], + ); + + const handleConfirmDelete = useCallback(() => { + if (modal.kind === "confirmDelete") { + wrappedDispatch({ type: "DELETE_STAGE", stageId: modal.stageId }); + showStatus("Stage deleted"); + } + setModal({ kind: "none" }); + }, [modal, showStatus, wrappedDispatch]); + + 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], + ); + + const handleLargeFileConfirm = useCallback( + (policy: CachePolicy) => { + wrappedDispatch({ type: "SET_CACHE_POLICY", policy }); + // The file was already pending — add it now + if (modal.kind === "largeFileWarning") { + const { path } = modal.warning; + const label = path.split("/").pop() ?? path; + wrappedDispatch({ + type: "ADD_INPUT", + source: { kind: "file", path }, + label, + }); + showStatus(`Added large file with "${policy}" cache policy`); + } + setModal({ kind: "none" }); + }, + [modal, showStatus, wrappedDispatch], + ); + + const handleLargeFile = useCallback((warning: FileSizeWarning) => { + setModal({ kind: "largeFileWarning", warning }); + }, []); + + const handleSessionResume = useCallback( + (_sessionId: string) => { + // Session resume is handled at the index.tsx level; for now, dismiss + showStatus("Resuming session..."); + setModal({ kind: "none" }); + }, + [showStatus], + ); + + const handleSaveSession = useCallback( + (name: string, mode: "rename" | "save-as") => { + setModal({ kind: "none" }); + const mgr = new SessionManager(); + if (mode === "rename") { + wrappedDispatch({ type: "SET_SESSION_NAME", name }); + void mgr.rename(state.sessionId, name).then(() => { + void autoSave.saveNow(state); + showStatus(`Session renamed to "${name}"`); + }); + } else { + // save-as: create a new session with a new ID + void mgr.saveAs(state, name).then(() => { + wrappedDispatch({ type: "SET_SESSION_NAME", name }); + showStatus(`Saved as new session "${name}"`); + }); + } + }, + [state, wrappedDispatch, autoSave, showStatus], + ); + + const handleSessionStartFresh = useCallback(() => { + setModal({ kind: "none" }); + }, []); + + // Welcome screen — no input loaded + if (!hasInput && !welcomeDismissed) { + return ( + { + setWelcomeDismissed(true); + showStatus(`Resuming session ${sessionId}...`); + }} + onOpenFile={(filePath) => { + setWelcomeDismissed(true); + const label = filePath.split("/").pop() ?? filePath; + wrappedDispatch({ + type: "ADD_INPUT", + source: { kind: "file", path: filePath }, + label, + }); + // Auto-detect file type and insert fromXXX stage + const autoStage = detectInputOperation(filePath); + if (autoStage) { + wrappedDispatch({ + type: "ADD_STAGE", + afterStageId: null, + config: autoStage, + }); + } + }} + onNewPipeline={() => { + setWelcomeDismissed(true); + }} + /> + ); + } + + const cursorStage = getCursorStage(state); + const cursorLabel = cursorStage?.config.operationName; + + const isFullModal = modal.kind === "addStage" || modal.kind === "editStage" || modal.kind === "help"; + + return ( + + {/* Title bar */} + + + {/* Pipeline bar — compact shell-style pipeline display */} + + + + {/* Inspector panel — hidden when a full-screen modal is open */} + {!isFullModal && } + + {/* Status bar */} + {!isFullModal && } + + {/* Modals */} + {modal.kind === "addStage" && (() => { + const output = getCursorOutput(state); + return ( + setModal({ kind: "none" })} + afterLabel={modal.position === "before" ? `before: ${cursorLabel ?? "start"}` : cursorLabel} + records={output?.records} + fieldNames={output?.fieldNames} + /> + ); + })()} + {modal.kind === "editStage" && cursorStage && (() => { + const parentOutput = cursorStage.parentId + ? getStageOutput(state, cursorStage.parentId) + : undefined; + return ( + setModal({ kind: "none" })} + onPipe={handleEditStagePipe} + records={parentOutput?.records} + fieldNames={parentOutput?.fieldNames} + /> + ); + })()} + {modal.kind === "confirmDelete" && ( + setModal({ kind: "none" })} + /> + )} + {modal.kind === "help" && ( + setModal({ kind: "none" })} /> + )} + {modal.kind === "exportPicker" && ( + setModal({ kind: "none" })} + /> + )} + {modal.kind === "forkManager" && ( + setModal({ kind: "none" })} + onShowStatus={showStatus} + /> + )} + {modal.kind === "inputSwitcher" && ( + setModal({ kind: "none" })} + onShowStatus={showStatus} + onLargeFile={handleLargeFile} + /> + )} + {modal.kind === "largeFileWarning" && ( + setModal({ kind: "none" })} + /> + )} + {modal.kind === "sessionPicker" && ( + + )} + {modal.kind === "saveSession" && ( + setModal({ kind: "none" })} + /> + )} + {modal.kind === "recordDetail" && (() => { + const output = getCursorOutput(state); + return output && output.records.length > 0 ? ( + setModal({ kind: "none" })} + onShowStatus={showStatus} + /> + ) : null; + })()} + {modal.kind === "fieldSpotlight" && (() => { + const output = getCursorOutput(state); + return output ? ( + { + wrappedDispatch({ + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + }); + wrappedDispatch({ type: "CLEAR_COLUMN_HIGHLIGHT" }); + setModal({ kind: "editStage" }); + showStatus(`Added ${config.operationName} on ${modal.fieldName}`); + }} + onClose={() => setModal({ kind: "none" })} + /> + ) : null; + })()} + + ); +} diff --git a/src/explorer/components/ForkTabs.tsx b/src/explorer/components/ForkTabs.tsx new file mode 100644 index 0000000..6d77762 --- /dev/null +++ b/src/explorer/components/ForkTabs.tsx @@ -0,0 +1,49 @@ +/** + * ForkTabs — tab bar above stage list for switching between forks. + * + * Hidden when there is only one fork. When visible, shows fork names + * as selectable tabs using Ink + Catppuccin. + */ + +import { useMemo, memo } from "react"; +import { Box, Text } from "ink"; +import type { PipelineState } from "../model/types.ts"; +import { theme } from "../theme.ts"; + +export interface ForkTabsProps { + state: PipelineState; +} + +export const ForkTabs = memo(function ForkTabs({ state }: ForkTabsProps) { + const forks = useMemo( + () => Array.from(state.forks.values()).sort((a, b) => a.createdAt - b.createdAt), + [state.forks], + ); + + if (state.forks.size <= 1) return null; + + return ( + + {forks.map((fork) => { + const isActive = fork.id === state.activeForkId; + return ( + + + {isActive ? "[" : " "} + + + {fork.name} + + + {isActive ? "]" : " "} + + + ); + })} + + ); +}); diff --git a/src/tui/components/InspectorHeader.tsx b/src/explorer/components/InspectorHeader.tsx similarity index 55% rename from src/tui/components/InspectorHeader.tsx rename to src/explorer/components/InspectorHeader.tsx index 05dca75..7d3c989 100644 --- a/src/tui/components/InspectorHeader.tsx +++ b/src/explorer/components/InspectorHeader.tsx @@ -1,5 +1,8 @@ +import { memo } from "react"; +import { Box, Text } from "ink"; import type { PipelineState } from "../model/types.ts"; import { getCursorStage, getCursorOutput, getActivePath, getStageOutput } from "../model/selectors.ts"; +import { theme } from "../theme.ts"; export interface InspectorHeaderProps { state: PipelineState; @@ -13,31 +16,36 @@ function formatCacheAge(computedAt: number): string { return `${Math.floor(elapsed / 3_600_000)}h ago`; } -export function InspectorHeader({ state }: InspectorHeaderProps) { +export const InspectorHeader = memo(function InspectorHeader({ state }: InspectorHeaderProps) { const stage = getCursorStage(state); const output = getCursorOutput(state); if (!stage) { return ( - - Inspector: (select a stage) - + + Inspector: + (select a stage) + ); } if (state.executing) { return ( - - Inspector: {stage.config.operationName} (computing...) - + + Inspector: + {stage.config.operationName} + (computing...) + ); } if (state.lastError?.stageId === stage.id) { return ( - - Inspector: {stage.config.operationName} — ERROR - + + Inspector: + {stage.config.operationName} + — ERROR + ); } @@ -50,6 +58,8 @@ export function InspectorHeader({ state }: InspectorHeaderProps) { let countStr: string; if (!output) { countStr = "not cached"; + } else if (output.records.length === 0 && output.lines.length > 0) { + countStr = `${output.lines.length} lines (text output)`; } else if (totalRecords && totalRecords > 0 && output.recordCount !== totalRecords) { const pct = Math.round((output.recordCount / totalRecords) * 100); countStr = `${output.recordCount} of ${totalRecords} records (${pct}%)`; @@ -59,10 +69,14 @@ export function InspectorHeader({ state }: InspectorHeaderProps) { const cacheAge = output ? `, cached ${formatCacheAge(output.computedAt)}` : ""; return ( - - - Inspector: {stage.config.operationName} ({countStr}{cacheAge}) - - + + + Inspector: + {stage.config.operationName} + ({countStr} + {cacheAge} + ) + + ); -} +}); diff --git a/src/explorer/components/InspectorPanel.tsx b/src/explorer/components/InspectorPanel.tsx new file mode 100644 index 0000000..0d69080 --- /dev/null +++ b/src/explorer/components/InspectorPanel.tsx @@ -0,0 +1,53 @@ +import { memo } from "react"; +import { Box, Text } from "ink"; +import type { PipelineState } from "../model/types.ts"; +import { getCursorStage, getCursorOutput } from "../model/selectors.ts"; +import { InspectorHeader } from "./InspectorHeader.tsx"; +import { RecordView } from "./RecordView.tsx"; +import { theme } from "../theme.ts"; + +export interface InspectorPanelProps { + state: PipelineState; +} + +export const InspectorPanel = memo(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. + + ) : ( + + )} + + + ); +}); diff --git a/src/explorer/components/PipelineBar.tsx b/src/explorer/components/PipelineBar.tsx new file mode 100644 index 0000000..20603c6 --- /dev/null +++ b/src/explorer/components/PipelineBar.tsx @@ -0,0 +1,114 @@ +/** + * PipelineBar — horizontal shell-pipeline display. + * + * Shows the pipeline as a compact shell command string: + * recs fromps | grep 'x > 5' | sort --key x | **totable** + * + * The cursor stage is rendered bold. Disabled stages are dimmed. + * Stages with errors are red. Clicking ↑↓ moves the cursor. + */ + +import { useMemo, memo } from "react"; +import { Box, Text } from "ink"; +import type { PipelineState, Stage } from "../model/types.ts"; +import { getActivePath, getStageOutput } from "../model/selectors.ts"; +import { shellEscape } from "../model/serialization.ts"; +import { allDocs } from "../../cli/operation-registry.ts"; +import { theme } from "../theme.ts"; + +export interface PipelineBarProps { + state: PipelineState; +} + +/** + * Format a stage as a shell command fragment with shell-escaped arguments. + * Known recs operations get the `recs` prefix; shell commands do not. + */ +function formatStageDisplay(stage: Stage): string { + const isRecsOp = allDocs.some((d) => d.name === stage.config.operationName); + const parts = isRecsOp + ? ["recs", stage.config.operationName] + : [stage.config.operationName]; + for (const arg of stage.config.args) { + parts.push(shellEscape(arg)); + } + return parts.join(" "); +} + +export const PipelineBar = memo(function PipelineBar({ state }: PipelineBarProps) { + const stages = useMemo( + () => getActivePath(state), + [state.stages, state.forks, state.activeForkId], + ); + const isFocused = state.focusedPanel === "pipeline"; + + if (stages.length === 0) { + return ( + + (empty pipeline — press + a + to add a stage) + + ); + } + + return ( + + {stages.map((stage, idx) => { + const isCursor = stage.id === state.cursorStageId; + const isDisabled = !stage.config.enabled; + const isError = state.lastError?.stageId === stage.id; + const cached = getStageOutput(state, stage.id); + const stageText = formatStageDisplay(stage); + + let color: string | undefined; + if (isError) { + color = theme.red; + } else if (isDisabled) { + color = theme.overlay0; + } else if (isCursor) { + color = theme.lavender; + } else { + color = theme.subtext0; + } + + // Record/line count badge + let badge = ""; + if (cached) { + if (cached.records.length === 0 && cached.lines.length > 0) { + badge = ` [${cached.lines.length}☰]`; + } else { + badge = ` [${cached.recordCount}]`; + } + } + + return ( + + {idx > 0 && | } + + {stageText} + + {badge && ( + {badge} + )} + + ); + })} + + ); +}); diff --git a/src/explorer/components/RecordTable.tsx b/src/explorer/components/RecordTable.tsx new file mode 100644 index 0000000..fc03bed --- /dev/null +++ b/src/explorer/components/RecordTable.tsx @@ -0,0 +1,134 @@ +import { useMemo } from "react"; +import { Box, Text } from "ink"; +import type { CachedResult } from "../model/types.ts"; +import { theme } from "../theme.ts"; + +export interface RecordTableProps { + result: CachedResult; + scrollOffset: number; + maxRows?: number; + highlightedColumn?: number | null; +} + +export function RecordTable({ + result, + scrollOffset, + maxRows = 50, + highlightedColumn = null, +}: RecordTableProps) { + const fields = result.fieldNames; + + // Memoize the visible record slice + const visibleRecords = useMemo( + () => result.records.slice(scrollOffset, scrollOffset + maxRows), + [result.records, scrollOffset, maxRows], + ); + + // Memoize column width calculation — O(records × fields) + const colWidths = useMemo(() => { + if (fields.length === 0) return []; + const COL_MIN = 4; + const COL_MAX = 30; + return 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); + }); + }, [fields, visibleRecords]); + + // Memoize header cells + const headerCells = useMemo( + () => fields.map((f, i) => f.padEnd(colWidths[i]!).slice(0, colWidths[i]!)), + [fields, colWidths], + ); + + // Memoize row data + const rowsData = useMemo( + () => + 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 }; + }), + [visibleRecords, scrollOffset, fields, colWidths], + ); + + if (result.records.length === 0) { + return (no records); + } + if (fields.length === 0) { + return (no fields); + } + + const footer = + result.recordCount > scrollOffset + maxRows + ? `... (${result.recordCount} total)` + : ""; + + // When no column is highlighted, render plain text (fast path) + if (highlightedColumn === null || highlightedColumn < 0 || highlightedColumn >= fields.length) { + const header = "# " + headerCells.join(" "); + const rows = rowsData.map( + ({ rowNum, cells }) => `${rowNum} ${cells.join(" ")}`, + ); + return ( + + {header} + {rows.map((row, i) => ( + {row} + ))} + {footer ? {footer} : null} + + ); + } + + // Column highlight: render each row with segments so the highlighted column stands out + const hi = highlightedColumn; + + function renderSegments(prefix: string, cells: string[], isHeader: boolean) { + // Build before, highlighted, and after text segments + const before = cells.slice(0, hi).join(" "); + const highlighted = cells[hi]!; + const after = cells.slice(hi + 1).join(" "); + + const beforeText = prefix + (before ? before + " " : ""); + const afterText = after ? " " + after : ""; + + return ( + + {beforeText} + {highlighted} + {afterText} + + ); + } + + return ( + + {renderSegments("# ", headerCells, true)} + {rowsData.map(({ rowNum, cells }, i) => + {renderSegments(`${rowNum} `, cells, false)}, + )} + {footer ? {footer} : null} + + + Column: {fields[hi]} + | + g:grep + s:sort + c:collate + F:spotlight + Esc:clear + + + + ); +} diff --git a/src/explorer/components/RecordView.tsx b/src/explorer/components/RecordView.tsx new file mode 100644 index 0000000..b505d47 --- /dev/null +++ b/src/explorer/components/RecordView.tsx @@ -0,0 +1,128 @@ +/** + * RecordView — view mode router for the inspector panel. + * + * Renders the appropriate view based on the current inspector view mode: + * - table: RecordTable (columnar display) + * - prettyprint: pretty-printed JSON per record + * - json: raw JSON lines + * - schema: SchemaView (field analysis) + * + * The `t` key cycles through modes (handled in App.tsx global keyboard). + */ + +import { useMemo, memo } from "react"; +import { Box, Text } from "ink"; +import type { CachedResult, InspectorState } from "../model/types.ts"; +import { RecordTable } from "./RecordTable.tsx"; +import { SchemaView } from "./SchemaView.tsx"; +import { theme } from "../theme.ts"; + +export interface RecordViewProps { + result: CachedResult; + viewMode: InspectorState["viewMode"]; + scrollOffset: number; + highlightedColumn?: number | null; +} + +const PrettyPrintView = memo(function PrettyPrintView({ result, scrollOffset }: { result: CachedResult; scrollOffset: number }) { + const pageSize = 20; + const start = scrollOffset; + const end = Math.min(start + pageSize, result.records.length); + + const lines = useMemo( + () => result.records.slice(start, end).map((r, i) => + `Record ${start + i + 1}: ${JSON.stringify(r.toJSON(), null, 2)}`), + [result.records, start, end], + ); + + if (result.records.length === 0) { + return (no records); + } + return ( + + {lines.map((line, i) => ( + {line} + ))} + {result.recordCount > end && ( + ... ({result.recordCount} total, showing {start + 1}–{end}) + )} + + ); +}); + +const JsonView = memo(function JsonView({ result, scrollOffset }: { result: CachedResult; scrollOffset: number }) { + const pageSize = 50; + const start = scrollOffset; + const end = Math.min(start + pageSize, result.records.length); + + const lines = useMemo( + () => result.records.slice(start, end).map((r) => r.toString()), + [result.records, start, end], + ); + + if (result.records.length === 0) { + return (no records); + } + return ( + + {lines.map((line, i) => ( + {line} + ))} + {result.recordCount > end && ( + ... ({result.recordCount} total, showing {start + 1}–{end}) + )} + + ); +}); + +const TextView = memo(function TextView({ result, scrollOffset }: { result: CachedResult; scrollOffset: number }) { + const pageSize = 50; + const start = scrollOffset; + const totalLines = result.lines.length; + const end = Math.min(start + pageSize, totalLines); + + const visibleLines = useMemo( + () => result.lines.slice(start, end), + [result.lines, start, end], + ); + + if (totalLines === 0) { + return (no output); + } + return ( + + {visibleLines.map((line, i) => ( + {line} + ))} + {totalLines > end && ( + ... ({totalLines} lines total, showing {start + 1}–{end}) + )} + + ); +}); + +export const RecordView = memo(function RecordView({ result, viewMode, scrollOffset, highlightedColumn }: RecordViewProps) { + // Auto-detect text output: if the result has lines but no records, show text view + if (result.records.length === 0 && result.lines.length > 0) { + return ; + } + + switch (viewMode) { + case "table": + return ( + + ); + case "prettyprint": + return ; + case "json": + return ; + case "schema": + return ; + default: + return Unknown view mode; + } +}); diff --git a/src/explorer/components/SchemaView.tsx b/src/explorer/components/SchemaView.tsx new file mode 100644 index 0000000..0b46c30 --- /dev/null +++ b/src/explorer/components/SchemaView.tsx @@ -0,0 +1,149 @@ +/** + * SchemaView — field analysis view for the inspector panel. + * + * Displays a table of all fields found in the cached result: + * - Field name + * - Inferred type(s) (string, number, boolean, null, object, array) + * - Sample values (up to 3 distinct) + * - % populated (non-null/undefined across all records) + */ + +import { useMemo } from "react"; +import { Box, Text } from "ink"; +import type { CachedResult } from "../model/types.ts"; +import { theme } from "../theme.ts"; + +export interface SchemaViewProps { + result: CachedResult; +} + +export interface FieldSchema { + name: string; + types: string[]; + sampleValues: string[]; + populatedPct: number; +} + +export function inferType(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; // "string" | "number" | "boolean" | "object" +} + +export function analyzeFields(result: CachedResult): FieldSchema[] { + const { records, fieldNames } = result; + if (records.length === 0 || fieldNames.length === 0) return []; + + const totalRecords = records.length; + + return fieldNames.map((name) => { + const typeSet = new Set(); + const sampleSet = new Set(); + let populated = 0; + + for (const record of records) { + const val = record.get(name); + if (val !== null && val !== undefined) { + populated++; + typeSet.add(inferType(val)); + if (sampleSet.size < 3) { + const str = typeof val === "object" ? JSON.stringify(val) : String(val); + const truncated = str.length > 25 ? str.slice(0, 22) + "..." : str; + sampleSet.add(truncated); + } + } else { + typeSet.add("null"); + } + } + + const types = Array.from(typeSet).sort(); + const sampleValues = Array.from(sampleSet); + const populatedPct = totalRecords > 0 ? Math.round((populated / totalRecords) * 100) : 0; + + return { name, types, sampleValues, populatedPct }; + }); +} + +export function SchemaView({ result }: SchemaViewProps) { + const fields = useMemo(() => analyzeFields(result), [result]); + + if (fields.length === 0) { + return (no fields to analyze); + } + + // Compute column widths + const nameWidth = Math.min( + Math.max(5, ...fields.map((f) => f.name.length)), + 25, + ); + const typeWidth = Math.min( + Math.max(5, ...fields.map((f) => f.types.join(", ").length)), + 20, + ); + const pctWidth = 4; + const sampleWidth = 40; + + const headerStr = + "Field".padEnd(nameWidth) + + " " + + "Type".padEnd(typeWidth) + + " " + + "%Pop".padStart(pctWidth) + + " " + + "Sample Values"; + + const separator = "─".repeat(nameWidth + typeWidth + pctWidth + sampleWidth + 6); + + // Color for type labels + function typeColor(types: string[]): string { + if (types.length > 1) return theme.flamingo; + const t = types[0]; + if (t === "string") return theme.green; + if (t === "number") return theme.teal; + if (t === "boolean") return theme.yellow; + if (t === "null") return theme.overlay0; + if (t === "array") return theme.peach; + if (t === "object") return theme.mauve; + return theme.text; + } + + // Color for population percentage + function pctColor(pct: number): string { + if (pct >= 90) return theme.green; + if (pct >= 50) return theme.yellow; + return theme.red; + } + + return ( + + {headerStr} + {separator} + {fields.map((field, i) => { + const nameCol = field.name.padEnd(nameWidth).slice(0, nameWidth); + const typeCol = field.types.join(", ").padEnd(typeWidth).slice(0, typeWidth); + const pctCol = `${field.populatedPct}%`.padStart(pctWidth); + const sampleCol = field.sampleValues.join(", ").slice(0, sampleWidth); + return ( + + {nameCol} + + {typeCol} + + {pctCol} + + {sampleCol} + + ); + })} + + + {result.recordCount} + records, + {fields.length} + fields + + + + ); +} diff --git a/src/explorer/components/StatusBar.tsx b/src/explorer/components/StatusBar.tsx new file mode 100644 index 0000000..599731b --- /dev/null +++ b/src/explorer/components/StatusBar.tsx @@ -0,0 +1,57 @@ +import { memo } from "react"; +import { Box, Text } from "ink"; +import type { PipelineState } from "../model/types.ts"; +import type { UseUndoRedoResult } from "../hooks/useUndoRedo.ts"; +import { theme } from "../theme.ts"; + +export interface StatusBarProps { + state: PipelineState; + statusMessage?: string | null; + undoRedo?: UseUndoRedoResult; +} + +export const StatusBar = memo(function StatusBar({ state, statusMessage, undoRedo }: StatusBarProps) { + const errorMsg = state.lastError?.message; + + // Context-sensitive keybindings based on focused panel + const keyDefs: Array<[string, string]> = + state.focusedPanel === "pipeline" + ? [["a","add"],["d","del"],["e","edit"],["S","save"],["f","fork"],["b","forks"],["i","input"],["x","export"],["u","undo"],["?","help"],["q","quit"]] + : [["↑↓","scroll"],["t","view"],["Tab","back"]]; + + // Build undo/redo counts + const undoCount = undoRedo?.undoCount ?? state.undoStack.length; + const redoCount = undoRedo?.redoCount ?? 0; + const canRedo = undoRedo?.canRedo ?? false; + + return ( + + {statusMessage ? ( + {statusMessage} + ) : errorMsg ? ( + Error: {errorMsg} + ) : ( + + {keyDefs.map(([key, label], i) => ( + + {i > 0 && } + {key} + : + {label} + + ))} + + )} + + undo: + {undoCount} + {canRedo && ( + <> + redo: + {redoCount} + + )} + + + ); +}); diff --git a/src/explorer/components/TitleBar.tsx b/src/explorer/components/TitleBar.tsx new file mode 100644 index 0000000..f2aea36 --- /dev/null +++ b/src/explorer/components/TitleBar.tsx @@ -0,0 +1,41 @@ +import { memo } from "react"; +import { Box, Text } from "ink"; +import type { PipelineState } from "../model/types.ts"; +import { theme } from "../theme.ts"; + +export interface TitleBarProps { + state: PipelineState; +} + +export const TitleBar = memo(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 explorer + {state.sessionName ? — {state.sessionName} : ""} + + + input: + {inputLabel} + {countStr ? {countStr} : ""} + fork: + {forkLabel} + + [?] + + + ); +}); diff --git a/src/explorer/components/VimTextInput.tsx b/src/explorer/components/VimTextInput.tsx new file mode 100644 index 0000000..e95b1a3 --- /dev/null +++ b/src/explorer/components/VimTextInput.tsx @@ -0,0 +1,156 @@ +/** + * VimTextInput — A text input with vim-style editing. + * + * Wraps the pure vim-text-engine state machine in a React component + * that renders inline with chalk.inverse cursor, similar to ink-text-input. + * + * Starts in insert mode. Escape toggles to normal mode. Double-escape + * (Escape in normal mode with no pending operator) propagates to parent + * via the onEscape callback. + */ + +import { useState, useCallback, useEffect } from "react"; +import { Text, useInput } from "ink"; +import chalk from "chalk"; +import { + processInput, + initialState, + type VimState, + type VimMode, + type PendingOp, +} from "../utils/vim-text-engine.ts"; +import { theme } from "../theme.ts"; + +export interface VimTextInputProps { + value: string; + onChange: (value: string) => void; + onSubmit?: (value: string) => void; + onEscape?: () => void; + focus?: boolean; + placeholder?: string; +} + +export function VimTextInput({ + value, + onChange, + onSubmit, + onEscape, + focus = true, + placeholder = "", +}: VimTextInputProps) { + const [vimState, setVimState] = useState(() => initialState(value.length)); + + // Sync cursor offset if value changes externally + useEffect(() => { + setVimState((prev) => { + if (prev.mode === "insert") { + if (prev.cursorOffset > value.length) { + return { ...prev, cursorOffset: value.length }; + } + } else { + const maxPos = Math.max(0, value.length - 1); + if (prev.cursorOffset > maxPos) { + return { ...prev, cursorOffset: maxPos }; + } + } + return prev; + }); + }, [value]); + + const handleInput = useCallback( + (input: string, key: import("ink").Key) => { + const result = processInput(input, key, vimState, value); + + // Update state + setVimState(result.state); + + // Update value if changed + if (result.value !== value) { + onChange(result.value); + } + + // Handle escaped (double-escape) + if (result.escaped && onEscape) { + onEscape(); + return; + } + + // Handle submitted + if (result.submitted && onSubmit) { + onSubmit(result.value); + } + }, + [vimState, value, onChange, onSubmit, onEscape], + ); + + useInput(handleInput, { isActive: focus }); + + // ── Rendering ────────────────────────────────────────────── + + const { mode, cursorOffset, pending } = vimState; + + // Mode indicator prefix + const modeIndicator = renderModeIndicator(mode, pending); + + // Render the text with cursor + let renderedContent: string; + + if (!focus) { + // Unfocused — just show value or placeholder + renderedContent = value.length > 0 ? value : (placeholder ? chalk.gray(placeholder) : ""); + } else if (value.length === 0) { + // Empty value + if (placeholder) { + renderedContent = chalk.inverse(placeholder[0] ?? " ") + chalk.gray(placeholder.slice(1)); + } else { + renderedContent = chalk.inverse(" "); + } + } else if (mode === "insert") { + // Insert mode: bar cursor (can be past last char) + renderedContent = renderInsertCursor(value, cursorOffset); + } else { + // Normal mode: block cursor (on character, 0..len-1) + const clampedOffset = Math.min(cursorOffset, value.length - 1); + renderedContent = renderNormalCursor(value, clampedOffset); + } + + return ( + + {modeIndicator} {renderedContent} + + ); +} + +function renderModeIndicator(mode: VimMode, pending: PendingOp): string { + if (mode === "insert") { + return chalk.hex(theme.blue)("[I]"); + } + if (pending) { + const opLabel = pending.kind; + return chalk.hex(theme.peach)(`[N:${opLabel}]`); + } + return chalk.hex(theme.peach)("[N]"); +} + +function renderInsertCursor(value: string, offset: number): string { + // Insert mode: cursor sits between characters (bar semantic) + // If cursor is at end, append inverse space + if (offset >= value.length) { + return value + chalk.inverse(" "); + } + // Cursor on a character — show that char as inverse + return ( + value.slice(0, offset) + + chalk.inverse(value[offset]!) + + value.slice(offset + 1) + ); +} + +function renderNormalCursor(value: string, offset: number): string { + // Normal mode: block cursor on the character + return ( + value.slice(0, offset) + + chalk.inverse(value[offset]!) + + value.slice(offset + 1) + ); +} diff --git a/src/explorer/components/WelcomeScreen.tsx b/src/explorer/components/WelcomeScreen.tsx new file mode 100644 index 0000000..12e4cc4 --- /dev/null +++ b/src/explorer/components/WelcomeScreen.tsx @@ -0,0 +1,200 @@ +/** + * WelcomeScreen — shown when `recs explorer` is launched with no arguments. + * + * Displays: + * - Recent sessions list (navigable) + * - File opener (path input) + * - New empty pipeline option + * + * Keyboard: + * - [o] Open a file by path + * - [Enter] Resume selected session + * - [n] Start a new empty pipeline + * - [q] Quit + */ + +import { useState, useCallback } from "react"; +import { Box, Text, useInput, useApp } from "ink"; +import { theme } from "../theme.ts"; + +export interface SessionSummary { + sessionId: string; + name?: string; + inputLabel: string; + stageCount: number; + lastAccessedAt: number; +} + +export interface WelcomeScreenProps { + /** Recent sessions to display */ + sessions: SessionSummary[]; + /** Called when user selects a session to resume */ + onResumeSession: (sessionId: string) => void; + /** Called when user opens a file */ + onOpenFile: (filePath: string) => void; + /** Called when user starts a new empty pipeline */ + onNewPipeline: () => void; + /** @deprecated Kept for App.tsx compat during migration; uses useApp().exit() internally */ + renderer?: unknown; +} + +type Mode = "list" | "fileInput"; + +function formatTimeAgo(timestamp: number): string { + const elapsed = Date.now() - timestamp; + if (elapsed < 60_000) return "just now"; + if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`; + if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`; + return `${Math.floor(elapsed / 86_400_000)}d ago`; +} + +export function WelcomeScreen({ + sessions, + onResumeSession, + onOpenFile, + onNewPipeline, +}: WelcomeScreenProps) { + const { exit } = useApp(); + const [mode, setMode] = useState("list"); + const [selectedIndex, setSelectedIndex] = useState(0); + const [filePath, setFilePath] = useState(""); + + const handleFileSubmit = useCallback(() => { + const trimmed = filePath.trim(); + if (trimmed.length > 0) { + onOpenFile(trimmed); + } + }, [filePath, onOpenFile]); + + useInput((input, key) => { + if (mode === "fileInput") { + if (key.escape) { + setMode("list"); + return; + } + if (key.return) { + handleFileSubmit(); + return; + } + // Handle text input for file path + if (key.backspace || key.delete) { + setFilePath((prev) => prev.slice(0, -1)); + return; + } + if (input && !key.ctrl && !key.meta) { + setFilePath((prev) => prev + input); + } + return; + } + + // List mode + // Handle special keys first (these are unaffected by PTY batching + // because escape sequences and control chars are split by the parser) + if (input === "c" && key.ctrl) { + exit(); + return; + } + if (key.upArrow) { + setSelectedIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex((i) => Math.min(sessions.length - 1, i + 1)); + return; + } + if (key.return) { + if (sessions.length > 0 && sessions[selectedIndex]) { + onResumeSession(sessions[selectedIndex]!.sessionId); + } + return; + } + + // Process printable characters individually. + // When the terminal event loop is busy, multiple keystrokes can arrive + // batched in a single DATA event (e.g. "nn" instead of two separate "n" + // events). Iterating over each character ensures single-key commands + // still match. + for (const ch of input) { + if (ch === "q") { exit(); return; } + if (ch === "o") { setMode("fileInput"); return; } + if (ch === "n") { onNewPipeline(); return; } + if (ch === "k") { setSelectedIndex((i) => Math.max(0, i - 1)); return; } + if (ch === "j") { setSelectedIndex((i) => Math.min(sessions.length - 1, i + 1)); return; } + } + }); + + return ( + + + + Welcome to recs explorer + + + Open a file to start building a pipeline: + + + {/* Recent sessions */} + {sessions.length > 0 ? ( + + Recent sessions: + {sessions.map((session, idx) => { + const isSelected = idx === selectedIndex && mode === "list"; + const stageLabel = session.stageCount === 1 ? "1 stage" : `${session.stageCount} stages`; + const timeLabel = formatTimeAgo(session.lastAccessedAt); + const primaryLabel = session.name ?? session.inputLabel; + const secondaryLabel = session.name ? ` (${session.inputLabel})` : ""; + return ( + + + {isSelected ? "> " : " "} + + + {primaryLabel} + + + {secondaryLabel} + + + {stageLabel} + , last used + {timeLabel} + + ); + })} + + ) : ( + No recent sessions + )} + + + + {/* File input mode */} + {mode === "fileInput" ? ( + + + File path: + {filePath}| + + + + Enter:open + Esc:back + + + + ) : ( + + [o] Open file + [Enter] Resume session + [n] New empty pipeline + [q] Quit + + )} + + + ); +} diff --git a/src/explorer/components/modals/AddStageModal.tsx b/src/explorer/components/modals/AddStageModal.tsx new file mode 100644 index 0000000..1dbe360 --- /dev/null +++ b/src/explorer/components/modals/AddStageModal.tsx @@ -0,0 +1,819 @@ +/** + * AddStageModal — categorized operation picker with fuzzy search + preview, + * plus a stream preview panel showing current records. + * + * Layout: + * - Top: title bar + search input + * - Middle: two columns — operations list (left), operation preview (right) + * - Bottom: stream preview showing records at the current pipeline position + * + * Tab toggles focus between operations and stream preview. + * In stream preview: ↑↓ navigate records, Enter zooms into a record detail. + * Enter selects the operation (when operations focused). Esc cancels (or exits zoom). + */ + +import { useState, useMemo, useCallback, useRef } from "react"; +import { Box, Text, useInput, useStdout } from "ink"; +import { allDocs } from "../../../cli/operation-registry.ts"; +import type { CommandDoc } from "../../../types/CommandDoc.ts"; +import type { Record } from "../../../Record.ts"; +import type { JsonValue } from "../../../types/json.ts"; +import { VimTextInput } from "../VimTextInput.tsx"; +import { fuzzyFilter } from "../../utils/fuzzy-match.ts"; +import { theme } from "../../theme.ts"; + +export interface AddStageModalProps { + /** Called when user selects an operation, optionally with initial args */ + onSelect: (operationName: string, initialArgs?: string[]) => void; + /** Called when user cancels (Esc) */ + onCancel: () => void; + /** Label shown in title (e.g., "after: grep") */ + afterLabel?: string; + /** Records at the current pipeline position (for stream preview) */ + records?: Record[]; + /** Field names from the current cached result */ + fieldNames?: 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 formatPreviewLines(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; +} + +// ── Stream Preview helpers ────────────────────────────────────── + +const COL_MIN = 4; +const COL_MAX = 20; + +function computeColumnWidths(fields: string[], records: Record[]): number[] { + return fields.map((field) => { + let maxWidth = field.length; + for (const record of records) { + 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); + }); +} + +// ── Record Zoom helpers (inline detail view) ──────────────────── + +/** Color a value based on its JSON type. */ +function valueColor(value: JsonValue): string { + if (value === null || value === undefined) return theme.overlay0; + if (typeof value === "string") return theme.green; + if (typeof value === "number") return theme.teal; + if (typeof value === "boolean") return theme.yellow; + return theme.text; +} + +/** Format a value for display (single line). */ +function formatValue(value: JsonValue): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return `Array(${value.length})`; + if (typeof value === "object") return `Object(${Object.keys(value).length})`; + return String(value); +} + +interface TreeRow { + depth: number; + label: string; + value: JsonValue; + isContainer: boolean; + path: string; + childCount: number; +} + +function flattenValue( + value: JsonValue, + collapsed: Set, + parentPath: string, + depth: number, + label: string, +): TreeRow[] { + const path = parentPath ? `${parentPath}.${label}` : label; + + if (value === null || value === undefined) { + return [{ depth, label, value: null, isContainer: false, path, childCount: 0 }]; + } + + if (typeof value === "object" && !Array.isArray(value)) { + const keys = Object.keys(value); + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: keys.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (const key of keys) { + rows.push(...flattenValue(value[key]!, collapsed, path, depth + 1, key)); + } + } + return rows; + } + + if (Array.isArray(value)) { + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: value.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (let i = 0; i < value.length; i++) { + rows.push(...flattenValue(value[i]!, collapsed, path, depth + 1, `[${i}]`)); + } + } + return rows; + } + + return [{ depth, label, value, isContainer: false, path, childCount: 0 }]; +} + +function flattenRecord(record: Record, collapsed: Set): TreeRow[] { + const data = record.toJSON(); + const rows: TreeRow[] = []; + for (const key of Object.keys(data)) { + rows.push(...flattenValue(data[key]!, collapsed, "", 0, key)); + } + return rows; +} + +// ── Focus areas ───────────────────────────────────────────────── + +type FocusArea = "operations" | "preview"; + +export function AddStageModal({ + onSelect, + onCancel, + afterLabel, + records, + fieldNames, +}: AddStageModalProps) { + const RECS_PREFIX = "recs "; + const [query, setQuery] = useState(RECS_PREFIX); + const [selectedIndex, setSelectedIndex] = useState(0); + const [focusArea, setFocusArea] = useState("operations"); + const [previewCursor, setPreviewCursor] = useState(0); + const [docScroll, setDocScroll] = useState(0); + + // Zoom state: which record index is zoomed (null = no zoom) + const [zoomedIndex, setZoomedIndex] = useState(null); + const [zoomCursorRow, setZoomCursorRow] = useState(0); + const [zoomCollapsed, setZoomCollapsed] = useState>(() => new Set()); + + const hasRecords = records && records.length > 0; + + // ── Dynamic height computation ──────────────────────────────── + // Distribute available terminal rows between the operations list, + // doc preview, and stream preview so the modal fills the screen. + const { stdout } = useStdout(); + const termRows = stdout?.rows ?? 40; + + // Fixed overhead lines: + // App chrome (TitleBar + ForkTabs + PipelineBar): 5 + // Modal border(2) + padding(2): 4 + // Title bar: 1, gap+search: 2, gap before 2-col: 1, + // scroll indicators in 2-col: 2, gap+footer: 2 → 8 + // Conditional: + // Stream preview: marginTop(1) + border(2) + title(1) + header(1) = 5 + const fixedOverhead = 5 + 4 + 8 + (hasRecords ? 5 : 0); + const available = Math.max(0, termRows - fixedOverhead); + + let opViewport: number; + let previewMaxRecords: number; + if (!hasRecords) { + opViewport = Math.max(6, available); + previewMaxRecords = 0; + } else { + // Split: 70% to ops/doc panel, 30% to stream preview + opViewport = Math.max(6, Math.floor(available * 0.7)); + previewMaxRecords = Math.max(2, available - opViewport); + } + // Doc viewport = lines available for doc text inside the column + // (column height is opViewport + 2, minus 2 scroll indicators = opViewport) + const docViewport = opViewport; + + const previewRecords = useMemo( + () => (records ?? []).slice(0, previewMaxRecords), + [records, previewMaxRecords], + ); + + // Detect recs vs shell mode based on query prefix + const isRecsMode = query.startsWith(RECS_PREFIX); + const recsFilter = isRecsMode ? query.slice(RECS_PREFIX.length) : ""; + // In shell mode, first word is command name, rest is args + const shellParts = !isRecsMode ? query.trim().split(/\s+/) : []; + const shellCommand = shellParts[0] ?? ""; + const shellArgs = shellParts.slice(1).join(" "); + + // Filter docs by fuzzy search, then group by category. + // When a query is active, show a flat list sorted by relevance + // instead of forcing category order (which buries exact matches). + // Only fuzzy-match the first word so typing args after the op name + // (e.g., "recs grep --key foo") doesn't break the match. + const recsFilterWord = recsFilter.split(/\s+/)[0] ?? ""; + const filteredDocs = useMemo( + () => + isRecsMode + ? fuzzyFilter( + allDocs.filter((d) => !HIDDEN_OPS.has(d.name)), + recsFilterWord, + (d) => `${d.name} ${d.description}`, + { + getName: (d) => d.name, + minScore: 50, + }, + ) + : [], + [isRecsMode, recsFilterWord], + ); + + const groups = useMemo(() => { + if (!isRecsMode) return []; + if (recsFilterWord.length > 0) { + // Active search: show flat list sorted by fuzzy relevance + return filteredDocs.length > 0 + ? [{ label: "RESULTS", docs: filteredDocs }] + : []; + } + return groupByCategory(filteredDocs); + }, [isRecsMode, recsFilter, filteredDocs]); + + // Flat list of visible docs (for index-based navigation) + const flatList = useMemo( + () => groups.flatMap((g) => g.docs), + [groups], + ); + + const selected = flatList[selectedIndex]; + + // Doc preview lines for the selected operation + const docLines = useMemo( + () => (selected ? formatPreviewLines(selected) : []), + [selected], + ); + + // Reset doc scroll when selection changes + const prevSelectedRef = useRef(selected); + if (prevSelectedRef.current !== selected) { + prevSelectedRef.current = selected; + // Can't call setDocScroll during render in strict mode, but this is Ink + // which doesn't use strict mode. For safety, we clamp in the render below. + setDocScroll(0); + } + + const docScrollClamped = Math.min(docScroll, Math.max(0, docLines.length - docViewport)); + const visibleDocLines = docLines.slice(docScrollClamped, docScrollClamped + docViewport); + const hasDocScrollUp = docScrollClamped > 0; + const hasDocScrollDown = docScrollClamped + docViewport < docLines.length; + + // Zoomed record tree rows + const zoomedRecord = zoomedIndex !== null ? previewRecords[zoomedIndex] : undefined; + const zoomRows = useMemo( + () => (zoomedRecord ? flattenRecord(zoomedRecord, zoomCollapsed) : []), + [zoomedRecord, zoomCollapsed], + ); + + const toggleZoomCollapse = useCallback( + (path: string) => { + setZoomCollapsed((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, + [], + ); + + // ── Keyboard: non-printable keys (always active) ────────────── + // VimTextInput handles Escape/Enter when operations is focused, + // so Escape only fires for non-operations focus areas. + useInput((_input, key) => { + // In zoom mode, let the secondary handler take over entirely + if (zoomedIndex !== null) return; + + if (key.escape && focusArea !== "operations") { + onCancel(); + return; + } + + if (key.tab) { + if (hasRecords) { + setFocusArea((f) => (f === "operations" ? "preview" : "operations")); + } + return; + } + + if (focusArea === "operations") { + if (key.upArrow) { + setSelectedIndex((i) => Math.max(0, i - 1)); + setDocScroll(0); + return; + } + if (key.downArrow) { + setSelectedIndex((i) => Math.min(flatList.length - 1, i + 1)); + setDocScroll(0); + return; + } + if (key.return) { + if (isRecsMode && selected) { + // Check for extra words after the operation name (inline args) + const afterOp = recsFilter.slice(selected.name.length).trim(); + if (afterOp) { + onSelect(selected.name, afterOp.split(/\s+/)); + } else { + onSelect(selected.name); + } + } else if (!isRecsMode && shellCommand) { + // Shell command mode + const args = shellParts.slice(1); + onSelect(shellCommand, args.length > 0 ? args : undefined); + } + return; + } + } + + // Ctrl+D / Ctrl+U scroll the doc preview by half a page + // Gate Ctrl+U behind focusArea !== "operations" to avoid conflict with + // VimTextInput's Ctrl+U (clear line) when the search box is focused. + const halfPage = Math.max(1, Math.floor(docViewport / 2)); + if (_input === "d" && key.ctrl) { + setDocScroll((s) => Math.min(Math.max(0, docLines.length - docViewport), s + halfPage)); + return; + } + if (_input === "u" && key.ctrl && focusArea !== "operations") { + setDocScroll((s) => Math.max(0, s - halfPage)); + return; + } + }); + + // ── Keyboard: zoom mode + preview navigation ───────────────── + // Active only when TextInput is NOT focused (preview/zoom). + // This handler may match printable chars (j/k/h/l/space) so it must + // be disabled while the user is typing in the search box. + useInput((input, key) => { + // ── Zoom mode input handling ────────────────────────────── + if (zoomedIndex !== null) { + if (key.escape) { + setZoomedIndex(null); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + return; + } + if (key.upArrow || input === "k") { + setZoomCursorRow((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow || input === "j") { + setZoomCursorRow((i) => Math.min(zoomRows.length - 1, i + 1)); + return; + } + if (input === " ") { + const row = zoomRows[zoomCursorRow]; + if (row?.isContainer) { + toggleZoomCollapse(row.path); + } + return; + } + // ←/→ navigate between records while zoomed + if (key.leftArrow || input === "h") { + if (zoomedIndex > 0) { + setZoomedIndex((i) => i! - 1); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + } + return; + } + if (key.rightArrow || input === "l") { + if (zoomedIndex < previewRecords.length - 1) { + setZoomedIndex((i) => i! + 1); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + } + return; + } + return; // Absorb all other input while zoomed + } + + // ── Preview navigation ──────────────────────────────────── + if (focusArea === "preview") { + if (key.upArrow || input === "k") { + setPreviewCursor((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow || input === "j") { + setPreviewCursor((i) => Math.min(previewRecords.length - 1, i + 1)); + return; + } + if (key.return) { + if (previewRecords.length > 0) { + setZoomedIndex(previewCursor); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + } + return; + } + } + }, { isActive: focusArea !== "operations" || zoomedIndex !== null }); + + const titleText = afterLabel + ? `Add Stage (after: ${afterLabel})` + : "Add Stage"; + + // ── Zoom view (replaces normal content) ──────────────────── + if (zoomedIndex !== null && zoomedRecord) { + const viewportHeight = 15; + let scrollTop = Math.max(0, zoomCursorRow - Math.floor(viewportHeight / 2)); + if (scrollTop + viewportHeight > zoomRows.length) { + scrollTop = Math.max(0, zoomRows.length - viewportHeight); + } + const visibleRows = zoomRows.slice(scrollTop, scrollTop + viewportHeight); + + return ( + + {/* Header */} + + + Record #{zoomedIndex + 1} + of {previewRecords.length} + + [Esc] back [←/→] prev/next + + + {/* Tree view */} + + {visibleRows.map((row, vi) => { + const actualIdx = scrollTop + vi; + const isSelected = actualIdx === zoomCursorRow; + const indent = " ".repeat(row.depth); + const marker = row.isContainer + ? zoomCollapsed.has(row.path) + ? "▶ " + : "▼ " + : " "; + + const labelText = `${indent}${marker}${row.label}`; + + if (row.isContainer) { + const summary = Array.isArray(row.value) + ? `Array(${row.childCount})` + : `Object(${row.childCount})`; + return ( + + {labelText}: {summary} + + ); + } + + return ( + + {labelText}: {formatValue(row.value)} + + ); + })} + {zoomRows.length === 0 && ( + (empty record) + )} + + + {/* Footer */} + + + ↑↓:navigate Space:toggle ←→:prev/next record Esc:back + + + + ); + } + + // ── Normal modal layout ──────────────────────────────────── + + // Compute stream preview table data + const previewFields = fieldNames ?? []; + const previewColWidths = useMemo( + () => computeColumnWidths(previewFields, previewRecords), + [previewFields, previewRecords], + ); + + // Build a flat list of entries (category headers + docs) for the scrolling viewport + const listEntries = useMemo(() => { + const entries: Array<{ kind: "header"; label: string } | { kind: "doc"; doc: CommandDoc; flatIdx: number }> = []; + let flatIdx = 0; + for (const group of groups) { + entries.push({ kind: "header", label: group.label }); + for (const doc of group.docs) { + entries.push({ kind: "doc", doc, flatIdx }); + flatIdx++; + } + } + return entries; + }, [groups]); + + // Find the entry index corresponding to the selected flatList index + const selectedEntryIdx = useMemo(() => { + for (let i = 0; i < listEntries.length; i++) { + const e = listEntries[i]!; + if (e.kind === "doc" && e.flatIdx === selectedIndex) return i; + } + return 0; + }, [listEntries, selectedIndex]); + + // Compute viewport scroll position to keep selected item visible + const listScrollTop = useMemo(() => { + const total = listEntries.length; + if (total <= opViewport) return 0; + // Center the selected item in the viewport + let top = selectedEntryIdx - Math.floor(opViewport / 2); + top = Math.max(0, Math.min(top, total - opViewport)); + return top; + }, [listEntries, selectedEntryIdx]); + + const visibleEntries = listEntries.slice(listScrollTop, listScrollTop + opViewport); + const hasScrollUp = listScrollTop > 0; + const hasScrollDown = listScrollTop + opViewport < listEntries.length; + + return ( + + {/* Title bar */} + + {titleText} + + [Esc(2x)] + cancel + {hasRecords && ( + <> + + [Tab] + stream + + )} + + + + {/* Command input */} + + $ + { + // Auto-transition: when the user types a space after a recognized + // operation name, switch to EditStageModal for live preview. + const isRecs = v.startsWith(RECS_PREFIX); + if (isRecs) { + const filter = v.slice(RECS_PREFIX.length); + const spaceIdx = filter.indexOf(" "); + if (spaceIdx > 0) { + const firstWord = filter.slice(0, spaceIdx); + const matched = allDocs.find( + (d) => d.name === firstWord && !HIDDEN_OPS.has(d.name), + ); + if (matched) { + const afterOp = filter.slice(spaceIdx + 1).trim(); + onSelect(matched.name, afterOp ? afterOp.split(/\s+/) : []); + return; + } + } + } else { + // Shell mode: transition after command name + space + const trimmed = v.trim(); + if (trimmed && v.includes(" ")) { + const parts = trimmed.split(/\s+/); + const cmd = parts[0]; + if (cmd) { + onSelect(cmd, parts.slice(1)); + return; + } + } + } + setQuery(v); + setSelectedIndex(0); + }} + onEscape={onCancel} + placeholder="recs grep ..." + focus={focusArea === "operations"} + /> + + + {/* Two-column content: operations + op preview (recs mode) */} + {isRecsMode ? ( + + {/* Left: scrolling categorized list — fixed height to prevent layout jitter */} + + {hasScrollUp ? " ↑ more" : ""} + {visibleEntries.map((entry, vi) => { + if (entry.kind === "header") { + const headerColor = entry.label === "TRANSFORM" ? theme.mauve + : entry.label === "INPUT" ? theme.blue + : entry.label === "OUTPUT" ? theme.green + : theme.subtext0; + return {entry.label}; + } + const isSel = entry.flatIdx === selectedIndex && focusArea === "operations"; + return ( + + {isSel ? "> " : " "} + {entry.doc.name} + + ); + })} + {hasScrollDown ? " ↓ more" : ""} + {flatList.length === 0 && ( + No matching operations + )} + + + {/* Right: scrolling doc preview pane */} + + {selected ? ( + <> + {hasDocScrollUp && ↑ Ctrl+U} + {!hasDocScrollUp && {" "}} + {visibleDocLines.map((line, i) => ( + {line} + ))} + {hasDocScrollDown && ↓ Ctrl+D} + + ) : ( + Select an operation to see details + )} + + + ) : ( + /* Shell command mode */ + + SHELL COMMAND + {" "} + {shellCommand ? ( + <> + Command: {shellCommand} + {shellArgs ? ( + Args: {shellArgs} + ) : ( + (no args — add after Enter) + )} + {" "} + Records will be serialized as JSONL, piped through the + command, and parsed back as records. + + ) : ( + Type a command name (e.g., head, tail, jq, grep ...) + )} + + )} + + {/* Stream preview — fixed height */} + {hasRecords && ( + + + + Stream Preview + + + {records!.length} rec{records!.length > previewMaxRecords ? ` (showing ${previewMaxRecords})` : ""} + + + + {previewFields.length > 0 ? ( + + {/* Header row */} + + {" # "} + {previewFields.map((f, i) => + f.padEnd(previewColWidths[i]!).slice(0, previewColWidths[i]!), + ).join(" ")} + + {/* Record rows + padding */} + {Array.from({ length: previewMaxRecords }, (_, ri) => { + const record = previewRecords[ri]; + if (!record) { + return {" "}; + } + const isSel = ri === previewCursor && focusArea === "preview"; + const prefix = isSel ? "> " : " "; + const rowNum = String(ri + 1).padStart(3); + const cells = previewFields.map((field, fi) => { + const val = record.get(field); + const str = val === null || val === undefined ? "" : String(val); + return str.padEnd(previewColWidths[fi]!).slice(0, previewColWidths[fi]!); + }); + return ( + + {prefix}{rowNum} {cells.join(" ")} + + ); + })} + + ) : ( + (no fields) + )} + + )} + + {/* Footer hint */} + + + {focusArea === "operations" + ? isRecsMode + ? "↑↓:navigate Enter:select Esc:vim Esc(2x):cancel ^D/^U:scroll docs" + : "Enter:add command Esc:vim Esc(2x):cancel" + : "↑↓:navigate Enter:zoom record Esc:cancel Tab:operations"} + + + + ); +} diff --git a/src/tui/components/modals/ConfirmDialog.tsx b/src/explorer/components/modals/ConfirmDialog.tsx similarity index 50% rename from src/tui/components/modals/ConfirmDialog.tsx rename to src/explorer/components/modals/ConfirmDialog.tsx index d0f6735..1533131 100644 --- a/src/tui/components/modals/ConfirmDialog.tsx +++ b/src/explorer/components/modals/ConfirmDialog.tsx @@ -4,7 +4,8 @@ * Used for stage deletion, fork deletion, etc. */ -import { useKeyboard } from "@opentui/react"; +import { Box, Text, useInput } from "ink"; +import { theme } from "../../theme.ts"; export interface ConfirmDialogProps { /** The question to display */ @@ -20,25 +21,31 @@ export function ConfirmDialog({ onConfirm, onCancel, }: ConfirmDialogProps) { - useKeyboard((key) => { - if (key.raw === "y" || key.name === "return") { + useInput((input, key) => { + if (input === "y" || key.return) { onConfirm(); - } else if (key.raw === "n" || key.name === "escape") { + } else if (input === "n" || key.escape) { onCancel(); } }); return ( - - {message} - - [y/Enter] confirm [n/Esc] cancel - - + {message} + + + [y/Enter] + confirm + [n/Esc] + cancel + + + ); } diff --git a/src/explorer/components/modals/EditStageModal.tsx b/src/explorer/components/modals/EditStageModal.tsx new file mode 100644 index 0000000..823ea9d --- /dev/null +++ b/src/explorer/components/modals/EditStageModal.tsx @@ -0,0 +1,863 @@ +/** + * EditStageModal — raw args text input for editing stage arguments, + * plus side-by-side input/output preview panels. + * + * Layout: + * - Top: title bar (operation name + hints) + * - Middle: args text input + * - Bottom: side-by-side input (upstream) / output (live preview) panels + * + * Tab cycles focus: args → input → output → args. + * In input/output panels: ↑↓ navigate records, Enter zooms into a record detail. + * Enter (in args) confirms the edit. Esc cancels (or exits zoom). + */ + +import { useCallback, useState, useMemo, useEffect } from "react"; +import { Box, Text, useInput, useStdout } from "ink"; +import type { Record } from "../../../Record.ts"; +import type { JsonValue } from "../../../types/json.ts"; +import { allDocs } from "../../../cli/operation-registry.ts"; +import type { CommandDoc } from "../../../types/CommandDoc.ts"; +import { VimTextInput } from "../VimTextInput.tsx"; +import { theme } from "../../theme.ts"; +import { createOperationOrShell } from "../../../operations/transform/chain.ts"; +import { InterceptReceiver } from "../../executor/intercept-receiver.ts"; + +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; + /** Called when user types | — confirms current args and adds a stage after */ + onPipe?: (args: string[]) => void; + /** Upstream records (output of the parent stage) */ + records?: Record[]; + /** Field names from the upstream cached result */ + fieldNames?: string[]; +} + +// ── Stream Preview helpers ────────────────────────────────────── + +const COL_MIN = 4; +const COL_MAX = 20; + +function computeColumnWidths(fields: string[], records: Record[]): number[] { + return fields.map((field) => { + let maxWidth = field.length; + for (const record of records) { + 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); + }); +} + +// ── Record Zoom helpers (inline detail view) ──────────────────── + +function valueColor(value: JsonValue): string { + if (value === null || value === undefined) return theme.overlay0; + if (typeof value === "string") return theme.green; + if (typeof value === "number") return theme.teal; + if (typeof value === "boolean") return theme.yellow; + return theme.text; +} + +function formatValue(value: JsonValue): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return `Array(${value.length})`; + if (typeof value === "object") return `Object(${Object.keys(value).length})`; + return String(value); +} + +interface TreeRow { + depth: number; + label: string; + value: JsonValue; + isContainer: boolean; + path: string; + childCount: number; +} + +function flattenValue( + value: JsonValue, + collapsed: Set, + parentPath: string, + depth: number, + label: string, +): TreeRow[] { + const path = parentPath ? `${parentPath}.${label}` : label; + + if (value === null || value === undefined) { + return [{ depth, label, value: null, isContainer: false, path, childCount: 0 }]; + } + + if (typeof value === "object" && !Array.isArray(value)) { + const keys = Object.keys(value); + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: keys.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (const key of keys) { + rows.push(...flattenValue(value[key]!, collapsed, path, depth + 1, key)); + } + } + return rows; + } + + if (Array.isArray(value)) { + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: value.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (let i = 0; i < value.length; i++) { + rows.push(...flattenValue(value[i]!, collapsed, path, depth + 1, `[${i}]`)); + } + } + return rows; + } + + return [{ depth, label, value, isContainer: false, path, childCount: 0 }]; +} + +function flattenRecord(record: Record, collapsed: Set): TreeRow[] { + const data = record.toJSON(); + const rows: TreeRow[] = []; + for (const key of Object.keys(data)) { + rows.push(...flattenValue(data[key]!, collapsed, "", 0, key)); + } + return rows; +} + +// ── Operation doc helpers ──────────────────────────────────────── + +function formatDocLines(doc: CommandDoc): string[] { + const lines: string[] = []; + 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("Examples:"); + for (const ex of doc.examples) { + lines.push(` ${ex.command}`); + if (ex.description) { + lines.push(` # ${ex.description}`); + } + } + } + return lines; +} + +// ── Shell preview safety ───────────────────────────────────────── + +/** Shell commands that are safe to auto-preview (read-only / filtering). */ +const SAFE_SHELL_COMMANDS = new Set([ + "head", "tail", "grep", "egrep", "fgrep", + "awk", "gawk", "sed", + "cut", "paste", "join", "column", + "sort", "uniq", "shuf", + "wc", "nl", "cat", "tac", + "tr", "rev", "fold", "fmt", + "jq", "yq", "gojq", +]); + +// ── Focus areas ───────────────────────────────────────────────── + +type FocusArea = "args" | "input" | "output"; + +const FOCUS_CYCLE: FocusArea[] = ["args", "input", "output"]; + +export function EditStageModal({ + operationName, + currentArgs, + onConfirm, + onCancel, + onPipe, + records, + fieldNames, +}: EditStageModalProps) { + const [value, setValue] = useState(currentArgs); + const [focusArea, setFocusArea] = useState("args"); + const [inputCursor, setInputCursor] = useState(0); + const [outputCursor, setOutputCursor] = useState(0); + + // Zoom state: which record index is zoomed (null = no zoom) + const [zoomedIndex, setZoomedIndex] = useState(null); + const [zoomSource, setZoomSource] = useState<"input" | "output">("input"); + const [zoomCursorRow, setZoomCursorRow] = useState(0); + const [zoomCollapsed, setZoomCollapsed] = useState>(() => new Set()); + + // Output preview state + const [outputRecords, setOutputRecords] = useState([]); + const [outputFieldNames, setOutputFieldNames] = useState([]); + const [outputLines, setOutputLines] = useState([]); + const [outputError, setOutputError] = useState(null); + + // Shell preview gating: recs ops and safe shell commands auto-preview; + // unknown shell commands require explicit opt-in via Ctrl+E. + const isRecsOp = useMemo( + () => allDocs.some((d) => d.name === operationName), + [operationName], + ); + const [shellPreviewEnabled, setShellPreviewEnabled] = useState(false); + const previewEnabled = isRecsOp || SAFE_SHELL_COMMANDS.has(operationName) || shellPreviewEnabled; + + // Doc help state + const [docScroll, setDocScroll] = useState(0); + const opDoc = useMemo(() => allDocs.find((d) => d.name === operationName), [operationName]); + const docLines = useMemo(() => (opDoc ? formatDocLines(opDoc) : []), [opDoc]); + + const hasRecords = records && records.length > 0; + const hasDoc = docLines.length > 0; + + // ── Dynamic height computation ──────────────────────────────── + // Distribute available terminal rows between the doc viewport and + // the record preview panels so the modal fills the screen. + const { stdout } = useStdout(); + const termRows = stdout?.rows ?? 40; + + // Fixed overhead lines that are always consumed: + // App chrome (TitleBar + ForkTabs + PipelineBar): 5 + // Modal border(2) + padding(2): 4 + // Title bar: 1, gap+cmd: 2, gap+args: 2, gap+footer: 2 → 7 + // Conditional overhead: + // Doc section: marginTop(1) + scroll indicators(2) = 3 + // Panel section: marginTop(1) + border(2) + title(1) + header(1) = 5 + const fixedOverhead = 5 + 4 + 7 + + (hasDoc ? 3 : 0) + + (hasRecords ? 5 : 0); + const available = Math.max(0, termRows - fixedOverhead); + + let docViewport: number; + let previewMaxRecords: number; + if (!hasDoc && !hasRecords) { + docViewport = 0; + previewMaxRecords = 0; + } else if (!hasRecords) { + docViewport = Math.max(3, available); + previewMaxRecords = 0; + } else if (!hasDoc) { + docViewport = 0; + previewMaxRecords = Math.max(3, available); + } else { + // Split: 55% to doc, 45% to records + docViewport = Math.max(3, Math.floor(available * 0.55)); + previewMaxRecords = Math.max(3, available - docViewport); + } + + const previewRecords = useMemo( + () => (records ?? []).slice(0, previewMaxRecords), + [records, previewMaxRecords], + ); + + // Debounced args for output preview + const [debouncedArgs, setDebouncedArgs] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebouncedArgs(value), 300); + return () => clearTimeout(timer); + }, [value]); + + // Execute preview when debounced args change (gated by previewEnabled) + useEffect(() => { + if (!previewEnabled) { + setOutputRecords([]); + setOutputFieldNames([]); + setOutputLines([]); + setOutputError(null); + return; + } + try { + const parsed = parseArgs(debouncedArgs); + const interceptor = new InterceptReceiver(); + const op = createOperationOrShell(operationName, parsed, interceptor); + for (const record of previewRecords) { + op.acceptRecord(record); + } + op.finish(); + setOutputRecords(interceptor.records.slice(0, previewMaxRecords)); + setOutputFieldNames([...interceptor.fieldNames]); + setOutputLines(interceptor.lines.slice(0, previewMaxRecords)); + setOutputError(null); + } catch (e: unknown) { + setOutputRecords([]); + setOutputFieldNames([]); + setOutputLines([]); + setOutputError(e instanceof Error ? e.message : String(e)); + } + }, [debouncedArgs, operationName, previewRecords, previewEnabled]); + + const outputPreviewRecords = outputRecords; + + const handleSubmit = useCallback( + (val: string) => { + const args = parseArgs(val); + onConfirm(args); + }, + [onConfirm], + ); + + // Intercept | character: confirm current args and pipe to a new stage + const handleArgsChange = useCallback( + (newValue: string) => { + if (newValue.includes("|")) { + const clean = newValue.replace(/\|/g, "").trimEnd(); + if (onPipe) { + onPipe(parseArgs(clean)); + } + return; + } + setValue(newValue); + }, + [onPipe], + ); + + // Determine which record set to use for zoom + const zoomRecordSet = zoomSource === "input" ? previewRecords : outputPreviewRecords; + const zoomedRecord = zoomedIndex !== null ? zoomRecordSet[zoomedIndex] : undefined; + const zoomRows = useMemo( + () => (zoomedRecord ? flattenRecord(zoomedRecord, zoomCollapsed) : []), + [zoomedRecord, zoomCollapsed], + ); + + const toggleZoomCollapse = useCallback( + (path: string) => { + setZoomCollapsed((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, + [], + ); + + // ── Keyboard: non-printable keys (always active) ────────────── + // VimTextInput handles Escape/Enter when args is focused, + // so this handler only fires for non-args focus areas. + useInput((_input, key) => { + // In zoom mode, let the secondary handler take over entirely + if (zoomedIndex !== null) return; + + if (key.escape && focusArea !== "args") { + onCancel(); + return; + } + + if (key.tab) { + if (hasRecords) { + setFocusArea((f) => { + const idx = FOCUS_CYCLE.indexOf(f); + return FOCUS_CYCLE[(idx + 1) % FOCUS_CYCLE.length]!; + }); + } + return; + } + + // Ctrl+E toggles live preview for unsafe shell commands + if (_input === "e" && key.ctrl && !isRecsOp) { + setShellPreviewEnabled((prev) => !prev); + return; + } + + // Ctrl+D / Ctrl+U scroll the doc help by half a page + // Gate Ctrl+U behind focusArea !== "args" to avoid conflict with VimTextInput's Ctrl+U + const halfPage = Math.max(1, Math.floor(docViewport / 2)); + if (_input === "d" && key.ctrl) { + setDocScroll((s) => Math.min(Math.max(0, docLines.length - docViewport), s + halfPage)); + return; + } + if (_input === "u" && key.ctrl && focusArea !== "args") { + setDocScroll((s) => Math.max(0, s - halfPage)); + return; + } + }); + + // ── Keyboard: zoom mode + preview navigation ───────────────── + // Active only when TextInput is NOT focused (input/output/zoom). + // This handler may match printable chars (j/k/h/l/space) so it must + // be disabled while the user is typing in the args box. + useInput((input, key) => { + // ── Zoom mode input handling ────────────────────────────── + if (zoomedIndex !== null) { + if (key.escape) { + setZoomedIndex(null); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + return; + } + if (key.upArrow || input === "k") { + setZoomCursorRow((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow || input === "j") { + setZoomCursorRow((i) => Math.min(zoomRows.length - 1, i + 1)); + return; + } + if (input === " ") { + const row = zoomRows[zoomCursorRow]; + if (row?.isContainer) { + toggleZoomCollapse(row.path); + } + return; + } + // ←/→ navigate between records while zoomed + if (key.leftArrow || input === "h") { + if (zoomedIndex > 0) { + setZoomedIndex((i) => i! - 1); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + } + return; + } + if (key.rightArrow || input === "l") { + if (zoomedIndex < zoomRecordSet.length - 1) { + setZoomedIndex((i) => i! + 1); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + } + return; + } + return; // Absorb all other input while zoomed + } + + // ── Input panel navigation ──────────────────────────────── + if (focusArea === "input") { + if (key.upArrow || input === "k") { + setInputCursor((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow || input === "j") { + setInputCursor((i) => Math.min(previewRecords.length - 1, i + 1)); + return; + } + if (key.return) { + if (previewRecords.length > 0) { + setZoomSource("input"); + setZoomedIndex(inputCursor); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + } + return; + } + } + + // ── Output panel navigation ─────────────────────────────── + if (focusArea === "output") { + if (key.upArrow || input === "k") { + setOutputCursor((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow || input === "j") { + setOutputCursor((i) => Math.min(outputPreviewRecords.length - 1, i + 1)); + return; + } + if (key.return) { + if (outputPreviewRecords.length > 0) { + setZoomSource("output"); + setZoomedIndex(outputCursor); + setZoomCursorRow(0); + setZoomCollapsed(new Set()); + } + return; + } + } + }, { isActive: focusArea !== "args" || zoomedIndex !== null }); + + // ── Zoom view (replaces normal content) ──────────────────── + if (zoomedIndex !== null && zoomedRecord) { + const viewportHeight = 15; + let scrollTop = Math.max(0, zoomCursorRow - Math.floor(viewportHeight / 2)); + if (scrollTop + viewportHeight > zoomRows.length) { + scrollTop = Math.max(0, zoomRows.length - viewportHeight); + } + const visibleRows = zoomRows.slice(scrollTop, scrollTop + viewportHeight); + + return ( + + {/* Header */} + + + Record #{zoomedIndex + 1} + of {zoomRecordSet.length} ({zoomSource}) + + [Esc] back [←/→] prev/next + + + {/* Tree view */} + + {visibleRows.map((row, vi) => { + const actualIdx = scrollTop + vi; + const isSelected = actualIdx === zoomCursorRow; + const indent = " ".repeat(row.depth); + const marker = row.isContainer + ? zoomCollapsed.has(row.path) + ? "▶ " + : "▼ " + : " "; + + const labelText = `${indent}${marker}${row.label}`; + + if (row.isContainer) { + const summary = Array.isArray(row.value) + ? `Array(${row.childCount})` + : `Object(${row.childCount})`; + return ( + + {labelText}: {summary} + + ); + } + + return ( + + {labelText}: {formatValue(row.value)} + + ); + })} + {zoomRows.length === 0 && ( + (empty record) + )} + + + {/* Footer */} + + + ↑↓:navigate Space:toggle ←→:prev/next record Esc:back + + + + ); + } + + // ── Normal modal layout ──────────────────────────────────── + + // Compute input panel table data + const inputFields = fieldNames ?? []; + const inputColWidths = useMemo( + () => computeColumnWidths(inputFields, previewRecords), + [inputFields, previewRecords], + ); + + // Compute output panel table data + const outputFields = outputFieldNames; + const outputColWidths = useMemo( + () => computeColumnWidths(outputFields, outputPreviewRecords), + [outputFields, outputPreviewRecords], + ); + + // Panel height: border(2) + panel title(1) + column header(1) + data rows + const panelHeight = previewMaxRecords + 4; + + return ( + + {/* Title bar */} + + + Edit: + {isRecsOp ? `recs ${operationName}` : operationName} + + + [Esc(2x)] + cancel + {hasRecords && ( + <> + + [Tab] + switch panel + + )} + + + + {/* Current command preview */} + + + $ + {isRecsOp ? `recs ${operationName}` : operationName} + {value ? {value} : (no args)} + + + + {/* Args input */} + + Args: + + + + {/* Operation help */} + {docLines.length > 0 && (() => { + const scrollClamped = Math.min(docScroll, Math.max(0, docLines.length - docViewport)); + const visibleLines = docLines.slice(scrollClamped, scrollClamped + docViewport); + const hasUp = scrollClamped > 0; + const hasDown = scrollClamped + docViewport < docLines.length; + return ( + + {hasUp && ↑ ^U} + {!hasUp && {" "}} + {visibleLines.map((line, i) => ( + {line} + ))} + {hasDown && ↓ ^D} + + ); + })()} + + {/* Side-by-side input/output preview */} + {hasRecords && ( + + {/* Input panel */} + + + + Input + + + {records!.length} rec{records!.length !== 1 ? "s" : ""} + + + + {inputFields.length > 0 ? ( + + {/* Header row */} + + {" # "} + {inputFields.map((f, i) => + f.padEnd(inputColWidths[i]!).slice(0, inputColWidths[i]!), + ).join(" ")} + + {/* Record rows + padding */} + {Array.from({ length: previewMaxRecords }, (_, ri) => { + const record = previewRecords[ri]; + if (!record) { + return {" "}; + } + const isSel = ri === inputCursor && focusArea === "input"; + const prefix = isSel ? "> " : " "; + const rowNum = String(ri + 1).padStart(3); + const cells = inputFields.map((field, fi) => { + const val = record.get(field); + const str = val === null || val === undefined ? "" : String(val); + return str.padEnd(inputColWidths[fi]!).slice(0, inputColWidths[fi]!); + }); + return ( + + {prefix}{rowNum} {cells.join(" ")} + + ); + })} + + ) : ( + (no fields) + )} + + + {/* Output panel */} + + + + Output + + + {outputError + ? "error" + : outputPreviewRecords.length > 0 + ? `${outputPreviewRecords.length} rec${outputPreviewRecords.length !== 1 ? "s" : ""}` + : outputLines.length > 0 + ? `${outputLines.length} line${outputLines.length !== 1 ? "s" : ""}` + : "empty"} + + + + {!previewEnabled ? ( + + Shell preview paused + ^E to enable live preview + {Array.from({ length: Math.max(0, previewMaxRecords - 2) }, (_, i) => ( + {" "} + ))} + + ) : outputError ? ( + + {outputError} + {Array.from({ length: previewMaxRecords - 1 }, (_, i) => ( + {" "} + ))} + + ) : outputFields.length > 0 ? ( + + {/* Header row */} + + {" # "} + {outputFields.map((f, i) => + f.padEnd(outputColWidths[i]!).slice(0, outputColWidths[i]!), + ).join(" ")} + + {/* Record rows + padding */} + {Array.from({ length: previewMaxRecords }, (_, ri) => { + const record = outputPreviewRecords[ri]; + if (!record) { + return {" "}; + } + const isSel = ri === outputCursor && focusArea === "output"; + const prefix = isSel ? "> " : " "; + const rowNum = String(ri + 1).padStart(3); + const cells = outputFields.map((field, fi) => { + const val = record.get(field); + const str = val === null || val === undefined ? "" : String(val); + return str.padEnd(outputColWidths[fi]!).slice(0, outputColWidths[fi]!); + }); + return ( + + {prefix}{rowNum} {cells.join(" ")} + + ); + })} + + ) : outputLines.length > 0 ? ( + + {/* Text line output (tojson, tocsv, toprettyprint, etc.) */} + {Array.from({ length: previewMaxRecords }, (_, li) => { + const line = outputLines[li]; + if (line === undefined) { + return {" "}; + } + const isSel = li === outputCursor && focusArea === "output"; + return ( + + {isSel ? "> " : " "}{line} + + ); + })} + + ) : ( + + (no output) + {Array.from({ length: previewMaxRecords - 1 }, (_, i) => ( + {" "} + ))} + + )} + + + )} + + {/* Footer hint */} + + + {focusArea === "args" + ? `Enter:confirm Esc:vim Esc(2x):cancel${hasRecords ? " Tab:input" : ""}${!isRecsOp ? ` ^E:${previewEnabled ? "pause" : "run"} preview` : ""}` + : focusArea === "input" + ? `↑↓:navigate Enter:zoom Tab:output Esc:cancel${!isRecsOp ? ` ^E:${previewEnabled ? "pause" : "run"} preview` : ""}` + : `↑↓:navigate Enter:zoom Tab:args Esc:cancel${!isRecsOp ? ` ^E:${previewEnabled ? "pause" : "run"} preview` : ""}`} + + + + ); +} + +/** + * 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/explorer/components/modals/ExportPicker.tsx similarity index 63% rename from src/tui/components/modals/ExportPicker.tsx rename to src/explorer/components/modals/ExportPicker.tsx index d1b5505..d0b78d0 100644 --- a/src/tui/components/modals/ExportPicker.tsx +++ b/src/explorer/components/modals/ExportPicker.tsx @@ -10,7 +10,8 @@ */ import { useState } from "react"; -import { useKeyboard } from "@opentui/react"; +import { Box, Text, useInput } from "ink"; +import { theme } from "../../theme.ts"; export type ExportFormat = "pipe-script" | "chain-command" | "save-file"; @@ -40,17 +41,17 @@ const OPTIONS: { format: ExportFormat; label: string; description: string }[] = export function ExportPicker({ onSelect, onCancel }: ExportPickerProps) { const [selectedIndex, setSelectedIndex] = useState(0); - useKeyboard((key) => { - if (key.name === "escape") { + useInput((input, key) => { + if (key.escape) { onCancel(); return; } - if (key.name === "up" || key.raw === "k") { + if (key.upArrow || input === "k") { setSelectedIndex((i) => Math.max(0, i - 1)); - } else if (key.name === "down" || key.raw === "j") { + } else if (key.downArrow || input === "j") { setSelectedIndex((i) => Math.min(OPTIONS.length - 1, i + 1)); - } else if (key.name === "return") { + } else if (key.return) { const option = OPTIONS[selectedIndex]; if (option) { onSelect(option.format); @@ -59,40 +60,39 @@ export function ExportPicker({ onSelect, onCancel }: ExportPickerProps) { }); return ( - - - - Export Pipeline - - [Esc] cancel - + + Export Pipeline + [Esc] cancel + - + {OPTIONS.map((opt, idx) => { const isSelected = idx === selectedIndex; return ( - - + {isSelected ? "> " : " "} {opt.label} - - {opt.description} - + + {opt.description} + ); })} - + - - ↑↓:navigate Enter:select Esc:cancel - - + + ↑↓:navigate Enter:select Esc:cancel + + ); } diff --git a/src/explorer/components/modals/FieldSpotlight.tsx b/src/explorer/components/modals/FieldSpotlight.tsx new file mode 100644 index 0000000..c61c4be --- /dev/null +++ b/src/explorer/components/modals/FieldSpotlight.tsx @@ -0,0 +1,242 @@ +/** + * FieldSpotlight — value distribution analysis for a single field. + * + * Activated by pressing `F` when a column is highlighted in table view. + * Shows: + * - Value frequency bar chart (Unicode blocks) + * - Numeric stats: min/max/avg (if applicable) + * - Navigable value list; Enter → grep for that value + * - s → sort, c → collate, Esc → close + */ + +import { useState, useMemo } from "react"; +import { Box, Text, useInput } from "ink"; +import type { CachedResult, StageConfig } from "../../model/types.ts"; +import { theme } from "../../theme.ts"; + +export interface FieldSpotlightProps { + fieldName: string; + result: CachedResult; + onAddStage: (config: StageConfig) => void; + onClose: () => void; +} + +interface ValueFrequency { + value: string; + rawValue: unknown; + count: number; + pct: number; +} + +interface NumericStats { + min: number; + max: number; + avg: number; + median: number; + nullCount: number; +} + +function computeFrequencies(result: CachedResult, fieldName: string): ValueFrequency[] { + const counts = new Map(); + for (const record of result.records) { + const val = record.get(fieldName); + const key = val === null || val === undefined ? "(null)" : String(val); + const existing = counts.get(key); + if (existing) { + existing.count++; + } else { + counts.set(key, { count: 1, rawValue: val }); + } + } + + const total = result.records.length; + const freqs: ValueFrequency[] = []; + for (const [value, { count, rawValue }] of counts) { + freqs.push({ value, rawValue, count, pct: total > 0 ? (count / total) * 100 : 0 }); + } + freqs.sort((a, b) => b.count - a.count); + return freqs; +} + +function computeNumericStats(result: CachedResult, fieldName: string): NumericStats | null { + const nums: number[] = []; + let nullCount = 0; + for (const record of result.records) { + const val = record.get(fieldName); + if (val === null || val === undefined) { + nullCount++; + continue; + } + const n = Number(val); + if (!Number.isNaN(n)) { + nums.push(n); + } + } + if (nums.length === 0) return null; + + nums.sort((a, b) => a - b); + const sum = nums.reduce((s, v) => s + v, 0); + const mid = Math.floor(nums.length / 2); + const median = + nums.length % 2 === 0 + ? (nums[mid - 1]! + nums[mid]!) / 2 + : nums[mid]!; + + return { + min: nums[0]!, + max: nums[nums.length - 1]!, + avg: sum / nums.length, + median, + nullCount, + }; +} + +const BAR_CHARS = ["░", "▒", "▓", "█"]; + +function renderBar(pct: number, maxWidth: number): string { + const filled = Math.round((pct / 100) * maxWidth); + if (filled === 0 && pct > 0) return BAR_CHARS[0]!; + + // Use denser blocks for higher fill + const result: string[] = []; + for (let i = 0; i < filled; i++) { + const ratio = (i + 1) / maxWidth; + if (ratio > 0.75) result.push(BAR_CHARS[3]!); + else if (ratio > 0.5) result.push(BAR_CHARS[2]!); + else if (ratio > 0.25) result.push(BAR_CHARS[1]!); + else result.push(BAR_CHARS[0]!); + } + return result.join(""); +} + +export function FieldSpotlight({ fieldName, result, onAddStage, onClose }: FieldSpotlightProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + + const frequencies = useMemo(() => computeFrequencies(result, fieldName), [result, fieldName]); + const stats = useMemo(() => computeNumericStats(result, fieldName), [result, fieldName]); + + const maxVisible = 20; + const visibleFreqs = frequencies.slice(0, maxVisible); + + useInput((input, key) => { + if (key.escape) { + onClose(); + return; + } + if (key.upArrow || input === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow || input === "j") { + setSelectedIndex((i) => Math.min(visibleFreqs.length - 1, i + 1)); + return; + } + // Enter → grep for selected value + if (key.return) { + const entry = visibleFreqs[selectedIndex]; + if (entry) { + const val = entry.value === "(null)" ? "" : entry.value; + onAddStage({ + operationName: "grep", + args: [`\${${fieldName}} eq "${val}"`], + enabled: true, + }); + } + return; + } + // s → sort by this field + if (input === "s") { + onAddStage({ + operationName: "sort", + args: ["--key", fieldName], + enabled: true, + }); + return; + } + // c → collate by this field + if (input === "c") { + onAddStage({ + operationName: "collate", + args: ["--key", fieldName, "--aggregator", "count,countAll"], + enabled: true, + }); + return; + } + }); + + const BAR_WIDTH = 20; + const valueWidth = 30; + const uniqueCount = frequencies.length; + const totalRecords = result.records.length; + + return ( + + {/* Title */} + + + Field Spotlight: {fieldName} + + [Esc] close + + + {/* Summary line */} + + + {totalRecords} records | {uniqueCount} unique values + {stats ? ` | min: ${stats.min} max: ${stats.max} avg: ${stats.avg.toFixed(2)} median: ${stats.median}` : ""} + {stats?.nullCount ? ` | ${stats.nullCount} null` : ""} + + + + {/* Header */} + + + {" "}{"Value".padEnd(valueWidth)} {"Count".padStart(6)} {" %".padStart(5)} Bar + + + + {/* Frequency list */} + + {visibleFreqs.map((entry, i) => { + const isSelected = i === selectedIndex; + const prefix = isSelected ? "> " : " "; + const valStr = entry.value.length > valueWidth + ? entry.value.slice(0, valueWidth - 3) + "..." + : entry.value.padEnd(valueWidth); + const countStr = String(entry.count).padStart(6); + const pctStr = entry.pct.toFixed(1).padStart(5); + const bar = renderBar(entry.pct, BAR_WIDTH); + + return ( + + {prefix}{valStr} {countStr} {pctStr}% {bar} + + ); + })} + {frequencies.length > maxVisible && ( + + ... and {frequencies.length - maxVisible} more unique values + + )} + + + {/* Footer */} + + + ↑↓:navigate Enter:grep value s:sort c:collate Esc:close + + + + ); +} diff --git a/src/explorer/components/modals/ForkManager.tsx b/src/explorer/components/modals/ForkManager.tsx new file mode 100644 index 0000000..cc255b5 --- /dev/null +++ b/src/explorer/components/modals/ForkManager.tsx @@ -0,0 +1,193 @@ +/** + * ForkManager — modal for creating, switching, and deleting forks. + * + * Lists all forks with their fork-point stage label. The active fork is + * highlighted. Users can: + * - Enter/Space: switch to the selected fork + * - n: create a new fork at the current cursor stage + * - d: delete the selected fork (with confirmation guard for non-main forks) + * - Esc: close + */ + +import { useState, useMemo, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; +import { VimTextInput } from "../VimTextInput.tsx"; +import type { PipelineState, PipelineAction } from "../../model/types.ts"; +import { theme } from "../../theme.ts"; + +export interface ForkManagerProps { + state: PipelineState; + dispatch: (action: PipelineAction) => void; + onClose: () => void; + onShowStatus: (msg: string) => void; +} + +export function ForkManager({ + state, + dispatch, + onClose, + onShowStatus, +}: ForkManagerProps) { + const forks = useMemo( + () => + Array.from(state.forks.values()).sort( + (a, b) => a.createdAt - b.createdAt, + ), + [state.forks], + ); + + const initialIndex = Math.max( + 0, + forks.findIndex((f) => f.id === state.activeForkId), + ); + const [selectedIndex, setSelectedIndex] = useState(initialIndex); + const [isNaming, setIsNaming] = useState(false); + const [newName, setNewName] = useState(""); + const [confirmingDelete, setConfirmingDelete] = useState(false); + + const selected = forks[selectedIndex]; + + const handleNameSubmit = useCallback( + (val: string) => { + const name = val.trim() || `fork-${forks.length}`; + if (state.cursorStageId) { + dispatch({ type: "CREATE_FORK", name, atStageId: state.cursorStageId }); + onShowStatus(`Created fork "${name}"`); + } else { + onShowStatus("No cursor stage — cannot fork"); + } + setIsNaming(false); + setNewName(""); + onClose(); + }, + [forks.length, state.cursorStageId, dispatch, onShowStatus, onClose], + ); + + // Keyboard when confirming delete + useInput((input, key) => { + if (input === "y" || key.return) { + if (selected && selected.parentForkId !== null) { + dispatch({ type: "DELETE_FORK", forkId: selected.id }); + setSelectedIndex(Math.max(0, selectedIndex - 1)); + onShowStatus(`Deleted fork "${selected.name}"`); + } + setConfirmingDelete(false); + } else if (input === "n" || key.escape) { + setConfirmingDelete(false); + } + }, { isActive: confirmingDelete }); + + // Naming phase: VimTextInput handles Escape (via onEscape) and Enter (via onSubmit), + // so no separate useInput handler is needed for the naming phase. + + const handleNamingEscape = useCallback(() => { + setIsNaming(false); + setNewName(""); + }, []); + + // Keyboard for normal list navigation + useInput((input, key) => { + if (key.escape) { + onClose(); + return; + } + + if (key.upArrow || input === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (key.downArrow || input === "j") { + setSelectedIndex((i) => Math.min(forks.length - 1, i + 1)); + } else if (key.return || input === " ") { + if (selected && selected.id !== state.activeForkId) { + dispatch({ type: "SWITCH_FORK", forkId: selected.id }); + onShowStatus(`Switched to fork "${selected.name}"`); + onClose(); + } + } else if (input === "n") { + setIsNaming(true); + } else if (input === "d") { + if (selected && selected.parentForkId !== null) { + setConfirmingDelete(true); + } else { + onShowStatus("Cannot delete the main fork"); + } + } + }, { isActive: !confirmingDelete && !isNaming }); + + return ( + + + Fork Manager + [Esc] close + + + {confirmingDelete && selected ? ( + + Delete fork "{selected.name}"? + + [y/Enter] confirm [n/Esc] cancel + + + ) : isNaming ? ( + + New fork name: + + + + + Enter:create Esc:vim Esc(2x):cancel + + + ) : ( + <> + + {forks.map((fork, idx) => { + const isSelected = idx === selectedIndex; + const isActive = fork.id === state.activeForkId; + const forkPoint = fork.forkPointStageId + ? state.stages.get(fork.forkPointStageId)?.config.operationName + : null; + const stageCount = fork.stageIds.length; + + return ( + + + {isSelected ? "> " : " "} + {fork.name} + {isActive ? " (active)" : ""} + + + {" "} + {stageCount} stage{stageCount !== 1 ? "s" : ""} + {forkPoint ? ` • from: ${forkPoint}` : " • root"} + + + ); + })} + + + + + ↑↓:navigate Enter:switch n:new d:delete Esc:close + + + + )} + + ); +} diff --git a/src/explorer/components/modals/HelpPanel.tsx b/src/explorer/components/modals/HelpPanel.tsx new file mode 100644 index 0000000..1597025 --- /dev/null +++ b/src/explorer/components/modals/HelpPanel.tsx @@ -0,0 +1,119 @@ +/** + * HelpPanel — full keyboard reference overlay. + * + * Displayed when the user presses `?`. Shows all keyboard shortcuts + * organized by context (global, pipeline, inspector). + */ + +import { Box, Text, useInput } from "ink"; +import { theme } from "../../theme.ts"; + +export interface HelpPanelProps { + onClose: () => void; +} + +interface HelpEntry { + key: string; + desc: string; +} + +interface HelpSection { + title: string; + color: string; + entries: HelpEntry[]; +} + +const HELP_SECTIONS: HelpSection[] = [ + { + title: "PIPELINE", + color: theme.mauve, + entries: [ + { key: "↑/k ↓/j", desc: "Move cursor between stages" }, + { key: "a", desc: "Add stage after cursor" }, + { key: "A", desc: "Add stage before cursor" }, + { key: "d", desc: "Delete stage (with confirm)" }, + { key: "e", desc: "Edit stage arguments" }, + { key: "Space", desc: "Toggle stage enabled/disabled" }, + { key: "J/K", desc: "Reorder stage down/up" }, + { key: "r", desc: "Re-run from cursor stage" }, + { key: "Enter/Tab", desc: "Focus inspector panel" }, + ], + }, + { + title: "INSPECTOR", + color: theme.blue, + entries: [ + { key: "↑/k ↓/j", desc: "Scroll records" }, + { key: "t", desc: "Cycle view: table → pp → json → schema" }, + { key: "←/h →/l", desc: "Move column highlight (table view)" }, + { key: "Enter", desc: "Open record detail (tree view)" }, + { key: "Esc", desc: "Clear column highlight / return to pipeline" }, + { key: "g", desc: "Add grep stage (column highlighted)" }, + { key: "s", desc: "Add sort stage (column highlighted)" }, + { key: "c", desc: "Add collate stage (column highlighted)" }, + { key: "F", desc: "Open field spotlight (column highlighted)" }, + ], + }, + { + title: "GLOBAL", + color: theme.peach, + entries: [ + { key: "Tab", desc: "Toggle focus: pipeline ↔ inspector" }, + { key: "u", desc: "Undo last pipeline edit" }, + { key: "Ctrl+R", desc: "Redo last undone edit" }, + { key: "v", desc: "Open records in $EDITOR" }, + { key: "x", desc: "Export pipeline → clipboard" }, + { key: "X", desc: "Export pipeline (choose format)" }, + { key: "S", desc: "Save/rename session" }, + { key: "f", desc: "Fork at cursor stage" }, + { key: "b", desc: "Switch/manage forks" }, + { key: "i", desc: "Switch input source" }, + { key: "p", desc: "Pin/unpin stage cache" }, + { key: "?", desc: "Toggle this help" }, + { key: "q/Ctrl+C", desc: "Quit" }, + ], + }, +]; + +export function HelpPanel({ onClose }: HelpPanelProps) { + useInput((input, key) => { + if (key.escape || input === "?" || input === "q") { + onClose(); + } + }); + + return ( + + + Keyboard Reference + + [Esc] + or + [?] + to close + + + + + {HELP_SECTIONS.map((section) => ( + + {section.title} + {section.entries.map((entry) => ( + + {entry.key.padEnd(12)} + {entry.desc} + + ))} + + ))} + + + ); +} diff --git a/src/explorer/components/modals/InputSwitcher.tsx b/src/explorer/components/modals/InputSwitcher.tsx new file mode 100644 index 0000000..40220ec --- /dev/null +++ b/src/explorer/components/modals/InputSwitcher.tsx @@ -0,0 +1,219 @@ +/** + * InputSwitcher — switch between or add input sources. + * + * Lists all current input sources with the active one highlighted. + * - Enter/Space: switch to the selected input + * - a: add a new file input (prompts for path) + * - d: remove the selected input (cannot remove the last one) + * - Esc: close + * + * When adding a file, a large-file check is performed (> 100MB warn, + * > 1GB danger) and the onLargeFile callback is invoked so the parent + * can show the LargeFileWarning modal before proceeding. + */ + +import { useState, useMemo, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; +import { VimTextInput } from "../VimTextInput.tsx"; +import type { + PipelineState, + PipelineAction, + FileSizeWarning, +} from "../../model/types.ts"; +import { FILE_SIZE_THRESHOLDS } from "../../model/types.ts"; +import { theme } from "../../theme.ts"; + +export interface InputSwitcherProps { + state: PipelineState; + dispatch: (action: PipelineAction) => void; + onClose: () => void; + onShowStatus: (msg: string) => void; + /** Called when a newly-added file exceeds a size threshold. */ + onLargeFile?: (warning: FileSizeWarning) => void; +} + +export function InputSwitcher({ + state, + dispatch, + onClose, + onShowStatus, + onLargeFile, +}: InputSwitcherProps) { + const inputs = useMemo( + () => Array.from(state.inputs.values()), + [state.inputs], + ); + + const initialIndex = Math.max( + 0, + inputs.findIndex((i) => i.id === state.activeInputId), + ); + const [selectedIndex, setSelectedIndex] = useState(initialIndex); + const [isAdding, setIsAdding] = useState(false); + const [newPath, setNewPath] = useState(""); + + const selected = inputs[selectedIndex]; + + async function addFileInput(path: string) { + const label = path.split("/").pop() ?? path; + + // Check file size for large file warning + try { + const file = Bun.file(path); + const size = file.size; + + if (size > FILE_SIZE_THRESHOLDS.danger || size > FILE_SIZE_THRESHOLDS.warn) { + // Estimate records: assume ~200 bytes per JSON line + const estimatedRecords = Math.round(size / 200); + const stageCount = Math.max(1, state.forks.get(state.activeForkId)?.stageIds.length ?? 1); + const projectedCacheBytes = size * stageCount; + + const warning: FileSizeWarning = { + path, + fileBytes: size, + estimatedRecords, + projectedCacheBytes, + acknowledged: false, + }; + + if (onLargeFile) { + onLargeFile(warning); + // The parent will handle adding the input after the user acknowledges + onClose(); + return; + } + } + } catch { + // File may not exist yet or stat failed — proceed without warning + } + + dispatch({ + type: "ADD_INPUT", + source: { kind: "file", path }, + label, + }); + onShowStatus(`Added input "${label}"`); + onClose(); + } + + const handlePathSubmit = useCallback( + (val: string) => { + const path = val.trim(); + if (path) { + void addFileInput(path); + } + setIsAdding(false); + setNewPath(""); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [state, dispatch, onShowStatus, onClose, onLargeFile], + ); + + // Adding phase: VimTextInput handles Escape (via onEscape) and Enter (via onSubmit), + // so no separate useInput handler is needed for the adding phase. + + const handleAddingEscape = useCallback(() => { + setIsAdding(false); + setNewPath(""); + }, []); + + // Keyboard for normal list navigation + useInput((input, key) => { + if (key.escape) { + onClose(); + return; + } + + if (key.upArrow || input === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (key.downArrow || input === "j") { + setSelectedIndex((i) => Math.min(inputs.length - 1, i + 1)); + } else if (key.return || input === " ") { + if (selected && selected.id !== state.activeInputId) { + dispatch({ type: "SWITCH_INPUT", inputId: selected.id }); + onShowStatus(`Switched to input "${selected.label}"`); + onClose(); + } + } else if (input === "a") { + setIsAdding(true); + } else if (input === "d") { + if (selected && state.inputs.size > 1) { + dispatch({ type: "REMOVE_INPUT", inputId: selected.id }); + setSelectedIndex(Math.max(0, selectedIndex - 1)); + onShowStatus(`Removed input "${selected.label}"`); + } else { + onShowStatus("Cannot remove the only input"); + } + } + }, { isActive: !isAdding }); + + return ( + + + Input Sources + [Esc] close + + + {isAdding ? ( + + File path: + + + + + Enter:add Esc:vim Esc(2x):cancel + + + ) : ( + <> + + {inputs.map((input, idx) => { + const isSelected = idx === selectedIndex; + const isActive = input.id === state.activeInputId; + const sourceDesc = + input.source.kind === "file" + ? input.source.path + : `stdin (${input.source.records.length} records)`; + + return ( + + + {isSelected ? "> " : " "} + {input.label} + {isActive ? " (active)" : ""} + + {" "}{sourceDesc} + + ); + })} + {inputs.length === 0 && ( + No inputs — press a to add + )} + + + + + ↑↓:navigate Enter:switch a:add d:remove Esc:close + + + + )} + + ); +} diff --git a/src/explorer/components/modals/LargeFileWarning.tsx b/src/explorer/components/modals/LargeFileWarning.tsx new file mode 100644 index 0000000..2fa1093 --- /dev/null +++ b/src/explorer/components/modals/LargeFileWarning.tsx @@ -0,0 +1,161 @@ +/** + * LargeFileWarning — cache policy selection dialog for large files. + * + * Shown when an input file exceeds size thresholds: + * - > 100MB (warn): yellow banner border + * - > 1GB (danger): red dialog border, requires explicit confirmation + * + * Lets the user choose a cache policy: + * - "all": cache every stage's output (default) + * - "selective": only cache explicitly pinned stages (via `p` key) + * - "none": never cache, always re-execute from source + * + * Displays file size, estimated records, and projected cache cost. + */ + +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type { FileSizeWarning, CacheConfig } from "../../model/types.ts"; +import { FILE_SIZE_THRESHOLDS } from "../../model/types.ts"; +import { theme } from "../../theme.ts"; + +export type CachePolicy = CacheConfig["cachePolicy"]; + +export interface LargeFileWarningProps { + warning: FileSizeWarning; + /** Called when user selects a cache policy and confirms */ + onConfirm: (policy: CachePolicy) => void; + /** Called when user cancels (Esc) — file is not added */ + onCancel: () => void; +} + +const POLICIES: { policy: CachePolicy; label: string; description: string }[] = [ + { + policy: "all", + label: "Cache all", + description: "Cache every stage's output (uses the most disk space)", + }, + { + policy: "selective", + label: "Cache selectively", + description: "Only cache stages you pin with the 'p' key", + }, + { + policy: "none", + label: "No caching", + description: "Always re-execute from source (slowest, no disk usage)", + }, +]; + +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(0)} KB`; + } + return `${bytes} B`; +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `~${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `~${(n / 1_000).toFixed(0)}K`; + return `~${n}`; +} + +export function LargeFileWarning({ + warning, + onConfirm, + onCancel, +}: LargeFileWarningProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + + const isDanger = warning.fileBytes >= FILE_SIZE_THRESHOLDS.danger; + const borderColor = isDanger ? theme.red : theme.yellow; + const severityLabel = isDanger ? "LARGE FILE WARNING" : "Large File Notice"; + + useInput((input, key) => { + if (key.escape) { + onCancel(); + return; + } + + if (key.upArrow || input === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (key.downArrow || input === "j") { + setSelectedIndex((i) => Math.min(POLICIES.length - 1, i + 1)); + } else if (key.return) { + const policy = POLICIES[selectedIndex]; + if (policy) { + onConfirm(policy.policy); + } + } + }); + + const fileName = warning.path.split("/").pop() ?? warning.path; + + return ( + + {/* Title */} + + {severityLabel} + [Esc] cancel + + + {/* File info */} + + + File: {fileName} + + Size: {formatBytes(warning.fileBytes)} + Estimated records: {formatCount(warning.estimatedRecords)} + + Projected cache cost: {formatBytes(warning.projectedCacheBytes)} + + + + {/* Warning message */} + + + {isDanger + ? "This file is very large. Caching all intermediate results may use significant disk space and memory." + : "This file is moderately large. Consider selective caching to reduce disk usage."} + + + + {/* Policy selection */} + + Choose a cache policy: + {POLICIES.map((opt, idx) => { + const isSelected = idx === selectedIndex; + return ( + + + {isSelected ? "> " : " "} + {opt.label} + + {" "}{opt.description} + + ); + })} + + + {/* Footer */} + + ↑↓:navigate Enter:confirm Esc:cancel + + + ); +} diff --git a/src/explorer/components/modals/RecordDetail.tsx b/src/explorer/components/modals/RecordDetail.tsx new file mode 100644 index 0000000..79544b5 --- /dev/null +++ b/src/explorer/components/modals/RecordDetail.tsx @@ -0,0 +1,332 @@ +/** + * RecordDetail — full-screen tree view for exploring a single record's + * nested JSON structure. + * + * Features: + * - Collapsible objects/arrays (Space to toggle, ▼ expanded / ▶ collapsed) + * - Up/down navigation through visible fields + * - ←/→ to navigate to prev/next record without closing + * - `y` to copy the value at cursor to clipboard + * - Color coding: strings green, numbers teal, booleans yellow, null dim + * - 2-space indentation per nesting level + * - Header shows "Record #N" and record navigation hint + */ + +import { useState, useMemo, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; +import type { JsonValue } from "../../../types/json.ts"; +import type { Record } from "../../../Record.ts"; +import { copyToClipboard } from "../../model/serialization.ts"; +import { theme } from "../../theme.ts"; + +export interface RecordDetailProps { + records: Record[]; + initialIndex: number; + onClose: () => void; + onShowStatus?: (msg: string) => void; +} + +/** A flattened row in the tree view. */ +interface TreeRow { + /** Indentation depth (0 = root fields) */ + depth: number; + /** The field key or array index label */ + label: string; + /** The raw JSON value at this node */ + value: JsonValue; + /** Whether this node is an object or array (can be collapsed) */ + isContainer: boolean; + /** Unique path key for tracking collapsed state */ + path: string; + /** Number of direct children (for the collapsed summary) */ + childCount: number; +} + +/** + * Flatten a JSON value into tree rows, respecting collapsed state. + */ +function flattenValue( + value: JsonValue, + collapsed: Set, + parentPath: string, + depth: number, + label: string, +): TreeRow[] { + const path = parentPath ? `${parentPath}.${label}` : label; + + if (value === null || value === undefined) { + return [{ depth, label, value: null, isContainer: false, path, childCount: 0 }]; + } + + if (typeof value === "object" && !Array.isArray(value)) { + const keys = Object.keys(value); + const row: TreeRow = { + depth, + label, + value, + isContainer: true, + path, + childCount: keys.length, + }; + const rows: TreeRow[] = [row]; + + if (!collapsed.has(path)) { + for (const key of keys) { + rows.push(...flattenValue(value[key]!, collapsed, path, depth + 1, key)); + } + } + return rows; + } + + if (Array.isArray(value)) { + const row: TreeRow = { + depth, + label, + value, + isContainer: true, + path, + childCount: value.length, + }; + const rows: TreeRow[] = [row]; + + if (!collapsed.has(path)) { + for (let i = 0; i < value.length; i++) { + rows.push(...flattenValue(value[i]!, collapsed, path, depth + 1, `[${i}]`)); + } + } + return rows; + } + + // Primitive value + return [{ depth, label, value, isContainer: false, path, childCount: 0 }]; +} + +/** + * Flatten an entire record into tree rows. + */ +function flattenRecord(record: Record, collapsed: Set): TreeRow[] { + const data = record.toJSON(); + const rows: TreeRow[] = []; + for (const key of Object.keys(data)) { + rows.push(...flattenValue(data[key]!, collapsed, "", 0, key)); + } + return rows; +} + +/** Color a value based on its JSON type. */ +function valueColor(value: JsonValue): string { + if (value === null || value === undefined) return theme.overlay0; + if (typeof value === "string") return theme.green; + if (typeof value === "number") return theme.teal; + if (typeof value === "boolean") return theme.yellow; + return theme.text; +} + +/** Format a value for display (single line). */ +function formatValue(value: JsonValue): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (Array.isArray(value)) return `Array(${value.length})`; + if (typeof value === "object") return `Object(${Object.keys(value).length})`; + return String(value); +} + +export function RecordDetail({ + records, + initialIndex, + onClose, + onShowStatus, +}: RecordDetailProps) { + const [recordIndex, setRecordIndex] = useState(initialIndex); + const [cursorRow, setCursorRow] = useState(0); + const [collapsed, setCollapsed] = useState>(() => new Set()); + + const record = records[recordIndex]; + const rows = useMemo( + () => (record ? flattenRecord(record, collapsed) : []), + [record, collapsed], + ); + + const toggleCollapse = useCallback( + (path: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, + [], + ); + + useInput((input, key) => { + // Close + if (key.escape || input === "q") { + onClose(); + return; + } + + // Navigation within tree + if (key.upArrow || input === "k") { + setCursorRow((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow || input === "j") { + setCursorRow((i) => Math.min(rows.length - 1, i + 1)); + return; + } + + // Toggle collapse + if (input === " ") { + const row = rows[cursorRow]; + if (row?.isContainer) { + toggleCollapse(row.path); + } + return; + } + + // Prev/next record + if (key.leftArrow || input === "h") { + if (recordIndex > 0) { + setRecordIndex((i) => i - 1); + setCursorRow(0); + setCollapsed(new Set()); + } + return; + } + if (key.rightArrow || input === "l") { + if (recordIndex < records.length - 1) { + setRecordIndex((i) => i + 1); + setCursorRow(0); + setCollapsed(new Set()); + } + return; + } + + // Copy value at cursor + if (input === "y") { + const row = rows[cursorRow]; + if (row) { + const text = + typeof row.value === "object" && row.value !== null + ? JSON.stringify(row.value, null, 2) + : String(row.value ?? "null"); + void copyToClipboard(text).then((ok) => { + onShowStatus?.(ok ? "Copied value!" : "Clipboard failed"); + }); + } + return; + } + }); + + if (!record) { + return ( + + No record at index {recordIndex} + + ); + } + + // Compute visible window (viewport scrolling) + const viewportHeight = 30; + let scrollTop = 0; + // Center cursor in viewport when possible + scrollTop = Math.max(0, cursorRow - Math.floor(viewportHeight / 2)); + if (scrollTop + viewportHeight > rows.length) { + scrollTop = Math.max(0, rows.length - viewportHeight); + } + const visibleRows = rows.slice(scrollTop, scrollTop + viewportHeight); + + return ( + + {/* Header */} + + + Record #{recordIndex + 1} + of {records.length} + + [Esc] close [←/→] prev/next + + + {/* Tree view */} + + {visibleRows.map((row, vi) => { + const actualIdx = scrollTop + vi; + const isSelected = actualIdx === cursorRow; + const indent = " ".repeat(row.depth); + const marker = row.isContainer + ? collapsed.has(row.path) + ? "▶ " + : "▼ " + : " "; + + const labelText = `${indent}${marker}${row.label}`; + + if (row.isContainer && collapsed.has(row.path)) { + // Collapsed container: show summary + const summary = Array.isArray(row.value) + ? `Array(${row.childCount})` + : `Object(${row.childCount})`; + return ( + + {labelText}: {summary} + + ); + } + + if (row.isContainer) { + // Expanded container: just show the label with marker + const typeHint = Array.isArray(row.value) + ? `Array(${row.childCount})` + : `Object(${row.childCount})`; + return ( + + {labelText}: {typeHint} + + ); + } + + // Leaf value + return ( + + {labelText}: {formatValue(row.value)} + + ); + })} + {rows.length === 0 && ( + (empty record) + )} + + + {/* Footer */} + + + ↑↓:navigate Space:toggle ←→:prev/next record y:copy Esc:close + + + + ); +} diff --git a/src/explorer/components/modals/SaveSessionModal.tsx b/src/explorer/components/modals/SaveSessionModal.tsx new file mode 100644 index 0000000..cfffbfa --- /dev/null +++ b/src/explorer/components/modals/SaveSessionModal.tsx @@ -0,0 +1,135 @@ +/** + * SaveSessionModal — prompts for a session name. + * + * If the session is already named, offers rename or save-as-new. + * If unnamed, prompts for a name and saves. + * + * Keyboard: Enter to confirm, Esc to cancel. + */ + +import { useState, useCallback } from "react"; +import { Box, Text, useInput } from "ink"; +import { VimTextInput } from "../VimTextInput.tsx"; +import { theme } from "../../theme.ts"; + +export interface SaveSessionModalProps { + /** Current session name, if any */ + currentName?: string; + /** Called when user confirms the save with a name and mode */ + onConfirm: (name: string, mode: "rename" | "save-as") => void; + /** Called when user cancels */ + onCancel: () => void; +} + +type SaveMode = "rename" | "save-as"; + +export function SaveSessionModal({ + currentName, + onConfirm, + onCancel, +}: SaveSessionModalProps) { + const hasName = Boolean(currentName); + const [name, setName] = useState(currentName ?? ""); + const [mode, setMode] = useState(hasName ? "rename" : "save-as"); + const [phase, setPhase] = useState<"choose" | "input">( + hasName ? "choose" : "input", + ); + + const handleNameSubmit = useCallback( + (val: string) => { + const trimmed = val.trim(); + if (trimmed.length > 0) { + onConfirm(trimmed, mode); + } + }, + [onConfirm, mode], + ); + + // Choose phase keyboard + useInput((input, key) => { + if (key.escape) { + onCancel(); + return; + } + + if (key.upArrow || input === "k") { + setMode("rename"); + } else if (key.downArrow || input === "j") { + setMode("save-as"); + } else if (key.return) { + if (mode === "rename") { + setName(currentName ?? ""); + } else { + setName(""); + } + setPhase("input"); + } + }, { isActive: phase === "choose" }); + + // Input phase: VimTextInput handles Escape (via onEscape) and Enter (via onSubmit), + // so no separate useInput handler is needed for the input phase. + + return ( + + + Save Session + [Esc] cancel + + + {phase === "choose" ? ( + + + Current name: {currentName} + + + + {mode === "rename" ? "> " : " "} + Rename session + + + {mode === "save-as" ? "> " : " "} + Save as new session + + + + + ↑↓:choose Enter:select Esc:cancel + + + + ) : ( + + + {mode === "rename" ? "Rename session:" : "Save as new session:"} + + + Name: + + + + Enter:save Esc:vim Esc(2x):cancel + + + )} + + ); +} diff --git a/src/explorer/components/modals/SessionPicker.tsx b/src/explorer/components/modals/SessionPicker.tsx new file mode 100644 index 0000000..009a960 --- /dev/null +++ b/src/explorer/components/modals/SessionPicker.tsx @@ -0,0 +1,128 @@ +/** + * SessionPicker — resume session modal shown on launch. + * + * Displayed when an input file matches an existing session, offering + * the user the choice to resume or start fresh. + * + * Keyboard: ↑↓ navigate, Enter select, Esc cancel (start fresh). + */ + +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { theme } from "../../theme.ts"; + +export interface SessionMatch { + sessionId: string; + name?: string; + inputLabel: string; + stageCount: number; + lastAccessedAt: number; +} + +export interface SessionPickerProps { + /** The file path that matched */ + filePath: string; + /** Matching sessions for this file */ + sessions: SessionMatch[]; + /** Called when user selects a session to resume */ + onResume: (sessionId: string) => void; + /** Called when user chooses to start fresh */ + onStartFresh: () => void; +} + +function formatTimeAgo(timestamp: number): string { + const elapsed = Date.now() - timestamp; + if (elapsed < 60_000) return "just now"; + if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`; + if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`; + return `${Math.floor(elapsed / 86_400_000)}d ago`; +} + +export function SessionPicker({ + filePath, + sessions, + onResume, + onStartFresh, +}: SessionPickerProps) { + // Options: each session + "Start fresh" at the end + const totalOptions = sessions.length + 1; + const [selectedIndex, setSelectedIndex] = useState(0); + + useInput((input, key) => { + if (key.escape) { + onStartFresh(); + return; + } + if (key.upArrow || input === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)); + } else if (key.downArrow || input === "j") { + setSelectedIndex((i) => Math.min(totalOptions - 1, i + 1)); + } else if (key.return) { + if (selectedIndex < sessions.length) { + const session = sessions[selectedIndex]; + if (session) { + onResume(session.sessionId); + } + } else { + onStartFresh(); + } + } + }); + + const fileName = filePath.split("/").pop() ?? filePath; + + return ( + + + Resume Session? + [Esc] start fresh + + + + + Found existing sessions for {fileName}: + + + + + {sessions.map((session, idx) => { + const isSelected = idx === selectedIndex; + const stageLabel = session.stageCount === 1 ? "1 stage" : `${session.stageCount} stages`; + const timeLabel = formatTimeAgo(session.lastAccessedAt); + const primaryLabel = session.name ?? session.inputLabel; + const secondaryLabel = session.name ? ` (${session.inputLabel})` : ""; + return ( + + + {isSelected ? "> " : " "} + {primaryLabel}{secondaryLabel} — {stageLabel}, last used {timeLabel} + + + ); + })} + + {/* Start fresh option */} + + {selectedIndex === sessions.length ? "> " : " "} + Start fresh (new pipeline) + + + + + ↑↓:navigate Enter:select Esc:start fresh + + + ); +} diff --git a/src/explorer/executor/cache-manager.ts b/src/explorer/executor/cache-manager.ts new file mode 100644 index 0000000..a55fb64 --- /dev/null +++ b/src/explorer/executor/cache-manager.ts @@ -0,0 +1,357 @@ +/** + * CacheManager — LRU cache with configurable memory limit, cascading + * invalidation, cache policies, and disk spill for large results. + * + * Sits between the executor and the PipelineState.cache map, providing + * intelligent eviction, SHA-256 key computation, and policy enforcement. + */ + +import { createHash } from "node:crypto"; +import { writeFileSync, readFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { + CachedResult, + CacheConfig, + CacheKey, + StageId, + InputId, + Stage, + Fork, +} from "../model/types.ts"; +import type { Record } from "../../Record.ts"; + +/** Default memory limit: 512 MB */ +const DEFAULT_MAX_MEMORY_BYTES = 512 * 1024 * 1024; + +/** Threshold for disk spill: results larger than 50 MB are spilled to disk */ +const SPILL_THRESHOLD_BYTES = 50 * 1024 * 1024; + +/** Access metadata tracked per cache entry for LRU eviction. */ +interface CacheEntry { + result: CachedResult; + /** Monotonic access counter for LRU ordering (avoids Date.now() precision issues). */ + accessOrder: number; +} + +export class CacheManager { + entries = new Map(); + currentSizeBytes = 0; + maxMemoryBytes: number; + cachePolicy: CacheConfig["cachePolicy"]; + pinnedStageIds: Set; + spillDir: string | null; + accessCounter = 0; + + constructor(config: CacheConfig, spillDir?: string) { + this.maxMemoryBytes = config.maxMemoryBytes || DEFAULT_MAX_MEMORY_BYTES; + this.cachePolicy = config.cachePolicy; + this.pinnedStageIds = new Set(config.pinnedStageIds); + this.spillDir = spillDir ?? null; + } + + // ── Configuration ────────────────────────────────────────────── + + updateConfig(config: CacheConfig): void { + this.maxMemoryBytes = config.maxMemoryBytes || DEFAULT_MAX_MEMORY_BYTES; + this.cachePolicy = config.cachePolicy; + this.pinnedStageIds = new Set(config.pinnedStageIds); + + // If policy changed to "none", clear all entries + if (this.cachePolicy === "none") { + this.clear(); + } + } + + // ── Cache Key Computation ────────────────────────────────────── + + /** + * Compute a SHA-256 cache key for a stage given its position in the pipeline. + * + * Key = sha256(inputId + parentCacheKey + operationName + JSON(args) + enabled) + * + * This ensures that any change to an upstream stage produces a different + * cache key for all downstream stages (cascading invalidation by key mismatch). + */ + computeCacheKey( + inputId: InputId, + stages: Stage[], + targetIndex: number, + ): CacheKey { + let parentKey = inputId; + + for (let i = 0; i <= targetIndex; i++) { + const stage = stages[i]!; + const data = [ + parentKey, + stage.config.operationName, + JSON.stringify(stage.config.args), + String(stage.config.enabled), + ].join("|"); + + parentKey = createHash("sha256").update(data).digest("hex").slice(0, 16); + } + + return parentKey; + } + + // ── Get / Put / Has ──────────────────────────────────────────── + + /** + * Retrieve a cached result by composite key (inputId:stageId). + * Updates the LRU access time on hit. + */ + get(inputId: InputId, stageId: StageId): CachedResult | undefined { + const key = compositeKey(inputId, stageId); + const entry = this.entries.get(key); + if (!entry) return undefined; + + // Load spilled records from disk if needed + if (entry.result.spillFile && entry.result.records.length === 0) { + try { + const content = readFileSync(entry.result.spillFile, "utf-8"); + const lines = content.split("\n").filter((l) => l.length > 0); + entry.result = { + ...entry.result, + records: lines.map((l) => JSON.parse(l)) as Record[], + }; + } catch { + // Spill file missing or corrupt — treat as cache miss + this.entries.delete(key); + return undefined; + } + } + + entry.accessOrder = ++this.accessCounter; + return entry.result; + } + + /** + * Check if a cache entry exists without updating LRU metadata. + */ + has(inputId: InputId, stageId: StageId): boolean { + return this.entries.has(compositeKey(inputId, stageId)); + } + + /** + * Store a cached result. Respects cache policy, handles disk spill + * for large results, and triggers LRU eviction if memory limit is exceeded. + */ + put(result: CachedResult): void { + if (!this.shouldCache(result.stageId)) return; + + const key = compositeKey(result.inputId, result.stageId); + + // Remove existing entry if replacing (reclaim its size) + if (this.entries.has(key)) { + this.evictEntry(key); + } + + let storedResult = result; + + // Spill large results to disk + if (result.sizeBytes > SPILL_THRESHOLD_BYTES && this.spillDir) { + const spillFile = this.spillToDisk(key, result.records); + if (spillFile) { + storedResult = { + ...result, + spillFile, + records: [], // Don't keep in memory + }; + } + } + + // Spilled entries don't count toward in-memory size + const inMemorySize = storedResult.spillFile ? 0 : storedResult.sizeBytes; + + // Evict entries if needed to stay under memory limit + while ( + this.currentSizeBytes + inMemorySize > this.maxMemoryBytes && + this.entries.size > 0 + ) { + this.evictLRU(); + } + + this.entries.set(key, { + result: storedResult, + accessOrder: ++this.accessCounter, + }); + this.currentSizeBytes += inMemorySize; + } + + // ── Invalidation ─────────────────────────────────────────────── + + /** + * Invalidate a single stage's cache across all inputs. + */ + invalidateStage(stageId: StageId): void { + for (const [key, entry] of this.entries) { + if (entry.result.stageId === stageId) { + this.evictEntry(key); + } + } + } + + /** + * Cascading invalidation: invalidate a stage and all downstream stages + * in the given fork. Called when a stage's config changes. + */ + invalidateCascade( + stageId: StageId, + forks: Map, + stages: Map, + ): void { + const stage = stages.get(stageId); + if (!stage) return; + + const fork = forks.get(stage.forkId); + if (!fork) return; + + const idx = fork.stageIds.indexOf(stageId); + if (idx === -1) return; + + // Invalidate this stage + all stages after it in the fork + const toInvalidate = new Set(fork.stageIds.slice(idx)); + + for (const [key, entry] of this.entries) { + if (toInvalidate.has(entry.result.stageId)) { + this.evictEntry(key); + } + } + } + + // ── Bulk Operations ──────────────────────────────────────────── + + /** + * Clear all cache entries and reclaim memory. + */ + clear(): void { + for (const [, entry] of this.entries) { + this.cleanupSpillFile(entry.result.spillFile); + } + this.entries.clear(); + this.currentSizeBytes = 0; + } + + /** + * Get all cached entries as a flat Map (for serialization / state sync). + */ + toMap(): Map { + const map = new Map(); + for (const [key, entry] of this.entries) { + map.set(key, entry.result); + } + return map; + } + + /** + * Restore cache from a state Map (e.g., after session load). + */ + fromMap(map: Map): void { + this.clear(); + for (const [_key, result] of map) { + this.put(result); + } + } + + // ── Stats ────────────────────────────────────────────────────── + + get size(): number { + return this.entries.size; + } + + get memoryUsageBytes(): number { + return this.currentSizeBytes; + } + + /** Expose the spill threshold for testing */ + static get SPILL_THRESHOLD_BYTES(): number { + return SPILL_THRESHOLD_BYTES; + } + + // ── Internal Methods ─────────────────────────────────────────── + + /** + * Check if a stage should be cached under the current policy. + */ + shouldCache(stageId: StageId): boolean { + switch (this.cachePolicy) { + case "none": + return false; + case "selective": + return this.pinnedStageIds.has(stageId); + case "all": + return true; + } + } + + /** + * Evict the least-recently-used entry. + */ + evictLRU(): void { + let oldestKey: string | null = null; + let oldestOrder = Infinity; + + for (const [key, entry] of this.entries) { + if (entry.accessOrder < oldestOrder) { + oldestOrder = entry.accessOrder; + oldestKey = key; + } + } + + if (oldestKey) { + this.evictEntry(oldestKey); + } + } + + /** + * Remove a specific entry by key, reclaiming its memory. + */ + evictEntry(key: string): void { + const entry = this.entries.get(key); + if (!entry) return; + + const inMemorySize = entry.result.spillFile ? 0 : entry.result.sizeBytes; + this.currentSizeBytes -= inMemorySize; + this.cleanupSpillFile(entry.result.spillFile); + this.entries.delete(key); + } + + /** + * Write records to a JSONL file on disk, returning the file path. + */ + spillToDisk(key: string, records: Record[]): string | null { + if (!this.spillDir) return null; + + try { + mkdirSync(this.spillDir, { recursive: true }); + const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_"); + const path = join(this.spillDir, `${safeKey}.jsonl`); + const content = records.map((r) => JSON.stringify(r)).join("\n") + "\n"; + writeFileSync(path, content, "utf-8"); + return path; + } catch { + return null; + } + } + + /** + * Remove a spill file from disk if it exists. + */ + cleanupSpillFile(spillFile: string | null): void { + if (!spillFile) return; + try { + if (existsSync(spillFile)) { + unlinkSync(spillFile); + } + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Build a composite cache key from inputId and stageId. + */ +function compositeKey(inputId: InputId, stageId: StageId): string { + return `${inputId}:${stageId}`; +} diff --git a/src/tui/executor/executor.ts b/src/explorer/executor/executor.ts similarity index 67% rename from src/tui/executor/executor.ts rename to src/explorer/executor/executor.ts index f1b2d4d..52c3405 100644 --- a/src/tui/executor/executor.ts +++ b/src/explorer/executor/executor.ts @@ -1,15 +1,16 @@ /** - * Pipeline executor for the TUI. + * Pipeline executor for the Explorer. * * 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. + * createOperationOrShell() from chain.ts to instantiate operations — known + * recs operations are created directly, unknown names are run as shell commands. */ // Side-effect import: registers all operation factories so createOperation() works. import "../../cli/dispatcher.ts"; -import { createOperation } from "../../operations/transform/chain.ts"; +import { createOperationOrShell } from "../../operations/transform/chain.ts"; import { Operation } from "../../Operation.ts"; import type { Record } from "../../Record.ts"; import { InterceptReceiver } from "./intercept-receiver.ts"; @@ -30,7 +31,7 @@ const BULK_STDIN_OPS = new Set([ ]); /** Operations that are fully self-contained (no input needed). */ -const SELF_CONTAINED_OPS = new Set(["fromps", "fromdb", "frommongo"]); +export const SELF_CONTAINED_OPS = new Set(["fromps", "fromdb", "frommongo"]); /** * Check if an operation instance overrides acceptLine (line-oriented input op). @@ -68,14 +69,15 @@ export function getStagePath( * 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, +function findNearestCacheInMap( + cache: Map, + activeInputId: string, 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)) { + const cacheKey = `${activeInputId}:${stage.id}`; + if (cache.has(cacheKey)) { return i; } } @@ -88,9 +90,23 @@ function findNearestCache( * Returns a CachedResult with the intercepted records, field names, and metadata. * The result is also stored in state.cache. */ +export interface ExecuteOptions { + /** + * Optional writable cache to use instead of state.cache. + * When provided, intermediate results are written here instead of mutating + * state.cache directly. This is important for React: mutating state.cache + * defeats memo comparators that rely on identity checks. + * + * When omitted (default), state.cache is mutated directly for backward + * compatibility with tests. + */ + workingCache?: Map; +} + export async function executeToStage( state: PipelineState, targetStageId: StageId, + options?: ExecuteOptions, ): Promise { const startTime = performance.now(); const path = getStagePath(state, targetStageId); @@ -99,13 +115,22 @@ export async function executeToStage( throw new Error(`Stage ${targetStageId} not found in pipeline`); } - const input = state.inputs.get(state.activeInputId); - if (!input) { + // Use the provided working cache (for React safety) or state.cache (for tests). + const cache = options?.workingCache ?? state.cache; + + const input = state.inputs.get(state.activeInputId) ?? null; + + // Check if the pipeline actually needs an input source. + // Self-contained ops (fromps, fromdb, frommongo) don't need one. + const firstEnabled = path.find((s) => s.config.enabled); + const pipelineNeedsInput = + !firstEnabled || !SELF_CONTAINED_OPS.has(firstEnabled.config.operationName); + if (pipelineNeedsInput && !input) { throw new Error(`Input ${state.activeInputId} not found`); } // Find nearest cached ancestor - const cachedIndex = findNearestCache(state, path); + const cachedIndex = findNearestCacheInMap(cache, state.activeInputId, path); // Determine starting records and which stages to execute let currentRecords: Record[]; @@ -114,13 +139,21 @@ export async function executeToStage( if (cachedIndex >= 0) { const cachedStage = path[cachedIndex]!; const cacheKey = `${state.activeInputId}:${cachedStage.id}`; - const cached = state.cache.get(cacheKey)!; + const cached = 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)) { + // No cache — check if first enabled stage is a transform (needs input records). + // Also load input if all stages are disabled (firstEnabled is undefined) and + // the target stage is a transform — disabled stages pass through input. + const needsInputRecords = + firstEnabled + ? !isInputOperation(firstEnabled.config.operationName) + : !isInputOperation(path[0]!.config.operationName); + if (needsInputRecords) { + if (!input) { + throw new Error(`No input source for transform stage "${(firstEnabled ?? path[0]!).config.operationName}"`); + } currentRecords = await loadInputRecords(input); } else { currentRecords = []; @@ -140,11 +173,17 @@ export async function executeToStage( const opName = stage.config.operationName; const interceptor = new InterceptReceiver(); - const op = createOperation(opName, [...stage.config.args], interceptor); + const op = createOperationOrShell(opName, [...stage.config.args], interceptor); if (isInputOperation(opName)) { // Input operations: handle the 3 patterns - await executeInputOp(op, opName, input, state); + if (SELF_CONTAINED_OPS.has(opName)) { + // Self-contained ops generate records on their own via finish() + } else if (input) { + await executeInputOp(op, opName, input, state); + } else { + throw new Error(`Input source required for operation "${opName}"`); + } } else { // Transform/output operations: feed records from previous stage for (const record of currentRecords) { @@ -162,20 +201,21 @@ export async function executeToStage( stageId: stage.id, inputId: state.activeInputId, records: interceptor.records, + lines: interceptor.lines, spillFile: null, recordCount: interceptor.recordCount, fieldNames: [...interceptor.fieldNames], computedAt: Date.now(), - sizeBytes: estimateSize(interceptor.records), + sizeBytes: estimateSize(interceptor.records) + estimateLineSize(interceptor.lines), computeTimeMs: elapsed, }; - state.cache.set(result.key, result); + cache.set(result.key, result); } // Return the final stage's cached result const targetCacheKey = `${state.activeInputId}:${targetStageId}`; - const finalResult = state.cache.get(targetCacheKey); + const finalResult = cache.get(targetCacheKey); if (!finalResult) { // This can happen if the target stage was disabled — return empty result const elapsed = performance.now() - startTime; @@ -184,6 +224,7 @@ export async function executeToStage( stageId: targetStageId, inputId: state.activeInputId, records: currentRecords, + lines: [], spillFile: null, recordCount: currentRecords.length, fieldNames: [...new Set(currentRecords.flatMap((r) => r.keys()))], @@ -191,7 +232,7 @@ export async function executeToStage( sizeBytes: estimateSize(currentRecords), computeTimeMs: elapsed, }; - state.cache.set(targetCacheKey, emptyResult); + cache.set(targetCacheKey, emptyResult); return emptyResult; } @@ -259,11 +300,26 @@ async function executeInputOp( /** * Rough estimate of memory size for an array of records. + * Samples up to 10 records and extrapolates to avoid serializing every record. */ function estimateSize(records: Record[]): number { - let size = 0; - for (const r of records) { - size += r.toString().length * 2; // rough: 2 bytes per char + const len = records.length; + if (len === 0) return 0; + const sampleSize = Math.min(10, len); + let sampleTotal = 0; + for (let i = 0; i < sampleSize; i++) { + sampleTotal += records[i]!.toString().length * 2; // rough: 2 bytes per char + } + return Math.round((sampleTotal / sampleSize) * len); +} + +/** + * Rough estimate of memory size for an array of text lines. + */ +function estimateLineSize(lines: string[]): number { + let total = 0; + for (const line of lines) { + total += line.length * 2; } - return size; + return total; } diff --git a/src/tui/executor/input-loader.ts b/src/explorer/executor/input-loader.ts similarity index 64% rename from src/tui/executor/input-loader.ts rename to src/explorer/executor/input-loader.ts index 9529e4b..45a355e 100644 --- a/src/tui/executor/input-loader.ts +++ b/src/explorer/executor/input-loader.ts @@ -1,11 +1,12 @@ -import { InputStream } from "../../InputStream.ts"; import type { Record } from "../../Record.ts"; +import { Record as RecordClass } from "../../Record.ts"; import type { InputSource } from "../model/types.ts"; /** * Load records from an InputSource. * - * - file: reads a JSONL file via InputStream + * - file: reads a JSONL file using Bun.file().text() (avoids ReadableStream + * which can trigger a Bun bug that destroys process.stdin) * - stdin-capture: returns the stored records directly */ export async function loadInputRecords( @@ -13,8 +14,16 @@ export async function loadInputRecords( ): Promise { switch (input.source.kind) { case "file": { - const stream = InputStream.fromFile(input.source.path); - return stream.toArray(); + const text = await Bun.file(input.source.path).text(); + const lines = text.split("\n"); + const records: Record[] = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed !== "") { + records.push(RecordClass.fromJSON(trimmed)); + } + } + return records; } case "stdin-capture": { return input.source.records; diff --git a/src/tui/executor/intercept-receiver.ts b/src/explorer/executor/intercept-receiver.ts similarity index 91% rename from src/tui/executor/intercept-receiver.ts rename to src/explorer/executor/intercept-receiver.ts index b499d06..adc32fd 100644 --- a/src/tui/executor/intercept-receiver.ts +++ b/src/explorer/executor/intercept-receiver.ts @@ -3,7 +3,7 @@ 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 + * collecting them for inspection in the Explorer. Tracks field names and * record counts for the inspector panel. */ export class InterceptReceiver implements RecordReceiver { diff --git a/src/explorer/hooks/useAutoSave.ts b/src/explorer/hooks/useAutoSave.ts new file mode 100644 index 0000000..a835079 --- /dev/null +++ b/src/explorer/hooks/useAutoSave.ts @@ -0,0 +1,50 @@ +/** + * useAutoSave — React hook for debounced session persistence. + * + * Wraps createAutoSave to manage lifecycle within a React component tree. + * Sets up auto-save on mount, cleans up on unmount, and performs a full + * save on quit (via returned saveNow function). + */ + +import { useRef, useEffect, useCallback } from "react"; +import type { PipelineState, PipelineAction } from "../model/types.ts"; +import { + createAutoSave, + type AutoSaveController, +} from "../session/auto-save.ts"; +import { SessionManager } from "../session/session-manager.ts"; + +interface UseAutoSaveResult { + /** Notify auto-save that an action was dispatched. */ + onAction: (action: PipelineAction, state: PipelineState) => void; + /** Perform a full save immediately (call on quit). */ + saveNow: (state: PipelineState) => Promise; +} + +export function useAutoSave(_state: PipelineState): UseAutoSaveResult { + const controllerRef = useRef(null); + + useEffect(() => { + const manager = new SessionManager(); + const controller = createAutoSave(manager); + controllerRef.current = controller; + + return () => { + controller.dispose(); + controllerRef.current = null; + }; + }, []); + + const onAction = useCallback( + (action: PipelineAction, actionState: PipelineState) => { + controllerRef.current?.onAction(action, actionState); + }, + [], + ); + + const saveNow = useCallback(async (saveState: PipelineState) => { + await controllerRef.current?.saveNow(saveState); + }, []); + + return { onAction, saveNow }; +} diff --git a/src/explorer/hooks/useExecution.ts b/src/explorer/hooks/useExecution.ts new file mode 100644 index 0000000..bc254fd --- /dev/null +++ b/src/explorer/hooks/useExecution.ts @@ -0,0 +1,121 @@ +/** + * useExecution — Async pipeline execution hook. + * + * Watches the cursor stage and triggers execution when needed. + * On cache hit: no-op (global state already has the result). + * On cache miss: runs executeToStage and dispatches result to global state. + * + * All execution status is managed via dispatch to the global reducer + * (state.executing, state.lastError, state.cache) rather than local + * useState, so this hook does NOT cause the host component to re-render + * on its own — only the dispatched reducer actions trigger re-renders. + */ + +import { useEffect, useRef } from "react"; +import { executeToStage, getStagePath, SELF_CONTAINED_OPS } from "../executor/executor.ts"; +import type { + PipelineState, + PipelineAction, +} from "../model/types.ts"; + +export function useExecution( + state: PipelineState, + dispatch: (action: PipelineAction) => void, +): void { + // Track the latest execution request to ignore stale completions + const executionIdRef = useRef(0); + + const { cursorStageId, activeInputId, cache } = state; + + // Use a ref to give the effect access to state without re-triggering on every change. + const stateRef = useRef(state); + stateRef.current = state; + + // Derive a cache-hit key so the effect only re-fires when the cache + // entry for the current cursor actually changes (not on every state update). + const cacheKey = cursorStageId ? `${activeInputId}:${cursorStageId}` : ""; + const hasCacheHit = cacheKey ? cache.has(cacheKey) : false; + + // Fingerprint the cursor stage's config so the effect re-fires when args + // or enabled state change — even if the cache had no entry (e.g. after an + // error, where no CACHE_RESULT was dispatched). + const cursorStage = cursorStageId ? state.stages.get(cursorStageId) : undefined; + const stageFingerprint = cursorStage + ? `${cursorStage.config.enabled}:${cursorStage.config.args.join("\0")}` + : ""; + + useEffect(() => { + if (!cursorStageId) return; + + // Check if the cursor stage exists + const currentState = stateRef.current; + const stage = currentState.stages.get(cursorStageId); + if (!stage) return; + + // Check cache first — result already in global state, nothing to do + const key = `${activeInputId}:${cursorStageId}`; + const cached = currentState.cache.get(key); + if (cached) return; + + // Check if there's an input source available. + // Self-contained ops (fromps, fromdb, frommongo) don't need one. + const input = currentState.inputs.get(activeInputId); + if (!input) { + const path = getStagePath(currentState, cursorStageId); + const firstEnabled = path.find((s) => s.config.enabled); + const needsInput = + !firstEnabled || !SELF_CONTAINED_OPS.has(firstEnabled.config.operationName); + if (needsInput) { + dispatch({ + type: "SET_ERROR", + stageId: cursorStageId, + message: "No input source selected", + }); + return; + } + } + + // Cache miss — execute. + // Use a local working cache so executeToStage doesn't mutate state.cache + // directly. This is critical for React: direct mutation defeats memo + // comparators in StageRow that rely on identity checks. + const workingCache = new Map(currentState.cache); + const thisExecId = ++executionIdRef.current; + + dispatch({ type: "SET_EXECUTING", executing: true }); + dispatch({ type: "CLEAR_ERROR" }); + + executeToStage(currentState, cursorStageId, { workingCache }) + .then((_result) => { + // Only apply if this is still the latest execution + if (executionIdRef.current !== thisExecId) return; + + // Dispatch CACHE_RESULT for all newly computed stages (including + // intermediate results). This ensures both StageRow badges and the + // InspectorPanel update correctly via React's state flow. + for (const [key, cachedResult] of workingCache) { + if (!currentState.cache.has(key)) { + dispatch({ + type: "CACHE_RESULT", + inputId: cachedResult.inputId, + stageId: cachedResult.stageId, + result: cachedResult, + }); + } + } + dispatch({ type: "SET_EXECUTING", executing: false }); + }) + .catch((err: unknown) => { + if (executionIdRef.current !== thisExecId) return; + + const message = + err instanceof Error ? err.message : String(err); + dispatch({ + type: "SET_ERROR", + stageId: cursorStageId, + message, + }); + dispatch({ type: "SET_EXECUTING", executing: false }); + }); + }, [cursorStageId, activeInputId, hasCacheHit, stageFingerprint, dispatch]); +} diff --git a/src/tui/hooks/usePipeline.ts b/src/explorer/hooks/usePipeline.ts similarity index 100% rename from src/tui/hooks/usePipeline.ts rename to src/explorer/hooks/usePipeline.ts diff --git a/src/explorer/hooks/useUndoRedo.ts b/src/explorer/hooks/useUndoRedo.ts new file mode 100644 index 0000000..c8289d8 --- /dev/null +++ b/src/explorer/hooks/useUndoRedo.ts @@ -0,0 +1,53 @@ +/** + * useUndoRedo — Exposes undo/redo availability and labels from pipeline state. + * + * Provides derived state for the StatusBar and other components to display + * undo/redo information (counts, availability, last action label). + * + * Note: The actual keyboard bindings (u for undo, Ctrl+R for redo) are + * handled in App.tsx's global useKeyboard handler, not in this hook. + */ + +import { useMemo } from "react"; +import type { PipelineState } from "../model/types.ts"; + +export interface UseUndoRedoResult { + /** Whether undo is available */ + canUndo: boolean; + /** Whether redo is available */ + canRedo: boolean; + /** Number of entries on the undo stack */ + undoCount: number; + /** Number of entries on the redo stack */ + redoCount: number; + /** Label of the next undo action (e.g., "Add grep stage") */ + nextUndoLabel: string | null; + /** Label of the next redo action */ + nextRedoLabel: string | null; +} + +export function useUndoRedo(state: PipelineState): UseUndoRedoResult { + return useMemo(() => { + const undoCount = state.undoStack.length; + const redoCount = state.redoStack.length; + + const nextUndoLabel = + undoCount > 0 + ? (state.undoStack[undoCount - 1]?.label ?? null) + : null; + + const nextRedoLabel = + redoCount > 0 + ? (state.redoStack[redoCount - 1]?.label ?? null) + : null; + + return { + canUndo: undoCount > 0, + canRedo: redoCount > 0, + undoCount, + redoCount, + nextUndoLabel, + nextRedoLabel, + }; + }, [state.undoStack, state.redoStack]); +} diff --git a/src/tui/hooks/useVimIntegration.ts b/src/explorer/hooks/useVimIntegration.ts similarity index 74% rename from src/tui/hooks/useVimIntegration.ts rename to src/explorer/hooks/useVimIntegration.ts index 7f8a04a..308b532 100644 --- a/src/tui/hooks/useVimIntegration.ts +++ b/src/explorer/hooks/useVimIntegration.ts @@ -7,7 +7,7 @@ */ import { useCallback } from "react"; -import { useRenderer } from "@opentui/react"; +import { useApp } from "ink"; import type { Record } from "../../Record.ts"; export interface UseVimIntegrationResult { @@ -16,7 +16,7 @@ export interface UseVimIntegrationResult { } export function useVimIntegration(): UseVimIntegrationResult { - const renderer = useRenderer(); + const { exit } = useApp(); const openInEditor = useCallback( async (records: Record[]) => { @@ -25,7 +25,7 @@ export function useVimIntegration(): UseVimIntegrationResult { // 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 tmpFile = `${tmpDir}/recs-explorer-${Date.now()}.jsonl`; const content = records.map((r) => r.toString()).join("\n") + "\n"; await Bun.write(tmpFile, content); @@ -33,8 +33,9 @@ export function useVimIntegration(): UseVimIntegrationResult { const editor = process.env["EDITOR"] ?? "vim"; try { - // Suspend the TUI renderer so the editor can take over - renderer.destroy(); + // Exit the Ink renderer so the editor can take over the terminal. + // The user can resume via session after the editor closes. + exit(); const proc = Bun.spawn([editor, tmpFile], { stdin: "inherit", @@ -52,12 +53,8 @@ export function useVimIntegration(): UseVimIntegrationResult { // 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], + [exit], ); return { openInEditor }; diff --git a/src/explorer/index.tsx b/src/explorer/index.tsx new file mode 100644 index 0000000..7d3f3fc --- /dev/null +++ b/src/explorer/index.tsx @@ -0,0 +1,64 @@ +/** + * Explorer Pipeline Builder for RecordStream. + * + * This is the main entry point. It creates an Ink renderer + * and mounts the root App component. + */ + +import { render } from "ink"; +import { App } from "./components/App.tsx"; + +export interface ExplorerOptions { + /** 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 Explorer pipeline builder. + * + * Creates an Ink renderer, mounts the React app, and waits until + * the user exits. Ink handles terminal restoration automatically. + */ +export async function launchExplorer(options: ExplorerOptions): Promise { + let instance: ReturnType | null = null; + + try { + instance = render(, { + exitOnCtrlC: false, + }); + } catch (err) { + process.stderr.write( + `Explorer failed to create renderer: ${err instanceof Error ? err.message : String(err)}\n`, + ); + if (err instanceof Error && err.stack) { + process.stderr.write(err.stack + "\n"); + } + process.exit(1); + } + + // Handle uncaught errors during rendering — restore terminal + const cleanup = (err: unknown) => { + try { + instance?.unmount(); + } catch { + // Best-effort cleanup + } + process.stderr.write( + `Explorer crashed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + if (err instanceof Error && err.stack) { + process.stderr.write(err.stack + "\n"); + } + process.exit(1); + }; + + process.on("uncaughtException", cleanup); + process.on("unhandledRejection", cleanup); + + // Wait until the Ink app exits (user quits with q or Ctrl+C) + await instance.waitUntilExit(); +} diff --git a/src/tui/model/reducer.ts b/src/explorer/model/reducer.ts similarity index 90% rename from src/tui/model/reducer.ts rename to src/explorer/model/reducer.ts index aa59969..573bb17 100644 --- a/src/tui/model/reducer.ts +++ b/src/explorer/model/reducer.ts @@ -34,6 +34,7 @@ export function createInitialState( viewMode: "table", scrollOffset: 0, searchQuery: null, + highlightedColumn: null, }; const mainFork: Fork = { @@ -99,6 +100,8 @@ export function pipelineReducer( return { ...state, ...entry.snapshot, + cache: new Map(), + lastError: null, undoStack: state.undoStack.slice(0, -1), redoStack: [ ...state.redoStack, @@ -113,6 +116,8 @@ export function pipelineReducer( return { ...state, ...entry.snapshot, + cache: new Map(), + lastError: null, undoStack: [ ...state.undoStack, { label: entry.label, snapshot: currentSnapshot, timestamp: Date.now() }, @@ -311,10 +316,22 @@ export function pipelineReducer( const stage = state.stages.get(action.stageId); if (!stage) return state; + // Skip update (and cache invalidation) if args haven't actually changed. + // This avoids a race where dismissing EditStageModal with unchanged args + // invalidates a freshly-computed cache result. + const oldArgs = stage.config.args; + const newArgs = action.args; + if ( + oldArgs.length === newArgs.length && + oldArgs.every((a, i) => a === newArgs[i]) + ) { + return state; + } + const stages = new Map(state.stages); stages.set(action.stageId, { ...stage, - config: { ...stage.config, args: [...action.args] }, + config: { ...stage.config, args: [...newArgs] }, }); const cache = invalidateStageAndDownstream(state.cache, state.forks, stage); @@ -541,6 +558,32 @@ export function pipelineReducer( state.focusedPanel === "pipeline" ? "inspector" : "pipeline", }; + // ── Column highlight ──────────────────────────────────────── + case "MOVE_COLUMN_HIGHLIGHT": { + const current = state.inspector.highlightedColumn; + const max = action.fieldCount - 1; + if (max < 0) return state; + + let next: number; + if (current === null) { + next = action.direction === "left" ? max : 0; + } else if (action.direction === "left") { + next = current <= 0 ? 0 : current - 1; + } else { + next = current >= max ? max : current + 1; + } + return { + ...state, + inspector: { ...state.inspector, highlightedColumn: next }, + }; + } + + case "CLEAR_COLUMN_HIGHLIGHT": + return { + ...state, + inspector: { ...state.inspector, highlightedColumn: null }, + }; + // ── Inspector view mode ───────────────────────────────────── case "SET_VIEW_MODE": return { @@ -548,6 +591,13 @@ export function pipelineReducer( inspector: { ...state.inspector, viewMode: action.viewMode }, }; + // ── Session name ───────────────────────────────────────────── + case "SET_SESSION_NAME": + return { + ...state, + sessionName: action.name, + }; + default: return state; } @@ -627,9 +677,19 @@ function invalidateStageAndDownstream( 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 "UPDATE_STAGE_ARGS": { + const stage = state.stages.get(action.stageId); + if (!stage) return true; + // No-op if args haven't actually changed + const oldArgs = stage.config.args; + const newArgs = action.args; + return ( + oldArgs.length === newArgs.length && + oldArgs.every((a, i) => a === newArgs[i]) + ); + } case "INSERT_STAGE_BEFORE": return !state.stages.has(action.beforeStageId); case "REORDER_STAGE": { diff --git a/src/tui/model/selectors.ts b/src/explorer/model/selectors.ts similarity index 59% rename from src/tui/model/selectors.ts rename to src/explorer/model/selectors.ts index 753c421..8af0f6c 100644 --- a/src/tui/model/selectors.ts +++ b/src/explorer/model/selectors.ts @@ -2,6 +2,8 @@ import type { PipelineState, Stage, StageId, + StageKind, + StageDelta, CachedResult, } from "./types.ts"; @@ -106,3 +108,71 @@ export function getTotalCacheSize(state: PipelineState): number { export function getEnabledStages(state: PipelineState): Stage[] { return getActivePath(state).filter((s) => s.config.enabled); } + +/** + * Classify a stage's operation into a broad kind for delta display. + */ +export function getStageKind(operationName: string): StageKind { + if (operationName.startsWith("from")) return "input"; + switch (operationName) { + case "sort": + return "reorder"; + case "grep": + return "filter"; + case "collate": + case "substream": + return "aggregate"; + default: + return "transform"; + } +} + +/** + * Compute the delta between a stage and its parent (previous stage output). + * Returns undefined if the stage has no cached result. + */ +export function getStageDelta( + state: PipelineState, + stageId: StageId, +): StageDelta | undefined { + const cached = getStageOutput(state, stageId); + if (!cached) return undefined; + + const stage = state.stages.get(stageId); + if (!stage) return undefined; + + const kind = getStageKind(stage.config.operationName); + + // Get parent cached result (if any) + let parentCached: CachedResult | undefined; + if (stage.parentId) { + parentCached = getStageOutput(state, stage.parentId); + } + + const parentFields = parentCached ? new Set(parentCached.fieldNames) : new Set(); + const currentFields = new Set(cached.fieldNames); + + let fieldsAdded = 0; + let fieldsRemoved = 0; + if (parentCached) { + for (const f of currentFields) { + if (!parentFields.has(f)) fieldsAdded++; + } + for (const f of parentFields) { + if (!currentFields.has(f)) fieldsRemoved++; + } + } + + // For text-output stages (lines but no records), report line count instead + const isTextOutput = cached.records.length === 0 && cached.lines.length > 0; + const outputCount = isTextOutput ? cached.lines.length : cached.recordCount; + + return { + kind, + parentCount: parentCached ? parentCached.recordCount : null, + outputCount, + fieldsAdded, + fieldsRemoved, + isTextOutput, + }; +} diff --git a/src/tui/model/serialization.ts b/src/explorer/model/serialization.ts similarity index 73% rename from src/tui/model/serialization.ts rename to src/explorer/model/serialization.ts index ec19518..b204228 100644 --- a/src/tui/model/serialization.ts +++ b/src/explorer/model/serialization.ts @@ -5,6 +5,15 @@ import type { PipelineState, InputSource, Stage } from "./types.ts"; import { getEnabledStages } from "./selectors.ts"; +import { allDocs } from "../../cli/operation-registry.ts"; + +/** + * Check whether an operation name is a known recs operation + * (as opposed to an arbitrary shell command like head, jq, etc.). + */ +function isKnownRecsOp(name: string): boolean { + return allDocs.some((d) => d.name === name); +} /** * Characters that need shell escaping (in addition to single quotes). @@ -76,10 +85,13 @@ export function exportAsChainCommand( } /** - * Format a stage as a `recs ` shell command fragment. + * Format a stage as a shell command fragment. + * Known recs operations get the `recs` prefix; shell commands do not. */ function formatStageCommand(stage: Stage): string { - const parts = ["recs", stage.config.operationName]; + const parts = isKnownRecsOp(stage.config.operationName) + ? ["recs", stage.config.operationName] + : [stage.config.operationName]; for (const arg of stage.config.args) { parts.push(shellEscape(arg)); } @@ -97,6 +109,31 @@ function formatChainPart(stage: Stage): string { return parts.join(" "); } +/** + * Export the pipeline as a single-line shell pipe command. + * + * Example output: + * ``` + * recs fromcsv data.csv | recs grep 'r.age > 25' | head -5 | recs totable + * ``` + */ +export function exportAsOneLiner( + state: PipelineState, + inputSource?: InputSource, +): string { + const stages = getEnabledStages(state); + if (stages.length === 0) return ""; + + const input = inputSource ?? state.inputs.get(state.activeInputId); + const parts = stages.map((stage) => formatStageCommand(stage)); + + if (input?.source.kind === "file") { + parts[0] = `${parts[0]} ${shellEscape(input.source.path)}`; + } + + return parts.join(" | "); +} + /** * Copy text to the system clipboard. * diff --git a/src/tui/model/types.ts b/src/explorer/model/types.ts similarity index 72% rename from src/tui/model/types.ts rename to src/explorer/model/types.ts index a6e8886..bfa8dc7 100644 --- a/src/tui/model/types.ts +++ b/src/explorer/model/types.ts @@ -42,6 +42,7 @@ export interface CachedResult { stageId: StageId; inputId: InputId; records: Record[]; + lines: string[]; spillFile: string | null; recordCount: number; fieldNames: string[]; @@ -60,6 +61,7 @@ export interface InspectorState { viewMode: "table" | "prettyprint" | "json" | "schema"; scrollOffset: number; searchQuery: string | null; + highlightedColumn: number | null; } export interface UndoEntry { @@ -94,6 +96,20 @@ export interface PipelineState { redoStack: UndoEntry[]; sessionId: string; sessionDir: string; + sessionName?: string; +} + +// ── Stage Deltas ───────────────────────────────────────────────── + +export type StageKind = "filter" | "reorder" | "aggregate" | "transform" | "input"; + +export interface StageDelta { + kind: StageKind; + parentCount: number | null; // null for input stages or no parent cache + outputCount: number; + fieldsAdded: number; + fieldsRemoved: number; + isTextOutput?: boolean; } // ── Actions ─────────────────────────────────────────────────────── @@ -130,7 +146,10 @@ export type PipelineAction = | { type: "CLEAR_ERROR" } | { type: "SET_EXECUTING"; executing: boolean } | { type: "TOGGLE_FOCUS" } - | { type: "SET_VIEW_MODE"; viewMode: InspectorState["viewMode"] }; + | { type: "SET_VIEW_MODE"; viewMode: InspectorState["viewMode"] } + | { type: "MOVE_COLUMN_HIGHLIGHT"; direction: "left" | "right"; fieldCount: number } + | { type: "CLEAR_COLUMN_HIGHLIGHT" } + | { type: "SET_SESSION_NAME"; name: string }; // ── File Size Warning ───────────────────────────────────────────── @@ -146,3 +165,40 @@ export const FILE_SIZE_THRESHOLDS = { warn: 100 * 1024 * 1024, // 100 MB danger: 1024 * 1024 * 1024, // 1 GB } as const; + +// ── Session Persistence ────────────────────────────────────────── + +export interface CacheManifestEntry { + key: string; // `${inputId}:${stageId}` + cacheKey: CacheKey; + recordCount: number; + fieldNames: string[]; + sizeBytes: number; + computedAt: number; + computeTimeMs: number; + file: string; // relative path to JSONL file in cache/ dir +} + +export interface SessionFile { + version: 1; + sessionId: string; + name?: string; + createdAt: number; + lastAccessedAt: number; + pipeline: PipelineSnapshot; + undoStack: UndoEntry[]; + redoStack: UndoEntry[]; + cacheConfig: CacheConfig; + cacheManifest: CacheManifestEntry[]; +} + +export interface SessionMetadata { + sessionId: string; + name?: string; + createdAt: number; + lastAccessedAt: number; + inputPaths: string[]; + stageCount: number; + cacheSizeBytes: number; + pipelineSummary: string; +} diff --git a/src/tui/model/undo.ts b/src/explorer/model/undo.ts similarity index 100% rename from src/tui/model/undo.ts rename to src/explorer/model/undo.ts diff --git a/src/explorer/session/auto-save.ts b/src/explorer/session/auto-save.ts new file mode 100644 index 0000000..427c157 --- /dev/null +++ b/src/explorer/session/auto-save.ts @@ -0,0 +1,112 @@ +/** + * Debounced auto-save for Explorer session persistence. + * + * Saves the pipeline state at a regular interval (30s) and immediately + * (debounced) on structural changes. Performs a full save on quit. + * Integrates with UNDOABLE_ACTIONS to detect structural changes. + */ + +import type { PipelineState, PipelineAction } from "../model/types.ts"; +import { UNDOABLE_ACTIONS } from "../model/undo.ts"; +import { SessionManager } from "./session-manager.ts"; + +const AUTO_SAVE_INTERVAL_MS = 30_000; // 30 seconds +const DEBOUNCE_MS = 2_000; // 2 seconds after structural change + +export interface AutoSaveController { + /** Notify auto-save that an action was dispatched. */ + onAction(action: PipelineAction, state: PipelineState): void; + /** Perform a full save immediately (call on quit). */ + saveNow(state: PipelineState): Promise; + /** Stop the auto-save timers and clean up. */ + dispose(): void; +} + +/** + * Create an auto-save controller that manages debounced and interval saves. + */ +export function createAutoSave( + sessionManager: SessionManager, + options?: { + intervalMs?: number; + debounceMs?: number; + }, +): AutoSaveController { + const intervalMs = options?.intervalMs ?? AUTO_SAVE_INTERVAL_MS; + const debounceMs = options?.debounceMs ?? DEBOUNCE_MS; + + let intervalTimer: ReturnType | null = null; + let debounceTimer: ReturnType | null = null; + let latestState: PipelineState | null = null; + let saving = false; + let dirty = false; + + async function doSave(state: PipelineState): Promise { + if (saving) return; + saving = true; + try { + await sessionManager.save(state); + dirty = false; + } catch { + // Save failed — will retry on next trigger + } finally { + saving = false; + } + } + + function startInterval(): void { + if (intervalTimer !== null) return; + intervalTimer = setInterval(() => { + if (latestState && dirty) { + void doSave(latestState); + } + }, intervalMs); + } + + function scheduleDebouncedSave(): void { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + if (latestState && dirty) { + void doSave(latestState); + } + }, debounceMs); + } + + // Start the interval timer + startInterval(); + + return { + onAction(action: PipelineAction, state: PipelineState): void { + latestState = state; + + if (UNDOABLE_ACTIONS.has(action.type)) { + dirty = true; + scheduleDebouncedSave(); + } + }, + + async saveNow(state: PipelineState): Promise { + latestState = state; + // Cancel any pending debounce + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + await doSave(state); + }, + + dispose(): void { + if (intervalTimer !== null) { + clearInterval(intervalTimer); + intervalTimer = null; + } + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + }, + }; +} diff --git a/src/explorer/session/session-cache-store.ts b/src/explorer/session/session-cache-store.ts new file mode 100644 index 0000000..993bdcd --- /dev/null +++ b/src/explorer/session/session-cache-store.ts @@ -0,0 +1,160 @@ +/** + * JSONL file-based cache storage for session persistence. + * + * Writes CachedResult records to .jsonl files in the session's cache/ directory. + * Supports lazy loading: cache manifest (metadata) is loaded on resume, + * but actual record data is only read from disk when a stage is inspected. + */ + +import { join } from "node:path"; +import { mkdir } from "node:fs/promises"; +import type { + CachedResult, + CacheManifestEntry, + InputId, + StageId, +} from "../model/types.ts"; +import { Record } from "../../Record.ts"; + +/** + * Manages reading and writing cached records as JSONL files in a session directory. + */ +export class SessionCacheStore { + readonly cacheDir: string; + + constructor(sessionDir: string) { + this.cacheDir = join(sessionDir, "cache"); + } + + /** + * Ensure the cache directory exists. + */ + async init(): Promise { + await mkdir(this.cacheDir, { recursive: true }); + } + + /** + * Write a CachedResult's records to a JSONL file. + * Returns a CacheManifestEntry describing the written file. + */ + async writeCache(result: CachedResult): Promise { + await this.init(); + + const fileName = `${result.inputId}-${result.stageId}.jsonl`; + const filePath = join(this.cacheDir, fileName); + + const lines = result.records.map((r) => JSON.stringify(r.toJSON())); + const content = lines.join("\n") + (lines.length > 0 ? "\n" : ""); + + await Bun.write(filePath, content); + + return { + key: result.key, + cacheKey: result.key, + recordCount: result.recordCount, + fieldNames: [...result.fieldNames], + sizeBytes: result.sizeBytes, + computedAt: result.computedAt, + computeTimeMs: result.computeTimeMs, + file: `cache/${fileName}`, + }; + } + + /** + * Read cached records from a JSONL file (lazy loading). + * Returns a full CachedResult with records populated. + */ + async readCache( + manifest: CacheManifestEntry, + sessionDir: string, + ): Promise { + const filePath = join(sessionDir, manifest.file); + const content = await Bun.file(filePath).text(); + const records = parseJsonlRecords(content); + + // Parse inputId and stageId from the key + const [inputId, stageId] = parseKey(manifest.key); + + return { + key: manifest.key, + stageId, + inputId, + records, + lines: [], + spillFile: null, + recordCount: manifest.recordCount, + fieldNames: [...manifest.fieldNames], + computedAt: manifest.computedAt, + sizeBytes: manifest.sizeBytes, + computeTimeMs: manifest.computeTimeMs, + }; + } + + /** + * Write all cached results from state to disk. + * Returns the cache manifest for session.json. + */ + async writeAllCaches( + cache: Map, + ): Promise { + const entries: CacheManifestEntry[] = []; + for (const result of cache.values()) { + const entry = await this.writeCache(result); + entries.push(entry); + } + return entries; + } + + /** + * Remove a specific cache file. + */ + async removeCache(inputId: InputId, stageId: StageId): Promise { + const fileName = `${inputId}-${stageId}.jsonl`; + const filePath = join(this.cacheDir, fileName); + try { + const { unlink } = await import("node:fs/promises"); + await unlink(filePath); + } catch { + // File may not exist — that's fine + } + } + + /** + * Remove the entire cache directory. + */ + async clearAll(): Promise { + try { + const { rm } = await import("node:fs/promises"); + await rm(this.cacheDir, { recursive: true, force: true }); + } catch { + // Directory may not exist + } + } +} + +/** + * Parse JSONL content into Record objects. + */ +function parseJsonlRecords(content: string): Record[] { + const records: Record[] = []; + const lines = content.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === "") continue; + try { + records.push(Record.fromJSON(trimmed)); + } catch { + // Skip malformed lines + } + } + return records; +} + +/** + * Parse a cache key string ("inputId:stageId") into its components. + */ +function parseKey(key: string): [InputId, StageId] { + const colonIdx = key.indexOf(":"); + if (colonIdx === -1) return [key, key]; + return [key.slice(0, colonIdx), key.slice(colonIdx + 1)]; +} diff --git a/src/explorer/session/session-manager.ts b/src/explorer/session/session-manager.ts new file mode 100644 index 0000000..83456ec --- /dev/null +++ b/src/explorer/session/session-manager.ts @@ -0,0 +1,422 @@ +/** + * Session manager for Explorer pipeline builder. + * + * Handles saving, loading, listing, and cleaning sessions stored at + * ~/.config/recs-explorer/sessions//. Each session directory contains: + * - session.json: pipeline structure, undo/redo, cache manifest + * - cache/: JSONL files for cached stage results + * - meta.json: session metadata for listing/discovery + */ + +import { join, basename } from "node:path"; +import { homedir } from "node:os"; +import { mkdir, readdir, rm, stat } from "node:fs/promises"; +import type { + PipelineState, + SessionFile, + SessionMetadata, + CacheConfig, + PipelineSnapshot, + UndoEntry, +} from "../model/types.ts"; +import { extractSnapshot } from "../model/undo.ts"; +import { SessionCacheStore } from "./session-cache-store.ts"; + +const SESSIONS_BASE_DIR = join(homedir(), ".config", "recs-explorer", "sessions"); +const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** + * Get the base directory where all sessions are stored. + */ +export function getSessionsBaseDir(): string { + return SESSIONS_BASE_DIR; +} + +export class SessionManager { + readonly baseDir: string; + + constructor(baseDir?: string) { + this.baseDir = baseDir ?? SESSIONS_BASE_DIR; + } + + /** + * Get the directory path for a specific session. + */ + sessionDir(sessionId: string): string { + return join(this.baseDir, sessionId); + } + + /** + * Save the current pipeline state as a session on disk. + * Creates/overwrites session.json and meta.json, and writes cache files. + */ + async save(state: PipelineState): Promise { + const dir = this.sessionDir(state.sessionId); + await mkdir(dir, { recursive: true }); + + const cacheStore = new SessionCacheStore(dir); + const cacheManifest = await cacheStore.writeAllCaches(state.cache); + + const snapshot = extractSnapshot(state); + const sessionFile: SessionFile = { + version: 1, + sessionId: state.sessionId, + name: state.sessionName, + createdAt: Date.now(), + lastAccessedAt: Date.now(), + pipeline: serializeSnapshot(snapshot), + undoStack: state.undoStack.map(serializeUndoEntry), + redoStack: state.redoStack.map(serializeUndoEntry), + cacheConfig: serializeCacheConfig(state.cacheConfig), + cacheManifest, + }; + + await Bun.write( + join(dir, "session.json"), + JSON.stringify(sessionFile, jsonReplacer, 2), + ); + + const metadata = buildMetadata(state, cacheManifest); + await Bun.write( + join(dir, "meta.json"), + JSON.stringify(metadata, null, 2), + ); + } + + /** + * Save the current pipeline state under a new named session. + * Creates a new session ID and assigns the given name. + * Returns the new session ID. + */ + async saveAs(state: PipelineState, name: string): Promise { + const { nanoid } = await import("nanoid"); + const newSessionId = nanoid(); + const namedState: PipelineState = { + ...state, + sessionId: newSessionId, + sessionName: name, + sessionDir: this.sessionDir(newSessionId), + }; + await this.save(namedState); + return newSessionId; + } + + /** + * Rename an existing session. Updates session.json and meta.json on disk. + */ + async rename(sessionId: string, name: string): Promise { + const dir = this.sessionDir(sessionId); + const sessionPath = join(dir, "session.json"); + const metaPath = join(dir, "meta.json"); + + try { + const sessionContent = await Bun.file(sessionPath).text(); + const sessionData = JSON.parse(sessionContent) as SessionFile; + sessionData.name = name; + await Bun.write(sessionPath, JSON.stringify(sessionData, null, 2)); + } catch { + // Session file missing — skip + } + + try { + const metaContent = await Bun.file(metaPath).text(); + const metaData = JSON.parse(metaContent) as SessionMetadata; + metaData.name = name; + await Bun.write(metaPath, JSON.stringify(metaData, null, 2)); + } catch { + // Meta file missing — skip + } + } + + /** + * Load a session from disk by session ID. + * Returns the SessionFile (pipeline is not yet hydrated into PipelineState). + */ + async load(sessionId: string): Promise { + const dir = this.sessionDir(sessionId); + const filePath = join(dir, "session.json"); + + const content = await Bun.file(filePath).text(); + const raw = JSON.parse(content) as SessionFile; + + // Update last accessed time + raw.lastAccessedAt = Date.now(); + await Bun.write(filePath, JSON.stringify(raw, null, 2)); + + return raw; + } + + /** + * Hydrate a SessionFile into a PipelineState. + * Cache records are NOT loaded — they are lazy-loaded via SessionCacheStore. + */ + hydrate(session: SessionFile): PipelineState { + const snapshot = deserializeSnapshot(session.pipeline); + const cacheConfig = deserializeCacheConfig(session.cacheConfig); + + return { + ...snapshot, + focusedPanel: "pipeline", + cache: new Map(), + cacheConfig, + inspector: { + viewMode: "table", + scrollOffset: 0, + searchQuery: null, + highlightedColumn: null, + }, + executing: false, + lastError: null, + undoStack: session.undoStack.map(deserializeUndoEntry), + redoStack: session.redoStack.map(deserializeUndoEntry), + sessionId: session.sessionId, + sessionDir: this.sessionDir(session.sessionId), + sessionName: session.name, + }; + } + + /** + * List all saved sessions with their metadata, sorted by last accessed time. + */ + async list(): Promise { + try { + await mkdir(this.baseDir, { recursive: true }); + const entries = await readdir(this.baseDir, { withFileTypes: true }); + const sessions: SessionMetadata[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const metaPath = join(this.baseDir, entry.name, "meta.json"); + try { + const content = await Bun.file(metaPath).text(); + const meta = JSON.parse(content) as SessionMetadata; + sessions.push(meta); + } catch { + // Skip sessions with missing/corrupt metadata + } + } + + // Sort by most recently accessed first + sessions.sort((a, b) => b.lastAccessedAt - a.lastAccessedAt); + return sessions; + } catch { + return []; + } + } + + /** + * Find a session that was using a specific input file path. + * Returns the most recently accessed matching session, or null. + */ + async findByInputPath(filePath: string): Promise { + const resolved = basename(filePath); + const sessions = await this.list(); + + for (const session of sessions) { + for (const inputPath of session.inputPaths) { + if (inputPath === filePath || basename(inputPath) === resolved) { + return session; + } + } + } + return null; + } + + /** + * Remove sessions older than 7 days (configurable via maxAgeMs). + * Returns the number of sessions removed. + */ + async clean(maxAgeMs: number = SESSION_MAX_AGE_MS): Promise { + const now = Date.now(); + let removed = 0; + + try { + const entries = await readdir(this.baseDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const metaPath = join(this.baseDir, entry.name, "meta.json"); + try { + const content = await Bun.file(metaPath).text(); + const meta = JSON.parse(content) as SessionMetadata; + + if (now - meta.lastAccessedAt > maxAgeMs) { + await rm(join(this.baseDir, entry.name), { + recursive: true, + force: true, + }); + removed++; + } + } catch { + // If we can't read metadata, check file system timestamps + const sessionDir = join(this.baseDir, entry.name); + try { + const stats = await stat(sessionDir); + if (now - stats.mtimeMs > maxAgeMs) { + await rm(sessionDir, { recursive: true, force: true }); + removed++; + } + } catch { + // Can't stat either — skip + } + } + } + } catch { + // Base directory doesn't exist — nothing to clean + } + + return removed; + } + + /** + * Delete a specific session by ID. + */ + async delete(sessionId: string): Promise { + const dir = this.sessionDir(sessionId); + await rm(dir, { recursive: true, force: true }); + } + + /** + * Check whether input files from a session still exist at their original paths. + * Returns an array of missing file paths. + */ + async verifyInputFiles(session: SessionFile): Promise { + const missing: string[] = []; + const snapshot = deserializeSnapshot(session.pipeline); + + for (const input of snapshot.inputs.values()) { + if (input.source.kind === "file") { + try { + await stat(input.source.path); + } catch { + missing.push(input.source.path); + } + } + } + + return missing; + } +} + +// ── Serialization helpers ──────────────────────────────────────── +// Maps/Sets cannot be serialized to JSON directly. We use a JSON +// replacer to convert them to arrays, and restore on deserialization. + +/** + * JSON.stringify replacer that converts Map → [key, value][] and Set → value[]. + */ +function jsonReplacer(_key: string, value: unknown): unknown { + if (value instanceof Map) return Array.from(value.entries()); + if (value instanceof Set) return Array.from(value); + return value; +} + +function serializeSnapshot(snapshot: PipelineSnapshot): PipelineSnapshot { + return snapshot; +} + +function serializeUndoEntry(entry: UndoEntry): UndoEntry { + return entry; +} + +function serializeCacheConfig(config: CacheConfig): CacheConfig { + return config; +} + +function deserializeSnapshot(raw: PipelineSnapshot): PipelineSnapshot { + return { + stages: toMap(raw.stages), + forks: toMap(raw.forks), + inputs: toMap(raw.inputs), + activeInputId: raw.activeInputId, + activeForkId: raw.activeForkId, + cursorStageId: raw.cursorStageId, + }; +} + +function deserializeUndoEntry(raw: UndoEntry): UndoEntry { + return { + label: raw.label, + snapshot: deserializeSnapshot(raw.snapshot), + timestamp: raw.timestamp, + }; +} + +function deserializeCacheConfig(raw: CacheConfig): CacheConfig { + return { + maxMemoryBytes: raw.maxMemoryBytes, + cachePolicy: raw.cachePolicy, + pinnedStageIds: toSet(raw.pinnedStageIds), + }; +} + +/** + * Convert a value that may be a Map, an array of [key, value] pairs, + * or a plain object into a Map. + */ +function toMap(value: unknown): Map { + if (value instanceof Map) return value; + if (Array.isArray(value)) return new Map(value as Array<[K, V]>); + // Plain object from JSON + if (value !== null && typeof value === "object") { + return new Map(Object.entries(value) as Array<[K, V]>); + } + return new Map(); +} + +/** + * Convert a value that may be a Set or an array into a Set. + */ +function toSet(value: unknown): Set { + if (value instanceof Set) return value; + if (Array.isArray(value)) return new Set(value as T[]); + return new Set(); +} + +// ── Metadata builder ───────────────────────────────────────────── + +function buildMetadata( + state: PipelineState, + cacheManifest: Array<{ sizeBytes: number }>, +): SessionMetadata { + const inputPaths: string[] = []; + for (const input of state.inputs.values()) { + if (input.source.kind === "file") { + inputPaths.push(input.source.path); + } + } + + const fork = state.forks.get(state.activeForkId); + const stageCount = fork?.stageIds.length ?? 0; + + const stageNames: string[] = []; + if (fork) { + for (const stageId of fork.stageIds.slice(0, 5)) { + const stage = state.stages.get(stageId); + if (stage) stageNames.push(stage.config.operationName); + } + } + + const summary = + stageCount === 0 + ? "empty pipeline" + : `${stageNames.join(" | ")}${stageCount > 5 ? ` (+${stageCount - 5} more)` : ""}`; + + const cacheSizeBytes = cacheManifest.reduce( + (sum, entry) => sum + entry.sizeBytes, + 0, + ); + + return { + sessionId: state.sessionId, + name: state.sessionName, + createdAt: Date.now(), + lastAccessedAt: Date.now(), + inputPaths, + stageCount, + cacheSizeBytes, + pipelineSummary: summary, + }; +} diff --git a/src/explorer/theme.ts b/src/explorer/theme.ts new file mode 100644 index 0000000..2499fa9 --- /dev/null +++ b/src/explorer/theme.ts @@ -0,0 +1,17 @@ +export const theme = { + base: '#1e1e2e', // background + surface0: '#313244', // surface + surface1: '#45475a', // lighter surface + overlay0: '#6c7086', // muted text + text: '#cdd6f4', // main text + subtext0: '#a6adc8', // secondary text + red: '#f38ba8', // errors, filters + green: '#a6e3a1', // success, strings + yellow: '#f9e2af', // warnings, stale + blue: '#89b4fa', // info, computing + mauve: '#cba6f7', // accents + teal: '#94e2d5', // numbers + peach: '#fab387', // highlights + flamingo: '#f2cdcd', // secondary accent + lavender: '#b4befe', // links/keys +}; diff --git a/src/explorer/utils/file-detect.ts b/src/explorer/utils/file-detect.ts new file mode 100644 index 0000000..1656745 --- /dev/null +++ b/src/explorer/utils/file-detect.ts @@ -0,0 +1,65 @@ +/** + * Auto-detect file type from extension and return the appropriate + * fromXXX stage config to insert as the first pipeline stage. + * + * Returns null for native formats (JSONL/JSON/NDJSON) or unknown extensions. + */ + +import type { StageConfig } from "../model/types.ts"; + +/** Extensions that are natively supported (JSONL record format). */ +const NATIVE_EXTENSIONS = new Set([".jsonl", ".json", ".ndjson"]); + +/** + * Map of file extensions to the fromXXX stage config that should be + * auto-inserted when that file type is opened. + */ +const EXTENSION_MAP: Record = { + ".csv": { + operationName: "fromcsv", + args: ["--header"], + enabled: true, + }, + ".tsv": { + operationName: "fromcsv", + args: ["--header", "--delim", "\t"], + enabled: true, + }, + ".xml": { + operationName: "fromxml", + args: [], + enabled: true, + }, +}; + +/** + * Detect the appropriate fromXXX operation for a given file path. + * + * @returns A StageConfig to insert as stage 0, or null if the file + * is a native format or the extension is unrecognized. + */ +export function detectInputOperation(filePath: string): StageConfig | null { + const ext = extname(filePath).toLowerCase(); + + if (NATIVE_EXTENSIONS.has(ext)) { + return null; + } + + return EXTENSION_MAP[ext] ?? null; +} + +/** + * Check if a file extension is a natively supported record format. + */ +export function isNativeFormat(filePath: string): boolean { + const ext = extname(filePath).toLowerCase(); + return NATIVE_EXTENSIONS.has(ext); +} + +/** Extract the file extension including the leading dot. */ +function extname(filePath: string): string { + const basename = filePath.split("/").pop() ?? filePath; + const dotIndex = basename.lastIndexOf("."); + if (dotIndex <= 0) return ""; + return basename.slice(dotIndex); +} diff --git a/src/tui/utils/fuzzy-match.ts b/src/explorer/utils/fuzzy-match.ts similarity index 54% rename from src/tui/utils/fuzzy-match.ts rename to src/explorer/utils/fuzzy-match.ts index b80b956..e76ae64 100644 --- a/src/tui/utils/fuzzy-match.ts +++ b/src/explorer/utils/fuzzy-match.ts @@ -55,22 +55,59 @@ export function fuzzyMatch(query: string, target: string): FuzzyResult { return { matches: true, score }; } +export interface FuzzyFilterOptions { + /** + * Extract a short "name" field from each item. When provided, the query is + * matched against the name first. Name matches receive a large score bonus + * so they always rank above description-only matches. + */ + getName?: (item: T) => string; + /** + * Minimum score required to include an item in results. Items scoring below + * this threshold are filtered out. Default: 0 (show all matches). + */ + minScore?: number; +} + /** * Filter and sort items by fuzzy match quality. + * + * When `options.getName` is provided, matching is two-tier: + * 1. Try matching the query against the name (with a +200 score bonus). + * 2. If the name doesn't match, fall back to full-text matching. + * + * This ensures that an operation whose *name* matches the query always ranks + * above operations that only match via their description text. */ export function fuzzyFilter( items: T[], query: string, getText: (item: T) => string, + options?: FuzzyFilterOptions, ): T[] { if (query.length === 0) return items; + const getName = options?.getName; + const minScore = options?.minScore ?? 0; + const scored = items .map((item) => { + // Tier 1: match against name (if extractor provided) + if (getName) { + const nameResult = fuzzyMatch(query, getName(item)); + if (nameResult.matches) { + // Bonus for name match + tiebreaker favoring shorter names + // (the query covers a larger fraction of shorter names). + const name = getName(item); + const coverageBonus = Math.round((query.length / name.length) * 50); + return { item, matches: true, score: nameResult.score + 200 + coverageBonus }; + } + } + // Tier 2: match against full text (name + description) const result = fuzzyMatch(query, getText(item)); return { item, ...result }; }) - .filter((r) => r.matches) + .filter((r) => r.matches && r.score >= minScore) .sort((a, b) => b.score - a.score); return scored.map((r) => r.item); diff --git a/src/explorer/utils/vim-text-engine.ts b/src/explorer/utils/vim-text-engine.ts new file mode 100644 index 0000000..6c6c962 --- /dev/null +++ b/src/explorer/utils/vim-text-engine.ts @@ -0,0 +1,1218 @@ +/** + * vim-text-engine — Pure state machine for vim-style text editing. + * + * Zero React dependencies. Receives an ink Key + input string, + * current state, and value; returns the next state + value. + */ + +import type { Key } from "ink"; + +// ── Types ────────────────────────────────────────────────────── + +export type VimMode = "insert" | "normal"; + +export type PendingOp = + | null + | { kind: "d" } + | { kind: "c" } + | { kind: "r" } + | { kind: "g" } + | { kind: "f" } + | { kind: "t" } + | { kind: "F" } + | { kind: "T" } + | { kind: "df" } + | { kind: "dt" } + | { kind: "cf" } + | { kind: "ct" } + | { kind: "dF" } + | { kind: "dT" } + | { kind: "cF" } + | { kind: "cT" }; + +export interface LastFind { + dir: "forward" | "backward"; + inclusive: boolean; + char: string; +} + +export interface VimState { + mode: VimMode; + cursorOffset: number; + pending: PendingOp; + lastFind?: LastFind | null; + lastInsertOffset?: number; +} + +export interface VimResult { + state: VimState; + value: string; + escaped: boolean; + submitted: boolean; + passThrough: boolean; +} + +// ── Word boundary helpers (exported for testing) ─────────────── + +export function charClass(ch: string): "word" | "punct" | "space" { + if (/\s/.test(ch)) return "space"; + if (/\w/.test(ch)) return "word"; + return "punct"; +} + +/** Find the start of the next vim word (w motion). Returns unclamped position. */ +export function findNextWordStart(text: string, pos: number): number { + if (pos >= text.length) return text.length; + const startClass = charClass(text[pos]!); + let i = pos; + // Skip current class + while (i < text.length && charClass(text[i]!) === startClass) i++; + // Skip whitespace + while (i < text.length && charClass(text[i]!) === "space") i++; + return i; +} + +/** Find the start of the previous vim word (b motion). */ +export function findPrevWordStart(text: string, pos: number): number { + if (pos <= 0) return 0; + let i = pos - 1; + // Skip whitespace + while (i > 0 && charClass(text[i]!) === "space") i--; + if (i <= 0) return 0; + const targetClass = charClass(text[i]!); + // Move back through same class + while (i > 0 && charClass(text[i - 1]!) === targetClass) i--; + return i; +} + +/** Find the start of the next WORD (W motion, whitespace-delimited). Returns unclamped. */ +export function findNextWORDStart(text: string, pos: number): number { + if (pos >= text.length) return text.length; + let i = pos; + // Skip non-space + while (i < text.length && charClass(text[i]!) !== "space") i++; + // Skip space + while (i < text.length && charClass(text[i]!) === "space") i++; + return i; +} + +/** Find the start of the previous WORD (B motion, whitespace-delimited). */ +export function findPrevWORDStart(text: string, pos: number): number { + if (pos <= 0) return 0; + let i = pos - 1; + // Skip whitespace + while (i > 0 && charClass(text[i]!) === "space") i--; + if (i <= 0) return 0; + // Move back through non-space + while (i > 0 && charClass(text[i - 1]!) !== "space") i--; + return i; +} + +/** Find the next occurrence of `char` forward from pos (exclusive). Returns -1 if not found. */ +export function findCharForward(text: string, pos: number, char: string): number { + const idx = text.indexOf(char, pos + 1); + return idx; +} + +/** Find the next occurrence of `char` backward from pos (exclusive). Returns -1 if not found. */ +export function findCharBackward(text: string, pos: number, char: string): number { + for (let i = pos - 1; i >= 0; i--) { + if (text[i] === char) return i; + } + return -1; +} + +/** Find the end of the current/next word (e motion). Returns position of last char. */ +export function findWordEnd(text: string, pos: number): number { + if (pos >= text.length - 1) return Math.max(0, text.length - 1); + let i = pos + 1; + // Skip whitespace + while (i < text.length && charClass(text[i]!) === "space") i++; + if (i >= text.length) return text.length - 1; + const cls = charClass(text[i]!); + // Advance through same class + while (i + 1 < text.length && charClass(text[i + 1]!) === cls) i++; + return i; +} + +/** Find the end of the previous word (ge motion). Returns position. */ +export function findPrevWordEnd(text: string, pos: number): number { + if (pos <= 0) return 0; + let i = pos - 1; + // Skip whitespace + while (i > 0 && charClass(text[i]!) === "space") i--; + if (i === 0 && charClass(text[i]!) === "space") return 0; + // If no whitespace was crossed and we started in a non-space, skip through current word + if (i === pos - 1 && pos < text.length && charClass(text[pos]!) !== "space") { + const cls = charClass(text[i]!); + while (i > 0 && charClass(text[i - 1]!) === cls) i--; + if (i <= 0) return 0; + i--; + while (i > 0 && charClass(text[i]!) === "space") i--; + if (charClass(text[i]!) === "space") return 0; + } + return i; +} + +// ── Clamp helper ─────────────────────────────────────────────── + +function clampNormal(offset: number, len: number): number { + if (len === 0) return 0; + return Math.max(0, Math.min(offset, len - 1)); +} + +function clampInsert(offset: number, len: number): number { + return Math.max(0, Math.min(offset, len)); +} + +// ── Default state ────────────────────────────────────────────── + +export function initialState(valueLength: number): VimState { + return { mode: "insert", cursorOffset: valueLength, pending: null }; +} + +// ── Top-level dispatch ───────────────────────────────────────── + +export function processInput( + input: string, + key: Key, + state: VimState, + value: string, +): VimResult { + if (state.mode === "insert") { + return processInsert(input, key, state, value); + } + return processNormal(input, key, state, value); +} + +// ── Insert mode ──────────────────────────────────────────────── + +function processInsert( + input: string, + key: Key, + state: VimState, + value: string, +): VimResult { + const result = (): VimResult => ({ + state, + value, + escaped: false, + submitted: false, + passThrough: false, + }); + + // Pass-through keys: Tab, Up/Down arrows, Ctrl+C + if (key.tab || key.upArrow || key.downArrow || (key.ctrl && input === "c")) { + return { ...result(), passThrough: true }; + } + + // Escape → normal mode (store lastInsertOffset for gi) + if (key.escape) { + const newOffset = clampNormal(state.cursorOffset - 1, value.length); + return { + ...result(), + state: { + mode: "normal", + cursorOffset: newOffset, + pending: null, + lastFind: state.lastFind, + lastInsertOffset: state.cursorOffset, + }, + }; + } + + // Enter → submit + if (key.return) { + return { ...result(), submitted: true }; + } + + // Ctrl+U → clear entire line + if (key.ctrl && input === "u") { + return { + ...result(), + state: { ...state, cursorOffset: 0 }, + value: "", + }; + } + + // Ctrl+K → clear from cursor to end + if (key.ctrl && input === "k") { + return { + ...result(), + value: value.slice(0, state.cursorOffset), + }; + } + + // Ctrl+W → delete backward word + if (key.ctrl && input === "w") { + const wordStart = findPrevWordStart(value, state.cursorOffset); + const newValue = value.slice(0, wordStart) + value.slice(state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: wordStart }, + value: newValue, + }; + } + + // Ctrl+A → move to line start + if (key.ctrl && input === "a") { + return { + ...result(), + state: { ...state, cursorOffset: 0 }, + }; + } + + // Ctrl+E → move to line end + if (key.ctrl && input === "e") { + return { + ...result(), + state: { ...state, cursorOffset: value.length }, + }; + } + + // Ctrl+H → backspace (readline convention) + if (key.ctrl && input === "h") { + if (state.cursorOffset > 0) { + const newValue = value.slice(0, state.cursorOffset - 1) + value.slice(state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: state.cursorOffset - 1 }, + value: newValue, + }; + } + return result(); + } + + // Ctrl+D → delete char at cursor (readline convention) + if (key.ctrl && input === "d") { + if (state.cursorOffset < value.length) { + const newValue = value.slice(0, state.cursorOffset) + value.slice(state.cursorOffset + 1); + return { + ...result(), + value: newValue, + }; + } + return result(); + } + + // Ctrl+T → transpose chars before cursor + if (key.ctrl && input === "t") { + if (state.cursorOffset >= 2) { + const chars = value.split(""); + const tmp = chars[state.cursorOffset - 2]!; + chars[state.cursorOffset - 2] = chars[state.cursorOffset - 1]!; + chars[state.cursorOffset - 1] = tmp; + return { + ...result(), + value: chars.join(""), + }; + } + return result(); + } + + // Ctrl+L → pass through to parent (clear/refresh) + if (key.ctrl && input === "l") { + return { ...result(), passThrough: true }; + } + + // Alt+F → forward word (meta + not escape + input === "f") + if (key.meta && !key.escape && input === "f") { + const newOffset = clampInsert(findNextWordStart(value, state.cursorOffset), value.length); + return { + ...result(), + state: { ...state, cursorOffset: newOffset }, + }; + } + + // Alt+B → backward word + if (key.meta && !key.escape && input === "b") { + const newOffset = findPrevWordStart(value, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: newOffset }, + }; + } + + // Left arrow + if (key.leftArrow) { + const newOffset = clampInsert(state.cursorOffset - 1, value.length); + return { + ...result(), + state: { ...state, cursorOffset: newOffset }, + }; + } + + // Right arrow + if (key.rightArrow) { + const newOffset = clampInsert(state.cursorOffset + 1, value.length); + return { + ...result(), + state: { ...state, cursorOffset: newOffset }, + }; + } + + // Backspace + if (key.backspace || key.delete) { + if (state.cursorOffset > 0) { + const newValue = value.slice(0, state.cursorOffset - 1) + value.slice(state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: state.cursorOffset - 1 }, + value: newValue, + }; + } + return result(); + } + + // Printable character insertion + if (input.length > 0 && !key.ctrl && !key.meta) { + const newValue = + value.slice(0, state.cursorOffset) + input + value.slice(state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: state.cursorOffset + input.length }, + value: newValue, + }; + } + + return result(); +} + +// ── Repeat last f/t/F/T helper ───────────────────────────────── + +function repeatLastFind( + state: VimState, + value: string, + result: () => VimResult, + reverse: boolean, +): VimResult { + const lf = state.lastFind; + if (!lf) return result(); + + const effectiveDir = reverse + ? (lf.dir === "forward" ? "backward" : "forward") + : lf.dir; + + // For non-inclusive finds (t/T), bump search position to skip adjacent char + let searchPos = state.cursorOffset; + if (!lf.inclusive) { + if (effectiveDir === "forward") searchPos++; + else searchPos--; + } + + const idx = effectiveDir === "forward" + ? findCharForward(value, searchPos, lf.char) + : findCharBackward(value, searchPos, lf.char); + + if (idx < 0) return result(); + + let target: number; + if (effectiveDir === "forward") { + target = lf.inclusive ? idx : idx - 1; + } else { + target = lf.inclusive ? idx : idx + 1; + } + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(target, value.length) }, + }; +} + +// ── Normal mode ──────────────────────────────────────────────── + +function processNormal( + input: string, + key: Key, + state: VimState, + value: string, +): VimResult { + const result = (): VimResult => ({ + state, + value, + escaped: false, + submitted: false, + passThrough: false, + }); + + // Pass-through keys + if (key.tab || key.upArrow || key.downArrow || (key.ctrl && input === "c")) { + return { ...result(), passThrough: true }; + } + + // Enter → submit from normal mode too + if (key.return) { + return { ...result(), submitted: true }; + } + + // ── Pending operator state machine ────────────────────────── + + if (state.pending !== null) { + return processPending(input, key, state, value); + } + + // ── Escape → propagate to parent ──────────────────────────── + + if (key.escape) { + return { ...result(), escaped: true }; + } + + // ── Motions ───────────────────────────────────────────────── + + // h / left arrow + if (input === "h" || key.leftArrow) { + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(state.cursorOffset - 1, value.length) }, + }; + } + + // l / right arrow + if (input === "l" || key.rightArrow) { + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(state.cursorOffset + 1, value.length) }, + }; + } + + // 0 → line start + if (input === "0") { + return { + ...result(), + state: { ...state, cursorOffset: 0 }, + }; + } + + // $ → line end + if (input === "$") { + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(value.length - 1, value.length) }, + }; + } + + // w → next word start + if (input === "w") { + const next = findNextWordStart(value, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(next, value.length) }, + }; + } + + // b → prev word start + if (input === "b") { + const prev = findPrevWordStart(value, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(prev, value.length) }, + }; + } + + // W → next WORD start + if (input === "W") { + const next = findNextWORDStart(value, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(next, value.length) }, + }; + } + + // B → previous WORD start + if (input === "B") { + const prev = findPrevWORDStart(value, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(prev, value.length) }, + }; + } + + // e → end of word + if (input === "e") { + const end = findWordEnd(value, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(end, value.length) }, + }; + } + + // ; → repeat last f/t/F/T search + if (input === ";") { + return repeatLastFind(state, value, result, false); + } + + // , → repeat last f/t/F/T search in opposite direction + if (input === ",") { + return repeatLastFind(state, value, result, true); + } + + // ~ → toggle case of char at cursor, move right + if (input === "~") { + if (value.length === 0) return result(); + const ch = value[state.cursorOffset]!; + const toggled = ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase(); + const newValue = value.slice(0, state.cursorOffset) + toggled + value.slice(state.cursorOffset + 1); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(state.cursorOffset + 1, newValue.length) }, + value: newValue, + }; + } + + // ── Mode switches ────────────────────────────────────────── + + // i → insert at cursor + if (input === "i") { + return { + ...result(), + state: { mode: "insert", cursorOffset: state.cursorOffset, pending: null }, + }; + } + + // I → insert at line start + if (input === "I") { + return { + ...result(), + state: { mode: "insert", cursorOffset: 0, pending: null }, + }; + } + + // a → insert after cursor + if (input === "a") { + return { + ...result(), + state: { + mode: "insert", + cursorOffset: clampInsert(state.cursorOffset + 1, value.length), + pending: null, + }, + }; + } + + // A → insert at end of line + if (input === "A") { + return { + ...result(), + state: { mode: "insert", cursorOffset: value.length, pending: null }, + }; + } + + // s → substitute (delete char + insert) + if (input === "s") { + if (value.length === 0) { + return { + ...result(), + state: { mode: "insert", cursorOffset: 0, pending: null }, + }; + } + const newValue = value.slice(0, state.cursorOffset) + value.slice(state.cursorOffset + 1); + const newOffset = clampInsert(state.cursorOffset, newValue.length); + return { + ...result(), + state: { mode: "insert", cursorOffset: newOffset, pending: null }, + value: newValue, + }; + } + + // S → substitute entire line (like cc) + if (input === "S") { + return { + ...result(), + state: { mode: "insert", cursorOffset: 0, pending: null }, + value: "", + }; + } + + // x → delete char at cursor + if (input === "x") { + if (value.length === 0) return result(); + const newValue = value.slice(0, state.cursorOffset) + value.slice(state.cursorOffset + 1); + const newOffset = clampNormal(state.cursorOffset, newValue.length); + return { + ...result(), + state: { ...state, cursorOffset: newOffset }, + value: newValue, + }; + } + + // D → delete to end of line (like d$) + if (input === "D") { + const newValue = value.slice(0, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(state.cursorOffset, newValue.length) }, + value: newValue, + }; + } + + // C → change to end of line (like c$) + if (input === "C") { + const newValue = value.slice(0, state.cursorOffset); + return { + ...result(), + state: { mode: "insert", cursorOffset: clampInsert(state.cursorOffset, newValue.length), pending: null }, + value: newValue, + }; + } + + // ── Operators (set pending) ───────────────────────────────── + + // d → start delete operator + if (input === "d") { + return { + ...result(), + state: { ...state, pending: { kind: "d" } }, + }; + } + + // c → start change operator + if (input === "c") { + return { + ...result(), + state: { ...state, pending: { kind: "c" } }, + }; + } + + // r → replace single char (pending) + if (input === "r") { + return { + ...result(), + state: { ...state, pending: { kind: "r" } }, + }; + } + + // g → prefix for ge, gi, etc. (pending) + if (input === "g") { + return { + ...result(), + state: { ...state, pending: { kind: "g" } }, + }; + } + + // f → find char forward (standalone motion) + if (input === "f") { + return { + ...result(), + state: { ...state, pending: { kind: "f" } }, + }; + } + + // t → till char forward (standalone motion) + if (input === "t") { + return { + ...result(), + state: { ...state, pending: { kind: "t" } }, + }; + } + + // F → find char backward (standalone motion) + if (input === "F") { + return { + ...result(), + state: { ...state, pending: { kind: "F" } }, + }; + } + + // T → till char backward (standalone motion) + if (input === "T") { + return { + ...result(), + state: { ...state, pending: { kind: "T" } }, + }; + } + + // Unrecognized key in normal mode — ignore + return result(); +} + +// ── Operator-pending processing ──────────────────────────────── + +function processPending( + input: string, + key: Key, + state: VimState, + value: string, +): VimResult { + const result = (): VimResult => ({ + state, + value, + escaped: false, + submitted: false, + passThrough: false, + }); + const pending = state.pending!; + + // Escape → cancel pending + if (key.escape) { + return { + ...result(), + state: { ...state, pending: null }, + }; + } + + // ── Standalone f/t (just motions, not operators) ──────────── + + // ── d{motion} ────────────────────────────────────────────── + + if (pending.kind === "d") { + // dd → clear entire line + if (input === "d") { + return { + ...result(), + state: { ...state, cursorOffset: 0, pending: null }, + value: "", + }; + } + + // d + f → wait for char + if (input === "f") { + return { + ...result(), + state: { ...state, pending: { kind: "df" } }, + }; + } + + // d + t → wait for char + if (input === "t") { + return { + ...result(), + state: { ...state, pending: { kind: "dt" } }, + }; + } + + // d + F → wait for char (backward) + if (input === "F") { + return { + ...result(), + state: { ...state, pending: { kind: "dF" } }, + }; + } + + // d + T → wait for char (backward) + if (input === "T") { + return { + ...result(), + state: { ...state, pending: { kind: "dT" } }, + }; + } + + // d + motion → compute range, delete + const range = motionRange(input, key, state.cursorOffset, value); + if (range) { + const [from, to] = range; + const newValue = value.slice(0, from) + value.slice(to); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(from, newValue.length), pending: null }, + value: newValue, + }; + } + + // Unknown key → cancel pending + return { + ...result(), + state: { ...state, pending: null }, + }; + } + + // ── c{motion} ────────────────────────────────────────────── + + if (pending.kind === "c") { + // cc → clear entire line, enter insert + if (input === "c") { + return { + ...result(), + state: { mode: "insert", cursorOffset: 0, pending: null }, + value: "", + }; + } + + // c + f → wait for char + if (input === "f") { + return { + ...result(), + state: { ...state, pending: { kind: "cf" } }, + }; + } + + // c + t → wait for char + if (input === "t") { + return { + ...result(), + state: { ...state, pending: { kind: "ct" } }, + }; + } + + // c + F → wait for char (backward) + if (input === "F") { + return { + ...result(), + state: { ...state, pending: { kind: "cF" } }, + }; + } + + // c + T → wait for char (backward) + if (input === "T") { + return { + ...result(), + state: { ...state, pending: { kind: "cT" } }, + }; + } + + // c + motion → compute range, delete, insert mode + const range = motionRange(input, key, state.cursorOffset, value); + if (range) { + const [from, to] = range; + const newValue = value.slice(0, from) + value.slice(to); + return { + ...result(), + state: { mode: "insert", cursorOffset: clampInsert(from, newValue.length), pending: null }, + value: newValue, + }; + } + + // Unknown key → cancel pending + return { + ...result(), + state: { ...state, pending: null }, + }; + } + + // ── r{char} — replace single character ──────────────────── + + if (pending.kind === "r") { + if (value.length === 0 || input.length === 0) { + return { ...result(), state: { ...state, pending: null } }; + } + const newValue = value.slice(0, state.cursorOffset) + input + value.slice(state.cursorOffset + 1); + return { + ...result(), + state: { ...state, pending: null }, + value: newValue, + }; + } + + // ── g{key} — g-prefix commands ────────────────────────── + + if (pending.kind === "g") { + // ge → end of previous word + if (input === "e") { + const end = findPrevWordEnd(value, state.cursorOffset); + return { + ...result(), + state: { ...state, cursorOffset: clampNormal(end, value.length), pending: null }, + }; + } + + // gi → resume insert at last position + if (input === "i") { + const offset = state.lastInsertOffset ?? value.length; + return { + ...result(), + state: { + mode: "insert", + cursorOffset: clampInsert(offset, value.length), + pending: null, + lastFind: state.lastFind, + lastInsertOffset: state.lastInsertOffset, + }, + }; + } + + // Unknown g-command → cancel + return { ...result(), state: { ...state, pending: null } }; + } + + // ── Standalone f/t/F/T (motions, not operators) ──────────── + + if (pending.kind === "f") { + const idx = findCharForward(value, state.cursorOffset, input); + if (idx >= 0) { + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(idx, value.length), + pending: null, + lastFind: { dir: "forward", inclusive: true, char: input }, + }, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + if (pending.kind === "t") { + const idx = findCharForward(value, state.cursorOffset, input); + if (idx >= 0) { + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(idx - 1, value.length), + pending: null, + lastFind: { dir: "forward", inclusive: false, char: input }, + }, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + if (pending.kind === "F") { + const idx = findCharBackward(value, state.cursorOffset, input); + if (idx >= 0) { + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(idx, value.length), + pending: null, + lastFind: { dir: "backward", inclusive: true, char: input }, + }, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + if (pending.kind === "T") { + const idx = findCharBackward(value, state.cursorOffset, input); + if (idx >= 0) { + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(idx + 1, value.length), + pending: null, + lastFind: { dir: "backward", inclusive: false, char: input }, + }, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + // ── df{char} / dt{char} — delete + find/till forward ────── + + if (pending.kind === "df") { + const idx = findCharForward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, state.cursorOffset) + value.slice(idx + 1); + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(state.cursorOffset, newValue.length), + pending: null, + lastFind: { dir: "forward", inclusive: true, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + if (pending.kind === "dt") { + const idx = findCharForward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, state.cursorOffset) + value.slice(idx); + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(state.cursorOffset, newValue.length), + pending: null, + lastFind: { dir: "forward", inclusive: false, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + // ── dF{char} / dT{char} — delete + find/till backward ───── + + if (pending.kind === "dF") { + const idx = findCharBackward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, idx) + value.slice(state.cursorOffset); + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(idx, newValue.length), + pending: null, + lastFind: { dir: "backward", inclusive: true, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + if (pending.kind === "dT") { + const idx = findCharBackward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, idx + 1) + value.slice(state.cursorOffset); + return { + ...result(), + state: { + ...state, + cursorOffset: clampNormal(idx + 1, newValue.length), + pending: null, + lastFind: { dir: "backward", inclusive: false, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + // ── cf{char} / ct{char} — change + find/till forward ────── + + if (pending.kind === "cf") { + const idx = findCharForward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, state.cursorOffset) + value.slice(idx + 1); + return { + ...result(), + state: { + mode: "insert", + cursorOffset: clampInsert(state.cursorOffset, newValue.length), + pending: null, + lastFind: { dir: "forward", inclusive: true, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + if (pending.kind === "ct") { + const idx = findCharForward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, state.cursorOffset) + value.slice(idx); + return { + ...result(), + state: { + mode: "insert", + cursorOffset: clampInsert(state.cursorOffset, newValue.length), + pending: null, + lastFind: { dir: "forward", inclusive: false, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + // ── cF{char} / cT{char} — change + find/till backward ───── + + if (pending.kind === "cF") { + const idx = findCharBackward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, idx) + value.slice(state.cursorOffset); + return { + ...result(), + state: { + mode: "insert", + cursorOffset: clampInsert(idx, newValue.length), + pending: null, + lastFind: { dir: "backward", inclusive: true, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + if (pending.kind === "cT") { + const idx = findCharBackward(value, state.cursorOffset, input); + if (idx >= 0) { + const newValue = value.slice(0, idx + 1) + value.slice(state.cursorOffset); + return { + ...result(), + state: { + mode: "insert", + cursorOffset: clampInsert(idx + 1, newValue.length), + pending: null, + lastFind: { dir: "backward", inclusive: false, char: input }, + }, + value: newValue, + }; + } + return { ...result(), state: { ...state, pending: null } }; + } + + // Fallback: cancel pending + return { + ...result(), + state: { ...state, pending: null }, + }; +} + +/** Compute the [from, to) range for a motion key from the given cursor position. */ +function motionRange( + input: string, + key: Key, + cursor: number, + value: string, +): [number, number] | null { + // h / left + if (input === "h" || key.leftArrow) { + if (cursor > 0) return [cursor - 1, cursor]; + return null; + } + + // l / right + if (input === "l" || key.rightArrow) { + if (cursor < value.length) return [cursor, cursor + 1]; + return null; + } + + // w → delete to next word start + if (input === "w") { + const next = findNextWordStart(value, cursor); + if (next > cursor) return [cursor, next]; + return null; + } + + // b → delete backward to prev word start + if (input === "b") { + const prev = findPrevWordStart(value, cursor); + if (prev < cursor) return [prev, cursor]; + return null; + } + + // W → delete to next WORD start + if (input === "W") { + const next = findNextWORDStart(value, cursor); + if (next > cursor) return [cursor, next]; + return null; + } + + // B → delete to previous WORD start + if (input === "B") { + const prev = findPrevWORDStart(value, cursor); + if (prev < cursor) return [prev, cursor]; + return null; + } + + // 0 → delete to line start + if (input === "0") { + if (cursor > 0) return [0, cursor]; + return null; + } + + // e → delete to end of word (inclusive) + if (input === "e") { + const end = findWordEnd(value, cursor); + if (end >= cursor && cursor < value.length) return [cursor, end + 1]; + return null; + } + + // $ → delete to line end (inclusive) + if (input === "$") { + if (cursor < value.length) return [cursor, value.length]; + return null; + } + + return null; +} + diff --git a/src/operations/transform/chain.ts b/src/operations/transform/chain.ts index 8374001..404c61f 100644 --- a/src/operations/transform/chain.ts +++ b/src/operations/transform/chain.ts @@ -38,6 +38,21 @@ export function createOperation( return op; } +/** + * Create a recs operation if the name is registered, otherwise fall back + * to a ShellOperation that pipes JSONL through the external command. + */ +export function createOperationOrShell( + name: string, + args: string[], + next: RecordReceiver, +): Operation { + if (operationFactories.has(name)) { + return createOperation(name, args, next); + } + return new ShellOperation(next, name, args); +} + /** * A "shell operation" that pipes JSONL through an external command. * Records are serialized to JSON, piped through the command's stdin/stdout, diff --git a/src/tui/components/App.tsx b/src/tui/components/App.tsx deleted file mode 100644 index d7cb040..0000000 --- a/src/tui/components/App.tsx +++ /dev/null @@ -1,329 +0,0 @@ -/** - * 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/InspectorPanel.tsx b/src/tui/components/InspectorPanel.tsx deleted file mode 100644 index df080df..0000000 --- a/src/tui/components/InspectorPanel.tsx +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 2fadcd1..0000000 --- a/src/tui/components/RecordTable.tsx +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 223f752..0000000 --- a/src/tui/components/StageList.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 51b5f1e..0000000 --- a/src/tui/components/StageRow.tsx +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 8f55682..0000000 --- a/src/tui/components/StatusBar.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index d6c75f2..0000000 --- a/src/tui/components/TitleBar.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index d71b502..0000000 --- a/src/tui/components/modals/AddStageModal.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * 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/EditStageModal.tsx b/src/tui/components/modals/EditStageModal.tsx deleted file mode 100644 index 723ffd3..0000000 --- a/src/tui/components/modals/EditStageModal.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 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/HelpPanel.tsx b/src/tui/components/modals/HelpPanel.tsx deleted file mode 100644 index a820172..0000000 --- a/src/tui/components/modals/HelpPanel.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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/hooks/useAutoSave.ts b/src/tui/hooks/useAutoSave.ts deleted file mode 100644 index 1f98867..0000000 --- a/src/tui/hooks/useAutoSave.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 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 deleted file mode 100644 index ec395df..0000000 --- a/src/tui/hooks/useExecution.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 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/index.tsx b/src/tui/index.tsx deleted file mode 100644 index 2b50eb7..0000000 --- a/src/tui/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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/tests/explorer/components/add-stage-modal-preview.test.ts b/tests/explorer/components/add-stage-modal-preview.test.ts new file mode 100644 index 0000000..2961b1b --- /dev/null +++ b/tests/explorer/components/add-stage-modal-preview.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for AddStageModal stream preview + record zoom feature. + * + * Tests cover: + * - Props interface accepts records and fieldNames + * - Module imports correctly (no crashes) + * - Internal logic: column width computation, tree flattening + */ + +import { describe, test, expect } from "bun:test"; +import { Record } from "../../../src/Record.ts"; + +// ── Column width computation (mirrors AddStageModal internal logic) ── + +const COL_MIN = 4; +const COL_MAX = 20; + +function computeColumnWidths(fields: string[], records: Record[]): number[] { + return fields.map((field) => { + let maxWidth = field.length; + for (const record of records) { + 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); + }); +} + +// ── Tree flattening (mirrors zoom view logic) ──────────────────── + +interface TreeRow { + depth: number; + label: string; + value: unknown; + isContainer: boolean; + path: string; + childCount: number; +} + +function flattenValue( + value: unknown, + collapsed: Set, + parentPath: string, + depth: number, + label: string, +): TreeRow[] { + const path = parentPath ? `${parentPath}.${label}` : label; + + if (value === null || value === undefined) { + return [{ depth, label, value: null, isContainer: false, path, childCount: 0 }]; + } + + if (typeof value === "object" && !Array.isArray(value)) { + const keys = Object.keys(value as object); + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: keys.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (const key of keys) { + rows.push(...flattenValue((value as { [k: string]: unknown })[key], collapsed, path, depth + 1, key)); + } + } + return rows; + } + + if (Array.isArray(value)) { + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: value.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (let i = 0; i < value.length; i++) { + rows.push(...flattenValue(value[i], collapsed, path, depth + 1, `[${i}]`)); + } + } + return rows; + } + + return [{ depth, label, value, isContainer: false, path, childCount: 0 }]; +} + +function flattenRecord(record: Record, collapsed: Set): TreeRow[] { + const data = record.toJSON(); + const rows: TreeRow[] = []; + for (const key of Object.keys(data)) { + rows.push(...flattenValue(data[key]!, collapsed, "", 0, key)); + } + return rows; +} + +// ── Tests ──────────────────────────────────────────────────────── + +describe("AddStageModal stream preview", () => { + describe("column width computation", () => { + test("uses field name length as minimum", () => { + const fields = ["longfieldname"]; + const records = [new Record({ longfieldname: "x" })]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe("longfieldname".length); + }); + + test("uses value length when longer than field name", () => { + const fields = ["x"]; + const records = [new Record({ x: "a-long-value-here" })]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe("a-long-value-here".length); + }); + + test("enforces minimum column width", () => { + const fields = ["x"]; + const records = [new Record({ x: "a" })]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe(COL_MIN); + }); + + test("enforces maximum column width", () => { + const fields = ["x"]; + const records = [new Record({ x: "a".repeat(50) })]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe(COL_MAX); + }); + + test("handles empty records", () => { + const fields = ["name", "age"]; + const widths = computeColumnWidths(fields, []); + expect(widths).toEqual([COL_MIN, COL_MIN]); + }); + + test("considers all records for width", () => { + const fields = ["x"]; + const records = [ + new Record({ x: "short" }), + new Record({ x: "a-much-longer-val" }), + new Record({ x: "med" }), + ]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe("a-much-longer-val".length); + }); + + test("handles null/undefined values", () => { + const fields = ["x"]; + const records = [new Record({})]; // x is undefined + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe(COL_MIN); + }); + + test("computes independent widths per field", () => { + const fields = ["short", "a-longer-field"]; + const records = [ + new Record({ short: "val", "a-longer-field": "v" }), + ]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe("short".length); + expect(widths[1]).toBe("a-longer-field".length); + }); + }); + + describe("record zoom tree flattening", () => { + test("flattens simple flat record", () => { + const record = new Record({ name: "Alice", age: 30 }); + const rows = flattenRecord(record, new Set()); + expect(rows).toHaveLength(2); + expect(rows[0]!.label).toBe("name"); + expect(rows[0]!.value).toBe("Alice"); + expect(rows[0]!.depth).toBe(0); + expect(rows[0]!.isContainer).toBe(false); + expect(rows[1]!.label).toBe("age"); + expect(rows[1]!.value).toBe(30); + }); + + test("flattens nested object", () => { + const record = new Record({ meta: { key: "val", num: 1 } }); + const rows = flattenRecord(record, new Set()); + // meta (container) + key + num = 3 rows + expect(rows).toHaveLength(3); + expect(rows[0]!.label).toBe("meta"); + expect(rows[0]!.isContainer).toBe(true); + expect(rows[0]!.childCount).toBe(2); + expect(rows[1]!.label).toBe("key"); + expect(rows[1]!.depth).toBe(1); + expect(rows[2]!.label).toBe("num"); + }); + + test("flattens array field", () => { + const record = new Record({ tags: ["a", "b", "c"] }); + const rows = flattenRecord(record, new Set()); + // tags (container) + [0] + [1] + [2] = 4 rows + expect(rows).toHaveLength(4); + expect(rows[0]!.label).toBe("tags"); + expect(rows[0]!.isContainer).toBe(true); + expect(rows[0]!.childCount).toBe(3); + expect(rows[1]!.label).toBe("[0]"); + expect(rows[1]!.value).toBe("a"); + }); + + test("collapses containers when path is in collapsed set", () => { + const record = new Record({ meta: { key: "val", num: 1 } }); + const collapsed = new Set(["meta"]); + const rows = flattenRecord(record, collapsed); + // Only the meta container row, children hidden + expect(rows).toHaveLength(1); + expect(rows[0]!.label).toBe("meta"); + expect(rows[0]!.isContainer).toBe(true); + }); + + test("handles null values", () => { + const record = new Record({ x: null }); + const rows = flattenRecord(record, new Set()); + expect(rows).toHaveLength(1); + expect(rows[0]!.value).toBe(null); + expect(rows[0]!.isContainer).toBe(false); + }); + + test("handles empty record", () => { + const record = new Record({}); + const rows = flattenRecord(record, new Set()); + expect(rows).toHaveLength(0); + }); + + test("handles deeply nested structures", () => { + const record = new Record({ a: { b: { c: "deep" } } }); + const rows = flattenRecord(record, new Set()); + // a (container) -> b (container) -> c (leaf) = 3 rows + expect(rows).toHaveLength(3); + expect(rows[2]!.label).toBe("c"); + expect(rows[2]!.depth).toBe(2); + expect(rows[2]!.value).toBe("deep"); + }); + + test("partial collapse only hides targeted subtree", () => { + const record = new Record({ + x: { a: 1, b: 2 }, + y: { c: 3, d: 4 }, + }); + const collapsed = new Set(["x"]); // collapse x but not y + const rows = flattenRecord(record, collapsed); + // x (collapsed) + y (expanded with c, d) = 1 + 3 = 4 rows + expect(rows).toHaveLength(4); + expect(rows[0]!.label).toBe("x"); + expect(rows[0]!.isContainer).toBe(true); + expect(rows[1]!.label).toBe("y"); + expect(rows[1]!.isContainer).toBe(true); + expect(rows[2]!.label).toBe("c"); + expect(rows[3]!.label).toBe("d"); + }); + }); + + describe("module import", () => { + test("AddStageModal can be imported without error", async () => { + const mod = await import("../../../src/explorer/components/modals/AddStageModal.tsx"); + expect(mod.AddStageModal).toBeDefined(); + expect(typeof mod.AddStageModal).toBe("function"); + }); + + test("AddStageModalProps type accepts records and fieldNames", async () => { + // Verify the module exports what we expect — the function signature + // accepts records/fieldNames as optional props (compile-time check). + const mod = await import("../../../src/explorer/components/modals/AddStageModal.tsx"); + // Function exists and has correct arity (at least 1 for props) + expect(mod.AddStageModal.length).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/tests/explorer/components/schema-view-logic.test.ts b/tests/explorer/components/schema-view-logic.test.ts new file mode 100644 index 0000000..1625f22 --- /dev/null +++ b/tests/explorer/components/schema-view-logic.test.ts @@ -0,0 +1,268 @@ +import { describe, test, expect } from "bun:test"; +import { + inferType, + analyzeFields, +} from "../../../src/explorer/components/SchemaView.tsx"; +import { Record } from "../../../src/Record.ts"; +import type { CachedResult } from "../../../src/explorer/model/types.ts"; + +function makeCachedResult( + records: Record[], + fieldNames?: string[], +): CachedResult { + const names = + fieldNames ?? + Array.from( + new Set(records.flatMap((r) => r.keys())), + ); + return { + key: "test", + stageId: "s1", + inputId: "in1", + records, + lines: [], + spillFile: null, + recordCount: records.length, + fieldNames: names, + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 5, + }; +} + +// ── inferType ──────────────────────────────────────────────────── + +describe("inferType", () => { + test("null returns 'null'", () => { + expect(inferType(null)).toBe("null"); + }); + + test("undefined returns 'null'", () => { + expect(inferType(undefined)).toBe("null"); + }); + + test("string returns 'string'", () => { + expect(inferType("hello")).toBe("string"); + expect(inferType("")).toBe("string"); + }); + + test("number returns 'number'", () => { + expect(inferType(42)).toBe("number"); + expect(inferType(0)).toBe("number"); + expect(inferType(3.14)).toBe("number"); + expect(inferType(-1)).toBe("number"); + }); + + test("boolean returns 'boolean'", () => { + expect(inferType(true)).toBe("boolean"); + expect(inferType(false)).toBe("boolean"); + }); + + test("array returns 'array'", () => { + expect(inferType([])).toBe("array"); + expect(inferType([1, 2, 3])).toBe("array"); + }); + + test("object returns 'object'", () => { + expect(inferType({})).toBe("object"); + expect(inferType({ a: 1 })).toBe("object"); + }); + + test("nested array returns 'array'", () => { + expect(inferType([[1], [2]])).toBe("array"); + }); +}); + +// ── analyzeFields ──────────────────────────────────────────────── + +describe("analyzeFields", () => { + test("returns empty array for empty records", () => { + const result = makeCachedResult([], []); + expect(analyzeFields(result)).toEqual([]); + }); + + test("returns empty array when fieldNames is empty", () => { + const records = [new Record({ x: 1 })]; + const result = makeCachedResult(records, []); + expect(analyzeFields(result)).toEqual([]); + }); + + test("detects string type", () => { + const records = [ + new Record({ name: "Alice" }), + new Record({ name: "Bob" }), + ]; + const result = makeCachedResult(records, ["name"]); + const fields = analyzeFields(result); + + expect(fields).toHaveLength(1); + expect(fields[0]!.name).toBe("name"); + expect(fields[0]!.types).toEqual(["string"]); + }); + + test("detects number type", () => { + const records = [ + new Record({ age: 30 }), + new Record({ age: 25 }), + ]; + const result = makeCachedResult(records, ["age"]); + const fields = analyzeFields(result); + + expect(fields[0]!.types).toEqual(["number"]); + }); + + test("detects boolean type", () => { + const records = [ + new Record({ active: true }), + new Record({ active: false }), + ]; + const result = makeCachedResult(records, ["active"]); + const fields = analyzeFields(result); + + expect(fields[0]!.types).toEqual(["boolean"]); + }); + + test("detects mixed types", () => { + const records = [ + new Record({ val: "hello" }), + new Record({ val: 42 }), + new Record({ val: true }), + ]; + const result = makeCachedResult(records, ["val"]); + const fields = analyzeFields(result); + + // Types should be sorted + expect(fields[0]!.types).toEqual(["boolean", "number", "string"]); + }); + + test("detects null type when field is missing from some records", () => { + const records = [ + new Record({ x: 1, y: "a" }), + new Record({ x: 2 }), + ]; + const result = makeCachedResult(records, ["x", "y"]); + const fields = analyzeFields(result); + + const yField = fields.find((f) => f.name === "y")!; + expect(yField.types).toContain("null"); + }); + + test("calculates % populated correctly — 100%", () => { + const records = [ + new Record({ x: 1 }), + new Record({ x: 2 }), + new Record({ x: 3 }), + ]; + const result = makeCachedResult(records, ["x"]); + const fields = analyzeFields(result); + + expect(fields[0]!.populatedPct).toBe(100); + }); + + test("calculates % populated correctly — partial", () => { + const records = [ + new Record({ x: 1 }), + new Record({}), + new Record({ x: 3 }), + new Record({}), + ]; + const result = makeCachedResult(records, ["x"]); + const fields = analyzeFields(result); + + expect(fields[0]!.populatedPct).toBe(50); + }); + + test("calculates % populated correctly — 0%", () => { + const records = [ + new Record({}), + new Record({}), + ]; + const result = makeCachedResult(records, ["x"]); + const fields = analyzeFields(result); + + expect(fields[0]!.populatedPct).toBe(0); + }); + + test("collects up to 3 sample values", () => { + const records = [ + new Record({ x: "a" }), + new Record({ x: "b" }), + new Record({ x: "c" }), + new Record({ x: "d" }), + new Record({ x: "e" }), + ]; + const result = makeCachedResult(records, ["x"]); + const fields = analyzeFields(result); + + expect(fields[0]!.sampleValues.length).toBeLessThanOrEqual(3); + }); + + test("sample values are distinct", () => { + const records = [ + new Record({ x: "same" }), + new Record({ x: "same" }), + new Record({ x: "different" }), + ]; + const result = makeCachedResult(records, ["x"]); + const fields = analyzeFields(result); + + const samples = fields[0]!.sampleValues; + const unique = new Set(samples); + expect(unique.size).toBe(samples.length); + }); + + test("truncates long sample values to 25 chars", () => { + const longValue = "a".repeat(50); + const records = [new Record({ x: longValue })]; + const result = makeCachedResult(records, ["x"]); + const fields = analyzeFields(result); + + const sample = fields[0]!.sampleValues[0]!; + expect(sample.length).toBeLessThanOrEqual(25); + expect(sample).toContain("..."); + }); + + test("handles array field values", () => { + const records = [ + new Record({ tags: ["a", "b"] }), + new Record({ tags: ["c"] }), + ]; + const result = makeCachedResult(records, ["tags"]); + const fields = analyzeFields(result); + + expect(fields[0]!.types).toEqual(["array"]); + expect(fields[0]!.populatedPct).toBe(100); + }); + + test("handles object field values", () => { + const records = [ + new Record({ meta: { key: "val" } }), + ]; + const result = makeCachedResult(records, ["meta"]); + const fields = analyzeFields(result); + + expect(fields[0]!.types).toEqual(["object"]); + // Sample should be JSON stringified + expect(fields[0]!.sampleValues[0]).toContain("key"); + }); + + test("handles multiple fields", () => { + const records = [ + new Record({ name: "Alice", age: 30, active: true }), + new Record({ name: "Bob", age: 25, active: false }), + ]; + const result = makeCachedResult(records, ["name", "age", "active"]); + const fields = analyzeFields(result); + + expect(fields).toHaveLength(3); + expect(fields.map((f) => f.name)).toEqual(["name", "age", "active"]); + }); + + test("analyzes fields in the order provided by fieldNames", () => { + const records = [new Record({ z: 1, a: 2, m: 3 })]; + const result = makeCachedResult(records, ["z", "a", "m"]); + const fields = analyzeFields(result); + + expect(fields.map((f) => f.name)).toEqual(["z", "a", "m"]); + }); +}); diff --git a/tests/explorer/e2e/tmux-harness.ts b/tests/explorer/e2e/tmux-harness.ts new file mode 100644 index 0000000..2d04c1e --- /dev/null +++ b/tests/explorer/e2e/tmux-harness.ts @@ -0,0 +1,217 @@ +/** + * TmuxTestHarness — drives the real recs-explorer TUI in a tmux session. + * + * Spawns a tmux session with a controlled terminal size, sends keystrokes + * via `tmux send-keys`, and reads the screen via `tmux capture-pane -p`. + */ + +import { nanoid } from "nanoid"; + +export interface TmuxHarnessOptions { + /** Extra CLI args passed to `bun run bin/recs.ts explorer ...` */ + args?: string[]; + /** Terminal width (default 120) */ + width?: number; + /** Terminal height (default 40) */ + height?: number; + /** Working directory for the explorer process */ + cwd?: string; +} + +export class TmuxTestHarness { + readonly sessionName: string; + started = false; + destroyed = false; + readonly width: number; + readonly height: number; + readonly cwd: string; + readonly args: string[]; + + constructor(options: TmuxHarnessOptions = {}) { + this.sessionName = `recs-e2e-${nanoid(8)}`; + this.width = options.width ?? 120; + this.height = options.height ?? 40; + this.cwd = options.cwd ?? process.cwd(); + this.args = options.args ?? []; + } + + /** Launch recs-explorer in a tmux session. */ + async start(): Promise { + if (this.started) throw new Error("Already started"); + this.started = true; + + const cmd = ["bun", "run", "bin/recs.ts", "explorer", ...this.args].join(" "); + + // Create a detached tmux session with controlled size + await this.exec([ + "tmux", "new-session", + "-d", + "-s", this.sessionName, + "-x", String(this.width), + "-y", String(this.height), + cmd, + ]); + + // Allow time for Bun to compile TypeScript on first run. + // Subsequent runs benefit from module cache and start much faster. + await sleep(2500); + } + + /** Send keystrokes to the tmux pane. */ + async sendKeys(keys: string): Promise { + this.ensureStarted(); + await this.exec([ + "tmux", "send-keys", + "-t", this.sessionName, + keys, + ]); + // Brief pause to let the TUI process the input + await sleep(300); + } + + /** + * Send literal text to the tmux pane as a single batch. + * + * We send the entire string in one `tmux send-keys -l` command so + * it arrives in a single DATA chunk. Ink's input parser groups + * consecutive printable characters into one event, and TextInput's + * onChange receives the full string at once: `onChange(value + text)`. + * This avoids the React stale-closure issue where per-character events + * in the same batch all read the same stale `value`. + */ + async sendText(text: string): Promise { + this.ensureStarted(); + await this.exec([ + "tmux", "send-keys", + "-t", this.sessionName, + "-l", + text, + ]); + await sleep(500); + } + + /** Capture the current screen content from the tmux pane. */ + async capturePane(): Promise { + this.ensureStarted(); + const result = await this.exec([ + "tmux", "capture-pane", + "-t", this.sessionName, + "-p", + ]); + return result; + } + + /** + * Wait until `text` appears on screen. + * Returns true if found within timeout, false otherwise. + */ + async waitForText(text: string, timeoutMs = 10000): Promise { + this.ensureStarted(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const screen = await this.capturePane(); + if (screen.includes(text)) return true; + await sleep(250); + } + return false; + } + + /** + * Wait until `text` disappears from screen. + * Returns true if gone within timeout, false otherwise. + */ + async waitForTextGone(text: string, timeoutMs = 10000): Promise { + this.ensureStarted(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const screen = await this.capturePane(); + if (!screen.includes(text)) return true; + await sleep(250); + } + return false; + } + + /** + * Wait until any of the given texts appear on screen. + * Returns the first matching text, or null on timeout. + */ + async waitForAnyText(texts: string[], timeoutMs = 10000): Promise { + this.ensureStarted(); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const screen = await this.capturePane(); + for (const text of texts) { + if (screen.includes(text)) return text; + } + await sleep(250); + } + return null; + } + + /** Assert that the screen contains the given text. Throws with screen dump on failure. */ + async assertScreenContains(text: string, message?: string): Promise { + const screen = await this.capturePane(); + if (!screen.includes(text)) { + throw new Error( + `${message ?? "Expected screen to contain"}: "${text}"\n\n` + + `--- Screen content ---\n${screen}\n--- End screen ---` + ); + } + } + + /** Assert that the screen does NOT contain the given text. */ + async assertScreenNotContains(text: string, message?: string): Promise { + const screen = await this.capturePane(); + if (screen.includes(text)) { + throw new Error( + `${message ?? "Expected screen NOT to contain"}: "${text}"\n\n` + + `--- Screen content ---\n${screen}\n--- End screen ---` + ); + } + } + + /** Kill the tmux session and clean up. */ + async cleanup(): Promise { + if (this.destroyed) return; + this.destroyed = true; + try { + await this.exec([ + "tmux", "kill-session", + "-t", this.sessionName, + ]); + } catch { + // Session may already be gone + } + } + + /** Dump the current screen for debugging. */ + async dumpScreen(label?: string): Promise { + const screen = await this.capturePane(); + const header = label ? `[${label}]` : "[Screen Dump]"; + console.log(`\n${header}\n${"─".repeat(this.width)}\n${screen}\n${"─".repeat(this.width)}\n`); + } + + ensureStarted(): void { + if (!this.started) throw new Error("Harness not started. Call start() first."); + if (this.destroyed) throw new Error("Harness already cleaned up."); + } + + async exec(cmd: string[]): Promise { + const proc = Bun.spawn(cmd, { + cwd: this.cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`Command failed (exit ${exitCode}): ${cmd.join(" ")}\nstderr: ${stderr}`); + } + return stdout; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/explorer/e2e/tui-smoke.test.ts b/tests/explorer/e2e/tui-smoke.test.ts new file mode 100644 index 0000000..8e8ec8a --- /dev/null +++ b/tests/explorer/e2e/tui-smoke.test.ts @@ -0,0 +1,407 @@ +/** + * End-to-end smoke tests for recs-explorer using tmux. + * + * These tests launch the real TUI in a tmux session and interact with it + * via send-keys / capture-pane. They catch bugs that unit tests miss. + * + * Requirements: tmux must be installed and available in PATH. + */ + +import { describe, test, expect, afterEach } from "bun:test"; +import { TmuxTestHarness } from "./tmux-harness.ts"; +import { join } from "node:path"; + +// These tests are slow and timing-sensitive (launch real tmux sessions). +// Skip by default; run with: RUN_E2E=1 bun test tests/explorer/e2e/ +const SKIP = !process.env["RUN_E2E"]; + +const REPO_ROOT = join(import.meta.dir, "..", "..", ".."); + +// Check tmux availability +let tmuxAvailable = false; +try { + const proc = Bun.spawnSync(["tmux", "-V"]); + tmuxAvailable = proc.exitCode === 0; +} catch { + tmuxAvailable = false; +} + +const describeE2E = (tmuxAvailable && !SKIP) ? describe : describe.skip; + +/** + * Helper: add a stage by typing its name in the AddStageModal. + * Handles the full flow: open modal → search → select → dismiss edit modal. + * + * The EditStageModal is dismissed with Escape rather than Enter because + * ink-text-input's internal useInput handler is recreated every render + * without useCallback, creating brief deregistration windows where + * tmux-sent Enter events can be missed. Since ADD_STAGE already creates + * the stage before EditStageModal opens, canceling simply keeps the + * default empty args — fine for operations like fromps that need none. + */ +/** + * Helper: add a stage by typing its name in the AddStageModal. + * Handles the full flow: open modal → search → select → dismiss edit modal. + * + * The EditStageModal is dismissed with Escape rather than Enter because + * ink-text-input's internal useInput handler is recreated every render + * without useCallback, creating brief deregistration windows where + * tmux-sent Enter events can be missed. Since ADD_STAGE already creates + * the stage before EditStageModal opens, canceling simply keeps the + * default empty args — fine for operations like fromps that need none. + */ +async function addStage(harness: TmuxTestHarness, opName: string): Promise { + await harness.sendKeys("a"); + // Wait for the AddStageModal to appear + const modalAppeared = await harness.waitForAnyText(["Add Stage", "Search"], 5000); + if (!modalAppeared) { + await harness.dumpScreen("addStage - AddStageModal did not appear"); + throw new Error("AddStageModal did not appear after pressing 'a'"); + } + + // Extra delay: the AddStageModal may appear on screen (React render) before + // its useInput/TextInput event listeners are registered (React effects run + // after paint). Characters sent during this gap are delivered to the wrong + // listeners and lost. Wait for the event loop to settle. + await sleep(1500); + + await harness.sendText(opName); + await sleep(800); + + // Press Enter to select the operation in AddStageModal + await harness.sendKeys("Enter"); + // Wait for the EditStageModal to appear (shows "Edit: ") + const editAppeared = await harness.waitForText("Edit:", 8000); + if (!editAppeared) { + await harness.dumpScreen("addStage - EditStageModal did not appear"); + throw new Error("EditStageModal did not appear after pressing Enter"); + } + + // Confirm the EditStageModal with Enter (keeps default empty args). + // Enter is processed immediately by the input parser, unlike Escape which + // goes through a setImmediate delay for escape-sequence disambiguation. + // + // The event loop may be frozen for several seconds after the modal mounts + // (Bun compiling operation modules, pipeline execution, etc.), so we retry + // Enter with increasing delays until the modal closes. + let editGone = false; + for (let attempt = 0; attempt < 6 && !editGone; attempt++) { + await harness.sendKeys("Enter"); + editGone = await harness.waitForTextGone("Edit:", 3000); + } + if (!editGone) { + await harness.dumpScreen("addStage - EditStageModal did not close"); + } + await sleep(300); +} + +/** + * Helper: wait for execution results to appear. + * Falls back to pressing 'r' (manual re-run) if auto-execution hasn't fired. + * + * Note: We search for "N records" patterns rather than "cached" because the + * not-yet-executed state shows "not cached" which contains "cached" as a + * substring, causing false-positive matches. + */ +async function waitForExecution(harness: TmuxTestHarness, timeoutMs = 10000): Promise { + // "records" appears in "Inspector: fromps (N records, cached Xs ago)" + // "computing" appears while execution is in progress + const found = await harness.waitForAnyText(["records", "computing"], timeoutMs); + if (found === "computing") { + // Execution started but hasn't finished — wait for completion + const done = await harness.waitForAnyText(["records"], timeoutMs); + return done !== null; + } + if (found) return true; + + // Auto-execution might not have triggered. Press 'r' to force re-run. + await harness.sendKeys("r"); + await sleep(1000); + const afterR = await harness.waitForAnyText(["records", "computing"], 8000); + if (afterR === "computing") { + const done = await harness.waitForAnyText(["records"], timeoutMs); + return done !== null; + } + return afterR !== null; +} + +/** + * Helper: start explorer and navigate past welcome screen to new pipeline. + */ +async function startNewPipeline(harness: TmuxTestHarness): Promise { + await harness.start(); + const found = await harness.waitForText("Welcome to recs explorer", 15000); + if (!found) throw new Error("Welcome screen did not appear"); + + await harness.sendKeys("n"); + const gone = await harness.waitForTextGone("Welcome to recs explorer", 8000); + if (!gone) { + // Retry the 'n' key in case it was lost during busy event loop + await harness.sendKeys("n"); + const retryGone = await harness.waitForTextGone("Welcome to recs explorer", 5000); + if (!retryGone) { + await harness.dumpScreen("startNewPipeline - welcome screen still showing after retry"); + throw new Error("Welcome screen did not dismiss after pressing 'n' twice"); + } + } + await sleep(500); +} + +describeE2E("Explorer E2E (tmux)", () => { + let harness: TmuxTestHarness; + + afterEach(async () => { + if (harness) { + await harness.cleanup(); + } + // Brief pause between tests to let tmux sessions clean up + await sleep(500); + }); + + // ── 1. Welcome Screen ───────────────────────────────────────────── + + test("welcome screen appears with no args", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await harness.start(); + + const found = await harness.waitForText("Welcome to recs explorer", 10000); + expect(found).toBe(true); + + await harness.assertScreenContains("[o] Open file"); + await harness.assertScreenContains("[n] New empty"); + await harness.assertScreenContains("[q] Quit"); + }, 20000); + + // ── 2. New Pipeline ──────────────────────────────────────────────── + + test("press 'n' to start new pipeline from welcome screen", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + const screen = await harness.capturePane(); + expect( + screen.includes("Pipeline") || + screen.includes("Inspector") || + screen.includes("a:add") + ).toBe(true); + }, 20000); + + // ── 3. fromps Stage (key regression test) ───────────────────────── + + test("fromps stage produces records in a new pipeline", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + await harness.dumpScreen("before addStage"); + await addStage(harness, "fromps"); + await harness.dumpScreen("after addStage, before waitForExecution"); + await waitForExecution(harness, 10000); + + const screen = await harness.capturePane(); + const hasRecordIndicators = + screen.includes("pid") || + screen.includes("user") || + screen.includes("command") || + screen.includes("%cpu") || + /\d+ record/.test(screen); + + if (!hasRecordIndicators) { + await harness.dumpScreen("fromps - no record indicators found"); + } + + expect(hasRecordIndicators).toBe(true); + }, 45000); + + // ── 4. File Input ───────────────────────────────────────────────── + + test("launch with JSONL file shows records", async () => { + const testFile = `/tmp/recs-e2e-test-${Date.now()}.jsonl`; + await Bun.write(testFile, [ + '{"name":"Alice","age":30}', + '{"name":"Bob","age":25}', + '{"name":"Charlie","age":35}', + ].join("\n") + "\n"); + + try { + harness = new TmuxTestHarness({ + cwd: REPO_ROOT, + args: [testFile], + }); + await harness.start(); + + // Wait for data to render + const hasData = await harness.waitForAnyText( + ["Alice", "Bob", "Charlie", "name", "age"], + 8000, + ); + + if (!hasData) { + await harness.dumpScreen("file input - no data found"); + } + + expect(hasData).not.toBeNull(); + } finally { + const fs = await import("node:fs"); + try { fs.unlinkSync(testFile); } catch {} + } + }, 20000); + + // ── 5. Navigation (j/k) ────────────────────────────────────────── + + test("j/k moves cursor between stages", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + await addStage(harness, "fromps"); + await waitForExecution(harness, 10000); + + // Add a second stage (sort with no args — works as identity) + await addStage(harness, "sort"); + await sleep(2000); + + const screen1 = await harness.capturePane(); + const hasFromps = screen1.includes("fromps"); + const hasSort = screen1.includes("sort"); + + if (!hasFromps || !hasSort) { + await harness.dumpScreen("navigation - missing stages"); + } + + expect(hasFromps).toBe(true); + expect(hasSort).toBe(true); + + await harness.sendKeys("k"); + await sleep(500); + + // Both stages should still be visible after navigation + const screen2 = await harness.capturePane(); + expect(screen2.includes("fromps")).toBe(true); + expect(screen2.includes("sort")).toBe(true); + }, 50000); + + // ── 6. Tab toggles panels ──────────────────────────────────────── + + test("Tab switches between pipeline and inspector panels", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + await addStage(harness, "fromps"); + await waitForExecution(harness, 10000); + + await harness.sendKeys("Tab"); + await sleep(500); + + const screen = await harness.capturePane(); + expect(screen.length).toBeGreaterThan(0); + expect(screen.includes("fromps")).toBe(true); + + await harness.sendKeys("Tab"); + await sleep(500); + }, 40000); + + // ── 7. View mode cycling ────────────────────────────────────────── + + test("'t' cycles through view modes in inspector", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + await addStage(harness, "fromps"); + await waitForExecution(harness, 10000); + + await harness.sendKeys("Tab"); + await sleep(500); + + // Capture views as we cycle: table → prettyprint → json → schema + const views: string[] = []; + for (let i = 0; i < 4; i++) { + views.push(await harness.capturePane()); + await harness.sendKeys("t"); + await sleep(500); + } + + // At least some screens should differ + const uniqueViews = new Set(views); + expect(uniqueViews.size).toBeGreaterThanOrEqual(1); + }, 45000); + + // ── 8. Help panel ───────────────────────────────────────────────── + + test("'?' opens help panel", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + await harness.sendKeys("?"); + await sleep(1000); + + const screen = await harness.capturePane(); + const hasHelp = + screen.includes("Help") || + screen.includes("Keyboard") || + screen.includes("shortcuts") || + screen.includes("Keybindings") || + screen.includes("help"); + + if (!hasHelp) { + await harness.dumpScreen("help - no help panel found"); + } + + expect(hasHelp).toBe(true); + + await harness.sendKeys("Escape"); + await sleep(500); + }, 20000); + + // ── 9. Quit with 'q' ───────────────────────────────────────────── + + test("'q' exits the explorer", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + await harness.sendKeys("q"); + await sleep(2000); + + let sessionDead = false; + try { + const screen = await harness.capturePane(); + sessionDead = !screen.includes("recs explorer") && !screen.includes("Welcome"); + } catch { + sessionDead = true; + } + expect(sessionDead).toBe(true); + }, 20000); + + // ── 10. Undo with 'u' ──────────────────────────────────────────── + + test("'u' undoes the last stage addition", async () => { + harness = new TmuxTestHarness({ cwd: REPO_ROOT }); + await startNewPipeline(harness); + + await addStage(harness, "fromps"); + await waitForExecution(harness, 10000); + + // Verify fromps is listed + await harness.assertScreenContains("fromps"); + + // Press 'u' to undo + await harness.sendKeys("u"); + await sleep(2000); + + const screen = await harness.capturePane(); + + // After undo, the pipeline should be empty (fromps removed) + const isEmpty = + screen.includes("(empty") || + screen.includes("press a to add") || + !screen.includes("1 fromps"); + + if (!isEmpty) { + await harness.dumpScreen("undo - fromps still visible"); + } + + expect(isEmpty).toBe(true); + }, 45000); +}); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/explorer/executor/cache-manager.test.ts b/tests/explorer/executor/cache-manager.test.ts new file mode 100644 index 0000000..e30f5fb --- /dev/null +++ b/tests/explorer/executor/cache-manager.test.ts @@ -0,0 +1,517 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { rmSync, existsSync } from "node:fs"; +import { CacheManager } from "../../../src/explorer/executor/cache-manager.ts"; +import type { + CachedResult, + CacheConfig, + Stage, + Fork, +} from "../../../src/explorer/model/types.ts"; + +// ── Helpers ────────────────────────────────────────────────────────── + +const TEST_SPILL_DIR = "/tmp/recs-explorer-cache-test"; + +function makeConfig(overrides?: Partial): CacheConfig { + return { + maxMemoryBytes: 100 * 1024 * 1024, // 100 MB + cachePolicy: "all", + pinnedStageIds: new Set(), + ...overrides, + }; +} + +function makeCachedResult( + inputId: string, + stageId: string, + overrides?: Partial, +): CachedResult { + return { + key: `${inputId}:${stageId}`, + stageId, + inputId, + records: [], + lines: [], + spillFile: null, + recordCount: 10, + fieldNames: ["a", "b"], + computedAt: Date.now(), + sizeBytes: 1000, + computeTimeMs: 5, + ...overrides, + }; +} + +function makeStage( + id: string, + opName: string, + args: string[], + forkId: string, + position: number, + parentId: string | null = null, +): Stage { + return { + id, + config: { operationName: opName, args, enabled: true }, + parentId, + childIds: [], + forkId, + position, + }; +} + +function makeFork(id: string, stageIds: string[]): Fork { + return { + id, + name: id, + forkPointStageId: null, + parentForkId: null, + stageIds, + createdAt: Date.now(), + }; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe("CacheManager", () => { + let manager: CacheManager; + + beforeEach(() => { + manager = new CacheManager(makeConfig()); + }); + + afterEach(() => { + if (existsSync(TEST_SPILL_DIR)) { + rmSync(TEST_SPILL_DIR, { recursive: true, force: true }); + } + }); + + // ── Basic get/put/has ────────────────────────────────────────── + + describe("get/put/has", () => { + test("stores and retrieves a cached result", () => { + const result = makeCachedResult("in1", "s1"); + manager.put(result); + + expect(manager.has("in1", "s1")).toBe(true); + expect(manager.get("in1", "s1")).toEqual(result); + }); + + test("returns undefined for cache miss", () => { + expect(manager.get("in1", "s1")).toBeUndefined(); + expect(manager.has("in1", "s1")).toBe(false); + }); + + test("replaces existing entry with same key", () => { + const r1 = makeCachedResult("in1", "s1", { recordCount: 10 }); + const r2 = makeCachedResult("in1", "s1", { recordCount: 20 }); + + manager.put(r1); + manager.put(r2); + + expect(manager.size).toBe(1); + expect(manager.get("in1", "s1")!.recordCount).toBe(20); + }); + + test("stores entries with different input/stage combinations", () => { + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in1", "s2")); + manager.put(makeCachedResult("in2", "s1")); + + expect(manager.size).toBe(3); + expect(manager.has("in1", "s1")).toBe(true); + expect(manager.has("in1", "s2")).toBe(true); + expect(manager.has("in2", "s1")).toBe(true); + }); + }); + + // ── Cache Key Computation ────────────────────────────────────── + + describe("computeCacheKey", () => { + test("produces a 16-char hex string", () => { + const stages = [makeStage("s1", "grep", ["status=200"], "main", 0)]; + const key = manager.computeCacheKey("in1", stages, 0); + + expect(key).toHaveLength(16); + expect(key).toMatch(/^[0-9a-f]+$/); + }); + + test("same input produces same key", () => { + const stages = [makeStage("s1", "grep", ["status=200"], "main", 0)]; + const key1 = manager.computeCacheKey("in1", stages, 0); + const key2 = manager.computeCacheKey("in1", stages, 0); + + expect(key1).toBe(key2); + }); + + test("different args produce different keys", () => { + const stages1 = [makeStage("s1", "grep", ["status=200"], "main", 0)]; + const stages2 = [makeStage("s1", "grep", ["status=404"], "main", 0)]; + + const key1 = manager.computeCacheKey("in1", stages1, 0); + const key2 = manager.computeCacheKey("in1", stages2, 0); + + expect(key1).not.toBe(key2); + }); + + test("different input IDs produce different keys", () => { + const stages = [makeStage("s1", "grep", ["status=200"], "main", 0)]; + const key1 = manager.computeCacheKey("in1", stages, 0); + const key2 = manager.computeCacheKey("in2", stages, 0); + + expect(key1).not.toBe(key2); + }); + + test("upstream change cascades to downstream key", () => { + const stages1 = [ + makeStage("s1", "grep", ["status=200"], "main", 0), + makeStage("s2", "sort", ["--key", "x=n"], "main", 1, "s1"), + ]; + const stages2 = [ + makeStage("s1", "grep", ["status=404"], "main", 0), + makeStage("s2", "sort", ["--key", "x=n"], "main", 1, "s1"), + ]; + + const key1 = manager.computeCacheKey("in1", stages1, 1); + const key2 = manager.computeCacheKey("in1", stages2, 1); + + // Different upstream args → different downstream key + expect(key1).not.toBe(key2); + }); + + test("enabled flag affects cache key", () => { + const stage1 = makeStage("s1", "grep", ["status=200"], "main", 0); + const stage2 = { ...stage1, config: { ...stage1.config, enabled: false } }; + + const key1 = manager.computeCacheKey("in1", [stage1], 0); + const key2 = manager.computeCacheKey("in1", [stage2], 0); + + expect(key1).not.toBe(key2); + }); + }); + + // ── LRU Eviction ─────────────────────────────────────────────── + + describe("LRU eviction", () => { + test("evicts least recently used entry when memory limit exceeded", () => { + const config = makeConfig({ maxMemoryBytes: 3000 }); + manager = new CacheManager(config); + + manager.put(makeCachedResult("in1", "s1", { sizeBytes: 1000 })); + manager.put(makeCachedResult("in1", "s2", { sizeBytes: 1000 })); + manager.put(makeCachedResult("in1", "s3", { sizeBytes: 1000 })); + + expect(manager.size).toBe(3); + + // Adding a 4th entry should evict the oldest (s1) + manager.put(makeCachedResult("in1", "s4", { sizeBytes: 1000 })); + + expect(manager.has("in1", "s1")).toBe(false); + expect(manager.has("in1", "s4")).toBe(true); + }); + + test("accessing an entry updates its LRU position", () => { + const config = makeConfig({ maxMemoryBytes: 3000 }); + manager = new CacheManager(config); + + manager.put(makeCachedResult("in1", "s1", { sizeBytes: 1000 })); + manager.put(makeCachedResult("in1", "s2", { sizeBytes: 1000 })); + manager.put(makeCachedResult("in1", "s3", { sizeBytes: 1000 })); + + // Access s1 to make it "recently used" + manager.get("in1", "s1"); + + // Adding s4 should evict s2 (oldest that wasn't recently accessed) + manager.put(makeCachedResult("in1", "s4", { sizeBytes: 1000 })); + + expect(manager.has("in1", "s1")).toBe(true); + expect(manager.has("in1", "s2")).toBe(false); + expect(manager.has("in1", "s4")).toBe(true); + }); + + test("tracks memory usage correctly", () => { + manager.put(makeCachedResult("in1", "s1", { sizeBytes: 500 })); + manager.put(makeCachedResult("in1", "s2", { sizeBytes: 300 })); + + expect(manager.memoryUsageBytes).toBe(800); + }); + + test("memory usage decreases on eviction", () => { + const config = makeConfig({ maxMemoryBytes: 1500 }); + manager = new CacheManager(config); + + manager.put(makeCachedResult("in1", "s1", { sizeBytes: 1000 })); + expect(manager.memoryUsageBytes).toBe(1000); + + // This should evict s1 + manager.put(makeCachedResult("in1", "s2", { sizeBytes: 1000 })); + expect(manager.memoryUsageBytes).toBe(1000); + expect(manager.size).toBe(1); + }); + }); + + // ── Cache Policies ───────────────────────────────────────────── + + describe("cache policies", () => { + test("policy 'none' rejects all entries", () => { + manager = new CacheManager(makeConfig({ cachePolicy: "none" })); + + manager.put(makeCachedResult("in1", "s1")); + expect(manager.size).toBe(0); + }); + + test("policy 'selective' only caches pinned stages", () => { + manager = new CacheManager( + makeConfig({ + cachePolicy: "selective", + pinnedStageIds: new Set(["s2"]), + }), + ); + + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in1", "s2")); + manager.put(makeCachedResult("in1", "s3")); + + expect(manager.size).toBe(1); + expect(manager.has("in1", "s1")).toBe(false); + expect(manager.has("in1", "s2")).toBe(true); + expect(manager.has("in1", "s3")).toBe(false); + }); + + test("policy 'all' caches everything", () => { + manager = new CacheManager(makeConfig({ cachePolicy: "all" })); + + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in1", "s2")); + + expect(manager.size).toBe(2); + }); + + test("updateConfig to 'none' clears existing entries", () => { + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in1", "s2")); + expect(manager.size).toBe(2); + + manager.updateConfig(makeConfig({ cachePolicy: "none" })); + expect(manager.size).toBe(0); + }); + + test("updateConfig to 'selective' affects new puts", () => { + manager.put(makeCachedResult("in1", "s1")); + expect(manager.size).toBe(1); + + manager.updateConfig( + makeConfig({ + cachePolicy: "selective", + pinnedStageIds: new Set(["s2"]), + }), + ); + + manager.put(makeCachedResult("in1", "s3")); + // s3 is not pinned so it shouldn't be added + expect(manager.has("in1", "s3")).toBe(false); + // s1 was already cached and should still be there + expect(manager.has("in1", "s1")).toBe(true); + }); + }); + + // ── Cascading Invalidation ───────────────────────────────────── + + describe("cascading invalidation", () => { + test("invalidateStage removes all entries for that stage", () => { + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in2", "s1")); + manager.put(makeCachedResult("in1", "s2")); + + manager.invalidateStage("s1"); + + expect(manager.has("in1", "s1")).toBe(false); + expect(manager.has("in2", "s1")).toBe(false); + expect(manager.has("in1", "s2")).toBe(true); + }); + + test("invalidateCascade removes stage and all downstream stages", () => { + const stages = new Map([ + ["s1", makeStage("s1", "grep", [], "main", 0)], + ["s2", makeStage("s2", "sort", [], "main", 1, "s1")], + ["s3", makeStage("s3", "collate", [], "main", 2, "s2")], + ]); + const forks = new Map([ + ["main", makeFork("main", ["s1", "s2", "s3"])], + ]); + + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in1", "s2")); + manager.put(makeCachedResult("in1", "s3")); + + // Invalidate from s2 — should remove s2 and s3, keep s1 + manager.invalidateCascade("s2", forks, stages); + + expect(manager.has("in1", "s1")).toBe(true); + expect(manager.has("in1", "s2")).toBe(false); + expect(manager.has("in1", "s3")).toBe(false); + }); + + test("invalidateCascade from first stage clears all", () => { + const stages = new Map([ + ["s1", makeStage("s1", "grep", [], "main", 0)], + ["s2", makeStage("s2", "sort", [], "main", 1, "s1")], + ]); + const forks = new Map([ + ["main", makeFork("main", ["s1", "s2"])], + ]); + + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in1", "s2")); + + manager.invalidateCascade("s1", forks, stages); + + expect(manager.has("in1", "s1")).toBe(false); + expect(manager.has("in1", "s2")).toBe(false); + }); + + test("invalidateCascade with unknown stage is a no-op", () => { + manager.put(makeCachedResult("in1", "s1")); + + const stages = new Map(); + const forks = new Map(); + + manager.invalidateCascade("unknown", forks, stages); + expect(manager.has("in1", "s1")).toBe(true); + }); + }); + + // ── Disk Spill ───────────────────────────────────────────────── + + describe("disk spill", () => { + test("spills large results to disk", () => { + manager = new CacheManager(makeConfig(), TEST_SPILL_DIR); + + const bigSizeBytes = CacheManager.SPILL_THRESHOLD_BYTES + 1; + const records = [{ x: 1 }, { x: 2 }] as unknown as CachedResult["records"]; + const result = makeCachedResult("in1", "s1", { + records, + sizeBytes: bigSizeBytes, + }); + + manager.put(result); + + // Entry should exist but with empty in-memory records + expect(manager.has("in1", "s1")).toBe(true); + + // Spill file should exist on disk + const entry = manager.get("in1", "s1")!; + expect(entry).toBeDefined(); + }); + + test("does not spill results under threshold", () => { + manager = new CacheManager(makeConfig(), TEST_SPILL_DIR); + + const result = makeCachedResult("in1", "s1", { + sizeBytes: 1000, + }); + + manager.put(result); + + const entry = manager.get("in1", "s1")!; + expect(entry.spillFile).toBeNull(); + }); + + test("does not spill when no spillDir configured", () => { + manager = new CacheManager(makeConfig()); // no spillDir + + const bigSizeBytes = CacheManager.SPILL_THRESHOLD_BYTES + 1; + const result = makeCachedResult("in1", "s1", { + records: [{ x: 1 }] as unknown as CachedResult["records"], + sizeBytes: bigSizeBytes, + }); + + manager.put(result); + + const entry = manager.get("in1", "s1")!; + // Without spillDir, records stay in memory + expect(entry.spillFile).toBeNull(); + }); + + test("cleanup removes spill files on eviction", () => { + manager = new CacheManager( + makeConfig({ maxMemoryBytes: 500 }), + TEST_SPILL_DIR, + ); + + const bigSizeBytes = CacheManager.SPILL_THRESHOLD_BYTES + 1; + const result = makeCachedResult("in1", "s1", { + records: [{ x: 1 }] as unknown as CachedResult["records"], + sizeBytes: bigSizeBytes, + }); + + manager.put(result); + + // Get the spill file path + const entry = manager.get("in1", "s1"); + const spillFile = entry?.spillFile; + + // Clear the cache — should clean up spill files + manager.clear(); + + if (spillFile) { + expect(existsSync(spillFile)).toBe(false); + } + }); + }); + + // ── Bulk Operations ──────────────────────────────────────────── + + describe("bulk operations", () => { + test("clear removes all entries", () => { + manager.put(makeCachedResult("in1", "s1")); + manager.put(makeCachedResult("in1", "s2")); + manager.put(makeCachedResult("in2", "s1")); + + manager.clear(); + + expect(manager.size).toBe(0); + expect(manager.memoryUsageBytes).toBe(0); + }); + + test("toMap returns all entries as a Map", () => { + const r1 = makeCachedResult("in1", "s1"); + const r2 = makeCachedResult("in1", "s2"); + + manager.put(r1); + manager.put(r2); + + const map = manager.toMap(); + expect(map.size).toBe(2); + expect(map.get("in1:s1")).toEqual(r1); + expect(map.get("in1:s2")).toEqual(r2); + }); + + test("fromMap restores entries from a Map", () => { + const map = new Map([ + ["in1:s1", makeCachedResult("in1", "s1")], + ["in1:s2", makeCachedResult("in1", "s2")], + ]); + + manager.fromMap(map); + + expect(manager.size).toBe(2); + expect(manager.has("in1", "s1")).toBe(true); + expect(manager.has("in1", "s2")).toBe(true); + }); + + test("fromMap clears existing entries first", () => { + manager.put(makeCachedResult("in1", "old")); + + const map = new Map([ + ["in1:s1", makeCachedResult("in1", "s1")], + ]); + + manager.fromMap(map); + + expect(manager.size).toBe(1); + expect(manager.has("in1", "old")).toBe(false); + expect(manager.has("in1", "s1")).toBe(true); + }); + }); +}); diff --git a/tests/tui/executor/executor.test.ts b/tests/explorer/executor/executor.test.ts similarity index 65% rename from tests/tui/executor/executor.test.ts rename to tests/explorer/executor/executor.test.ts index b39afc2..d7d4d85 100644 --- a/tests/tui/executor/executor.test.ts +++ b/tests/explorer/executor/executor.test.ts @@ -1,11 +1,11 @@ import { describe, test, expect } from "bun:test"; import { Record } from "../../../src/Record.ts"; -import { InterceptReceiver } from "../../../src/tui/executor/intercept-receiver.ts"; +import { InterceptReceiver } from "../../../src/explorer/executor/intercept-receiver.ts"; import { executeToStage, getStagePath, -} from "../../../src/tui/executor/executor.ts"; -import { loadInputRecords } from "../../../src/tui/executor/input-loader.ts"; +} from "../../../src/explorer/executor/executor.ts"; +import { loadInputRecords } from "../../../src/explorer/executor/input-loader.ts"; import { createOperation } from "../../../src/operations/transform/chain.ts"; import type { PipelineState, @@ -13,7 +13,7 @@ import type { InputSource, CacheConfig, InspectorState, -} from "../../../src/tui/model/types.ts"; +} from "../../../src/explorer/model/types.ts"; // ── Helpers ────────────────────────────────────────────────────────── @@ -63,6 +63,7 @@ function makePipelineState( viewMode: "table", scrollOffset: 0, searchQuery: null, + highlightedColumn: null, }; return { @@ -93,7 +94,7 @@ function makePipelineState( undoStack: [], redoStack: [], sessionId: "test-session", - sessionDir: "/tmp/recs-tui-test", + sessionDir: "/tmp/recs-explorer-test", }; } @@ -283,6 +284,7 @@ describe("executeToStage", () => { stageId: "s1", inputId: "in1", records: [new Record({ x: 5 }), new Record({ x: 3 })], + lines: [], spillFile: null, recordCount: 2, fieldNames: ["x"], @@ -344,7 +346,7 @@ describe("executeToStage", () => { // 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`; + const tmpFile = `/tmp/recs-explorer-test-${Date.now()}.csv`; await Bun.write(tmpFile, csvContent); const fileInput: InputSource = { @@ -438,6 +440,135 @@ describe("executeToStage", () => { }); }); +// ── Self-contained op tests (fromps without input source) ───────────────── + +// fromps spawns `ps aux` which is slow and may fail in sandboxed environments. +// Run explicitly with: RUN_SLOW=1 bun test tests/explorer/executor/executor.test.ts +const describeFromps = process.env["RUN_SLOW"] ? describe : describe.skip; + +describeFromps("Self-contained ops (no input source)", () => { + test( + "fromps executes and produces records without an input source", + async () => { + const stages = [makeStage("s1", "fromps", [], null, 0)]; + const stageMap = new Map(); + for (const s of stages) stageMap.set(s.id, s); + + const cacheConfig: CacheConfig = { + maxMemoryBytes: 100 * 1024 * 1024, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + const inspector: InspectorState = { + viewMode: "table", + scrollOffset: 0, + searchQuery: null, + highlightedColumn: null, + }; + + const state: PipelineState = { + stages: stageMap, + forks: new Map([ + [ + "main", + { + id: "main", + name: "main", + forkPointStageId: null, + parentForkId: null, + stageIds: ["s1"], + createdAt: Date.now(), + }, + ], + ]), + inputs: new Map(), + activeInputId: "no-input", + activeForkId: "main", + cursorStageId: "s1", + focusedPanel: "pipeline", + cache: new Map(), + cacheConfig, + inspector, + executing: false, + lastError: null, + undoStack: [], + redoStack: [], + sessionId: "test-session", + sessionDir: "/tmp/recs-explorer-test", + }; + + const result = await executeToStage(state, "s1"); + + expect(result.recordCount).toBeGreaterThan(0); + expect(result.records.length).toBeGreaterThan(0); + expect(result.fieldNames.length).toBeGreaterThan(0); + }, + 30000, + ); + + test( + "fromps + grep pipeline works without input source", + async () => { + const stages = [ + makeStage("s1", "fromps", [], null, 0), + makeStage("s2", "grep", ["true"], "s1", 1), + ]; + const stageMap = new Map(); + for (const s of stages) stageMap.set(s.id, s); + stageMap.get("s1")!.childIds = ["s2"]; + + const cacheConfig: CacheConfig = { + maxMemoryBytes: 100 * 1024 * 1024, + cachePolicy: "all", + pinnedStageIds: new Set(), + }; + const inspector: InspectorState = { + viewMode: "table", + scrollOffset: 0, + searchQuery: null, + highlightedColumn: null, + }; + + const state: PipelineState = { + stages: stageMap, + forks: new Map([ + [ + "main", + { + id: "main", + name: "main", + forkPointStageId: null, + parentForkId: null, + stageIds: ["s1", "s2"], + createdAt: Date.now(), + }, + ], + ]), + inputs: new Map(), + activeInputId: "no-input", + activeForkId: "main", + cursorStageId: "s2", + focusedPanel: "pipeline", + cache: new Map(), + cacheConfig, + inspector, + executing: false, + lastError: null, + undoStack: [], + redoStack: [], + sessionId: "test-session", + sessionDir: "/tmp/recs-explorer-test", + }; + + const result = await executeToStage(state, "s2"); + + expect(result.recordCount).toBeGreaterThan(0); + expect(result.records.length).toBeGreaterThan(0); + }, + 30000, + ); +}); + // ── Direct operation tests (using InterceptReceiver with createOperation) ─ describe("createOperation + InterceptReceiver", () => { @@ -488,4 +619,113 @@ describe("createOperation + InterceptReceiver", () => { const groups = receiver.records.map((r) => r.get("group")).sort(); expect(groups).toEqual(["a", "b"]); }); + + test("totable produces lines (not records) through InterceptReceiver", () => { + const receiver = new InterceptReceiver(); + const op = createOperation("totable", [], receiver); + + op.acceptRecord(new Record({ name: "alice", age: 30 })); + op.acceptRecord(new Record({ name: "bob", age: 25 })); + op.finish(); + + // totable outputs text lines, not records + expect(receiver.recordCount).toBe(0); + expect(receiver.records.length).toBe(0); + expect(receiver.lines.length).toBeGreaterThan(0); + // Should contain a header and data rows + const allText = receiver.lines.join("\n"); + expect(allText).toContain("alice"); + expect(allText).toContain("bob"); + }); + + test("toprettyprint produces lines through InterceptReceiver", () => { + const receiver = new InterceptReceiver(); + const op = createOperation("toprettyprint", [], receiver); + + op.acceptRecord(new Record({ x: 1, y: "hello" })); + op.finish(); + + expect(receiver.recordCount).toBe(0); + expect(receiver.records.length).toBe(0); + expect(receiver.lines.length).toBeGreaterThan(0); + const allText = receiver.lines.join("\n"); + expect(allText).toContain("hello"); + }); + + test("tocsv produces lines through InterceptReceiver", () => { + const receiver = new InterceptReceiver(); + const op = createOperation("tocsv", [], receiver); + + op.acceptRecord(new Record({ name: "alice", age: 30 })); + op.acceptRecord(new Record({ name: "bob", age: 25 })); + op.finish(); + + expect(receiver.recordCount).toBe(0); + expect(receiver.records.length).toBe(0); + expect(receiver.lines.length).toBeGreaterThan(0); + const allText = receiver.lines.join("\n"); + expect(allText).toContain("alice"); + expect(allText).toContain("bob"); + }); +}); + +// ── Text output through executor pipeline ───────────────────────────────── + +describe("Text output operations in executor pipeline", () => { + const inputRecords = [ + new Record({ name: "alice", age: 30 }), + new Record({ name: "bob", age: 25 }), + ]; + const input: InputSource = { + id: "in1", + source: { kind: "stdin-capture", records: inputRecords }, + label: "test input", + }; + + test("totable stage captures lines in CachedResult", async () => { + const stages = [ + makeStage("s1", "grep", ["true"], null, 0), + makeStage("s2", "totable", [], "s1", 1), + ]; + const state = makePipelineState(stages, input); + + const result = await executeToStage(state, "s2"); + + // totable produces lines, not records + expect(result.records.length).toBe(0); + expect(result.lines.length).toBeGreaterThan(0); + const allText = result.lines.join("\n"); + expect(allText).toContain("alice"); + expect(allText).toContain("bob"); + }); + + test("toprettyprint stage captures lines in CachedResult", async () => { + const stages = [ + makeStage("s1", "grep", ["true"], null, 0), + makeStage("s2", "toprettyprint", [], "s1", 1), + ]; + const state = makePipelineState(stages, input); + + const result = await executeToStage(state, "s2"); + + expect(result.records.length).toBe(0); + expect(result.lines.length).toBeGreaterThan(0); + const allText = result.lines.join("\n"); + expect(allText).toContain("alice"); + }); + + test("tocsv stage captures lines in CachedResult", async () => { + const stages = [ + makeStage("s1", "grep", ["true"], null, 0), + makeStage("s2", "tocsv", [], "s1", 1), + ]; + const state = makePipelineState(stages, input); + + const result = await executeToStage(state, "s2"); + + expect(result.records.length).toBe(0); + expect(result.lines.length).toBeGreaterThan(0); + const allText = result.lines.join("\n"); + expect(allText).toContain("alice"); + }); }); diff --git a/tests/explorer/hooks/useUndoRedo.test.ts b/tests/explorer/hooks/useUndoRedo.test.ts new file mode 100644 index 0000000..d886688 --- /dev/null +++ b/tests/explorer/hooks/useUndoRedo.test.ts @@ -0,0 +1,209 @@ +/** + * Tests for useUndoRedo derived state. + * + * Since useUndoRedo is a thin useMemo wrapper, we test the logic + * by computing the same derived values directly from PipelineState + * rather than requiring React Testing Library. + */ + +import { describe, test, expect, beforeEach } from "bun:test"; +import { + pipelineReducer, + createInitialState, +} from "../../../src/explorer/model/reducer.ts"; +import type { + PipelineState, + StageConfig, +} from "../../../src/explorer/model/types.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), + }); +} + +/** + * Mirror of useUndoRedo logic — computes derived state from PipelineState. + */ +function computeUndoRedoState(state: PipelineState) { + const undoCount = state.undoStack.length; + const redoCount = state.redoStack.length; + + const nextUndoLabel = + undoCount > 0 + ? (state.undoStack[undoCount - 1]?.label ?? null) + : null; + + const nextRedoLabel = + redoCount > 0 + ? (state.redoStack[redoCount - 1]?.label ?? null) + : null; + + return { + canUndo: undoCount > 0, + canRedo: redoCount > 0, + undoCount, + redoCount, + nextUndoLabel, + nextRedoLabel, + }; +} + +describe("useUndoRedo derived state", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("fresh state: cannot undo or redo", () => { + const result = computeUndoRedoState(state); + expect(result.canUndo).toBe(false); + expect(result.canRedo).toBe(false); + expect(result.undoCount).toBe(0); + expect(result.redoCount).toBe(0); + expect(result.nextUndoLabel).toBeNull(); + expect(result.nextRedoLabel).toBeNull(); + }); + + test("after one action: can undo, cannot redo", () => { + state = addStage(state, "grep"); + + const result = computeUndoRedoState(state); + expect(result.canUndo).toBe(true); + expect(result.canRedo).toBe(false); + expect(result.undoCount).toBe(1); + expect(result.nextUndoLabel).toBe("Add grep stage"); + }); + + test("after two actions: undoCount is 2", () => { + state = addStage(state, "grep"); + state = addStage(state, "sort"); + + const result = computeUndoRedoState(state); + expect(result.undoCount).toBe(2); + expect(result.nextUndoLabel).toBe("Add sort stage"); + }); + + test("after undo: can redo, nextRedoLabel shows undone action", () => { + state = addStage(state, "grep"); + state = addStage(state, "sort"); + + state = pipelineReducer(state, { type: "UNDO" }); + + const result = computeUndoRedoState(state); + expect(result.canUndo).toBe(true); + expect(result.canRedo).toBe(true); + expect(result.undoCount).toBe(1); + expect(result.redoCount).toBe(1); + expect(result.nextUndoLabel).toBe("Add grep stage"); + expect(result.nextRedoLabel).toBe("Add sort stage"); + }); + + test("after undo all: cannot undo, can redo all", () => { + state = addStage(state, "grep"); + state = addStage(state, "sort"); + + state = pipelineReducer(state, { type: "UNDO" }); + state = pipelineReducer(state, { type: "UNDO" }); + + const result = computeUndoRedoState(state); + expect(result.canUndo).toBe(false); + expect(result.canRedo).toBe(true); + expect(result.undoCount).toBe(0); + expect(result.redoCount).toBe(2); + }); + + test("new action after undo clears redo", () => { + state = addStage(state, "grep"); + state = addStage(state, "sort"); + state = pipelineReducer(state, { type: "UNDO" }); + + // New action should clear redo + state = addStage(state, "collate"); + + const result = computeUndoRedoState(state); + expect(result.canRedo).toBe(false); + expect(result.redoCount).toBe(0); + expect(result.nextRedoLabel).toBeNull(); + expect(result.undoCount).toBe(2); + }); + + test("undo label reflects the most recent structural action", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: id }); + + const result = computeUndoRedoState(state); + expect(result.nextUndoLabel).toBe("Toggle stage enabled"); + }); + + test("DELETE_STAGE shows correct undo label", () => { + state = addStage(state, "grep"); + state = addStage(state, "sort"); + const sortId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "DELETE_STAGE", + stageId: sortId, + }); + + const result = computeUndoRedoState(state); + expect(result.nextUndoLabel).toBe("Delete stage"); + }); + + test("UPDATE_STAGE_ARGS shows correct undo label", () => { + state = addStage(state, "grep", ["status=200"]); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "UPDATE_STAGE_ARGS", + stageId: id, + args: ["status=404"], + }); + + const result = computeUndoRedoState(state); + expect(result.nextUndoLabel).toBe("Update stage arguments"); + }); + + test("REORDER_STAGE shows correct undo label with direction", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "REORDER_STAGE", + stageId: bId, + direction: "up", + }); + + const result = computeUndoRedoState(state); + expect(result.nextUndoLabel).toBe("Move stage up"); + }); + + test("non-undoable actions do not change undo/redo counts", () => { + state = addStage(state, "grep"); + const initial = computeUndoRedoState(state); + + state = pipelineReducer(state, { type: "TOGGLE_FOCUS" }); + const afterFocus = computeUndoRedoState(state); + expect(afterFocus.undoCount).toBe(initial.undoCount); + + state = pipelineReducer(state, { + type: "SET_EXECUTING", + executing: true, + }); + const afterExec = computeUndoRedoState(state); + expect(afterExec.undoCount).toBe(initial.undoCount); + }); +}); diff --git a/tests/explorer/integration/e2e-comprehensive.test.ts b/tests/explorer/integration/e2e-comprehensive.test.ts new file mode 100644 index 0000000..a740ebe --- /dev/null +++ b/tests/explorer/integration/e2e-comprehensive.test.ts @@ -0,0 +1,1516 @@ +/** + * Comprehensive end-to-end integration tests for the Explorer. + * + * Covers scenarios not fully exercised by existing tests: + * - Large data performance (10k+ records) + * - Cache invalidation correctness (modify stage → downstream stale) + * - Fork operations with real execution + * - Undo/redo state consistency across complex sequences + * - Session save/load roundtrip with forks, inputs, and cache + * - Multiple input sources with switching + * - All view modes + * - Export edge cases + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { Record } from "../../../src/Record.ts"; +import { + createInitialState, + pipelineReducer, +} from "../../../src/explorer/model/reducer.ts"; +import { executeToStage } from "../../../src/explorer/executor/executor.ts"; +import { + getActivePath, + getCursorStage, + getCursorOutput, + getDownstreamStages, + isDownstreamOfError, + getTotalCacheSize, + getStageKind, + getStageDelta, +} from "../../../src/explorer/model/selectors.ts"; +import { + exportAsPipeScript, + exportAsChainCommand, +} from "../../../src/explorer/model/serialization.ts"; +import { SessionManager } from "../../../src/explorer/session/session-manager.ts"; +import type { + PipelineState, + PipelineAction, + StageConfig, +} from "../../../src/explorer/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); +} + +function generateRecords(count: number): Record[] { + return Array.from({ length: count }, (_, i) => + new Record({ + id: i, + name: `user-${i}`, + score: Math.floor(Math.random() * 100), + group: `group-${i % 10}`, + active: i % 3 === 0, + }), + ); +} + +// ── 1. Large Data Performance ──────────────────────────────────────── + +describe("Large data performance", () => { + test("10k records through grep + sort completes in < 2s", async () => { + let state = createInitialState(); + const records = generateRecords(10_000); + state = addInput(state, records); + + state = addStage(state, "grep", ["{{score}} > 50"]); + state = addStage(state, "sort", ["--key", "score=-n"]); + const sortId = getLastStageId(state); + + const start = performance.now(); + const result = await executeToStage(state, sortId); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(2000); + // Approximately half should pass the filter + expect(result.recordCount).toBeGreaterThan(0); + expect(result.recordCount).toBeLessThan(10_000); + + // Verify sort order is correct (descending by score) + for (let i = 1; i < result.records.length; i++) { + expect(result.records[i - 1]!.get("score")).toBeGreaterThanOrEqual( + result.records[i]!.get("score") as number, + ); + } + }); + + test("10k records through xform + collate completes in < 2s", async () => { + let state = createInitialState(); + const records = Array.from({ length: 10_000 }, (_, i) => + new Record({ + group: `g${i % 5}`, + value: i, + }), + ); + state = addInput(state, records); + + state = addStage(state, "xform", ["{{doubled}} = {{value}} * 2"]); + state = addStage(state, "collate", ["--key", "group", "-a", "count"]); + const collateId = getLastStageId(state); + + const start = performance.now(); + const result = await executeToStage(state, collateId); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(2000); + expect(result.recordCount).toBe(5); // 5 groups + // Each group should have 2000 records + for (const rec of result.records) { + expect(rec.get("count")).toBe(2000); + } + }); + + test("50k records through single grep completes in < 2s", async () => { + let state = createInitialState(); + const records = Array.from({ length: 50_000 }, (_, i) => + new Record({ x: i }), + ); + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} >= 25000"]); + const grepId = getLastStageId(state); + + const start = performance.now(); + const result = await executeToStage(state, grepId); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(2000); + expect(result.recordCount).toBe(25_000); + }); + + test("cache reuse: second execution of same stage is near-instant", async () => { + let state = createInitialState(); + const records = generateRecords(10_000); + state = addInput(state, records); + + state = addStage(state, "grep", ["{{score}} > 50"]); + state = addStage(state, "sort", ["--key", "score=-n"]); + const sortId = getLastStageId(state); + + // First execution populates cache + await executeToStage(state, sortId); + + // Second execution should hit cache + const start = performance.now(); + const result = await executeToStage(state, sortId); + const elapsed = performance.now() - start; + + // Cache hit should be very fast (< 50ms) + expect(elapsed).toBeLessThan(50); + expect(result.recordCount).toBeGreaterThan(0); + }); +}); + +// ── 2. Cache Invalidation Correctness ──────────────────────────────── + +describe("Cache invalidation correctness", () => { + test("UPDATE_STAGE_ARGS invalidates the stage and all downstream", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 5 }), + new Record({ x: 10 }), + new Record({ x: 15 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 3"]); + const grepId = getStageIds(state)[0]!; + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getStageIds(state)[1]!; + state = addStage(state, "grep", ["{{x}} < 20"]); + const grep2Id = getStageIds(state)[2]!; + + // Execute full pipeline + await executeToStage(state, grep2Id); + expect(state.cache.has(`${state.activeInputId}:${grepId}`)).toBe(true); + expect(state.cache.has(`${state.activeInputId}:${sortId}`)).toBe(true); + expect(state.cache.has(`${state.activeInputId}:${grep2Id}`)).toBe(true); + + // Modify the first grep — cache for grep and all downstream should be gone + state = dispatch(state, { + type: "UPDATE_STAGE_ARGS", + stageId: grepId, + args: ["{{x}} > 10"], + }); + + expect(state.cache.has(`${state.activeInputId}:${grepId}`)).toBe(false); + expect(state.cache.has(`${state.activeInputId}:${sortId}`)).toBe(false); + expect(state.cache.has(`${state.activeInputId}:${grep2Id}`)).toBe(false); + + // Re-execute and verify updated results + const result = await executeToStage(state, grep2Id); + expect(result.recordCount).toBe(1); // only x=15 passes both greps + expect(result.records[0]!.get("x")).toBe(15); + }); + + test("TOGGLE_STAGE invalidates downstream cache", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 2 }), + new Record({ x: 3 }), + ]; + 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]!; + + await executeToStage(state, sortId); + expect(state.cache.has(`${state.activeInputId}:${grepId}`)).toBe(true); + expect(state.cache.has(`${state.activeInputId}:${sortId}`)).toBe(true); + + // Disable grep + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: grepId }); + expect(state.cache.has(`${state.activeInputId}:${grepId}`)).toBe(false); + expect(state.cache.has(`${state.activeInputId}:${sortId}`)).toBe(false); + + // Re-execute — grep disabled means sort sees all 3 records + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("x")).toBe(3); + }); + + test("modifying middle stage only invalidates that stage and downstream, not upstream", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 2 }), + new Record({ x: 3 }), + new Record({ x: 4 }), + ]; + 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]!; + state = addStage(state, "grep", ["{{x}} < 4"]); + const grep2Id = getStageIds(state)[2]!; + + await executeToStage(state, grep2Id); + + // Cache the computedAt for the first grep + const grepCacheBefore = state.cache.get(`${state.activeInputId}:${grepId}`)!; + + // Modify the sort (middle stage) + state = dispatch(state, { + type: "UPDATE_STAGE_ARGS", + stageId: sortId, + args: ["--key", "x=-n"], + }); + + // Upstream (first grep) should still be cached + expect(state.cache.has(`${state.activeInputId}:${grepId}`)).toBe(true); + const grepCacheAfter = state.cache.get(`${state.activeInputId}:${grepId}`)!; + expect(grepCacheAfter.computedAt).toBe(grepCacheBefore.computedAt); + + // Downstream stages should be invalidated + expect(state.cache.has(`${state.activeInputId}:${sortId}`)).toBe(false); + expect(state.cache.has(`${state.activeInputId}:${grep2Id}`)).toBe(false); + }); + + test("executor uses cached upstream result when only downstream needs re-execution", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 2 }), + new Record({ x: 3 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 0"]); + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getStageIds(state)[1]!; + + // Execute full pipeline + await executeToStage(state, sortId); + + // Invalidate only sort cache (simulate downstream-only invalidation) + const sortCacheKey = `${state.activeInputId}:${sortId}`; + state.cache.delete(sortCacheKey); + + // Re-execute — should start from grep cached result + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("x")).toBe(1); + }); +}); + +// ── 3. Fork Operations with Execution ──────────────────────────────── + +describe("Fork operations with execution", () => { + test("create fork, add stages, execute independently from main", async () => { + let state = createInitialState(); + const records = [ + new Record({ name: "Alice", score: 90 }), + new Record({ name: "Bob", score: 60 }), + new Record({ name: "Charlie", score: 85 }), + ]; + state = addInput(state, records); + + // Build main pipeline: grep score > 70 + state = addStage(state, "grep", ["{{score}} > 70"]); + const mainGrepId = getLastStageId(state); + const mainForkId = state.activeForkId; + + // Execute main pipeline + const mainResult = await executeToStage(state, mainGrepId); + expect(mainResult.recordCount).toBe(2); // Alice, Charlie + + // Create a fork from the grep stage + state = dispatch(state, { + type: "CREATE_FORK", + name: "experiment", + atStageId: mainGrepId, + }); + const expForkId = state.activeForkId; + expect(expForkId).not.toBe(mainForkId); + + // Add different stage to the fork: sort by name + state = addStage(state, "sort", ["--key", "name"]); + const forkSortId = getLastStageId(state); + + // Execute fork pipeline + const forkResult = await executeToStage(state, forkSortId); + // Fork should see all 3 records (not filtered, since fork starts fresh) + // Actually, fork starts with empty stageIds and its own stages + // The fork's sort operates on the raw input + expect(forkResult.recordCount).toBe(3); + expect(forkResult.records[0]!.get("name")).toBe("Alice"); + + // Switch back to main and verify it still has its result + state = dispatch(state, { type: "SWITCH_FORK", forkId: mainForkId }); + const pathNames = getActivePath(state).map((s) => s.config.operationName); + expect(pathNames).toEqual(["grep"]); + }); + + test("independent fork execution: changing fork stages doesn't affect main", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 10 }), + new Record({ x: 20 }), + new Record({ x: 30 }), + ]; + state = addInput(state, records); + + // Main: grep x > 15 + state = addStage(state, "grep", ["{{x}} > 15"]); + const mainGrepId = getLastStageId(state); + const mainForkId = state.activeForkId; + + await executeToStage(state, mainGrepId); + + // Fork + state = dispatch(state, { + type: "CREATE_FORK", + name: "fork-1", + atStageId: mainGrepId, + }); + + // Fork: sort descending + state = addStage(state, "sort", ["--key", "x=-n"]); + const forkSortId = getLastStageId(state); + + const forkResult = await executeToStage(state, forkSortId); + expect(forkResult.records[0]!.get("x")).toBe(30); + + // Switch back to main — grep cache should still be there + state = dispatch(state, { type: "SWITCH_FORK", forkId: mainForkId }); + expect(state.cache.has(`${state.activeInputId}:${mainGrepId}`)).toBe(true); + const mainCached = state.cache.get(`${state.activeInputId}:${mainGrepId}`)!; + expect(mainCached.recordCount).toBe(2); + }); + + test("delete fork cleans up its stages", async () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "grep", ["{{x}} > 0"]); + const mainGrepId = getLastStageId(state); + const mainForkId = state.activeForkId; + + // Create fork and add stages + state = dispatch(state, { + type: "CREATE_FORK", + name: "temp-fork", + atStageId: mainGrepId, + }); + const forkId = state.activeForkId; + state = addStage(state, "sort", ["--key", "x"]); + state = addStage(state, "grep", ["true"]); + + const stagesBefore = state.stages.size; + + // Delete the fork + state = dispatch(state, { type: "DELETE_FORK", forkId }); + + // Should return to main fork + expect(state.activeForkId).toBe(mainForkId); + // Fork stages should be removed + expect(state.stages.size).toBe(stagesBefore - 2); + // Main grep should still exist + expect(state.stages.has(mainGrepId)).toBe(true); + }); +}); + +// ── 4. Undo/Redo State Consistency ─────────────────────────────────── + +describe("Undo/redo state consistency", () => { + test("undo after UPDATE_STAGE_ARGS restores original args and can execute", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 5 }), + new Record({ x: 10 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 3"]); + const grepId = getLastStageId(state); + + // Execute with original args + let result = await executeToStage(state, grepId); + expect(result.recordCount).toBe(2); // x=5, x=10 + + // Update args + state = dispatch(state, { + type: "UPDATE_STAGE_ARGS", + stageId: grepId, + args: ["{{x}} > 8"], + }); + + // Clear cache and execute with new args + state = { ...state, cache: new Map() }; + result = await executeToStage(state, grepId); + expect(result.recordCount).toBe(1); // x=10 + + // Undo — should restore original args + state = dispatch(state, { type: "UNDO" }); + expect(state.stages.get(grepId)!.config.args).toEqual(["{{x}} > 3"]); + + // Clear cache and execute — should use original args + state = { ...state, cache: new Map() }; + result = await executeToStage(state, grepId); + expect(result.recordCount).toBe(2); + }); + + test("undo DELETE_STAGE + execute restores correct pipeline behavior", 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"]); + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getLastStageId(state); + + // Execute full pipeline + let result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(2); + + // Delete grep, leaving only sort + const grepId = getStageIds(state)[0]!; + state = dispatch(state, { type: "DELETE_STAGE", stageId: grepId }); + expect(getActivePath(state)).toHaveLength(1); + + // Undo the delete + state = dispatch(state, { type: "UNDO" }); + expect(getActivePath(state)).toHaveLength(2); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "grep", + "sort", + ]); + + // Clear cache and re-execute — pipeline should work as before + state = { ...state, cache: new Map() }; + const allStageIds = getStageIds(state); + result = await executeToStage(state, allStageIds[allStageIds.length - 1]!); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("x")).toBe(2); + expect(result.records[1]!.get("x")).toBe(3); + }); + + test("complex undo/redo sequence: add, modify, delete, undo x3, redo x2", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 5 }), + new Record({ x: 10 }), + ]; + state = addInput(state, records); + + // Step 1: Add grep (undoable) + state = addStage(state, "grep", ["{{x}} > 0"]); + const grepId = getLastStageId(state); + expect(getActivePath(state)).toHaveLength(1); + + // Step 2: Update args (undoable) + state = dispatch(state, { + type: "UPDATE_STAGE_ARGS", + stageId: grepId, + args: ["{{x}} > 3"], + }); + + // Step 3: Add sort (undoable) + state = addStage(state, "sort", ["--key", "x=n"]); + expect(getActivePath(state)).toHaveLength(2); + + // Step 4: Delete sort (undoable) + const sortId = getLastStageId(state); + state = dispatch(state, { type: "DELETE_STAGE", stageId: sortId }); + expect(getActivePath(state)).toHaveLength(1); + + // Undo x3: undo delete → undo add-sort → undo update-args + state = dispatch(state, { type: "UNDO" }); // restore sort + expect(getActivePath(state)).toHaveLength(2); + + state = dispatch(state, { type: "UNDO" }); // undo add sort + expect(getActivePath(state)).toHaveLength(1); + + state = dispatch(state, { type: "UNDO" }); // undo update args + expect(state.stages.get(grepId)!.config.args).toEqual(["{{x}} > 0"]); + + // Redo x2: redo update-args → redo add-sort + state = dispatch(state, { type: "REDO" }); // update args + expect(state.stages.get(grepId)!.config.args).toEqual(["{{x}} > 3"]); + + state = dispatch(state, { type: "REDO" }); // add sort back + expect(getActivePath(state)).toHaveLength(2); + + // Execute from this restored state + state = { ...state, cache: new Map() }; + const lastId = getLastStageId(state); + const result = await executeToStage(state, lastId); + expect(result.recordCount).toBe(2); // x=5, x=10 (x > 3, sorted ascending) + expect(result.records[0]!.get("x")).toBe(5); + expect(result.records[1]!.get("x")).toBe(10); + }); + + test("undo TOGGLE_STAGE re-enables and gives correct results", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 5 }), + new Record({ x: 10 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 3"]); + const grepId = getLastStageId(state); + state = addStage(state, "sort", ["--key", "x=n"]); + const sortId = getLastStageId(state); + + // Toggle grep off + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: grepId }); + expect(state.stages.get(grepId)!.config.enabled).toBe(false); + + // Execute — all 3 records pass through + let result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(3); + + // Undo the toggle — grep re-enabled + state = dispatch(state, { type: "UNDO" }); + expect(state.stages.get(grepId)!.config.enabled).toBe(true); + + // Execute — only x=5, x=10 pass + state = { ...state, cache: new Map() }; + result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(2); + }); + + test("undo REORDER_STAGE restores original order and execution", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 2 }), + new Record({ x: 3 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 1"]); + state = addStage(state, "sort", ["--key", "x=-n"]); + + const sortId = getActivePath(state)[1]!.id; + + // Reorder: move sort up (before grep) + state = dispatch(state, { + type: "REORDER_STAGE", + stageId: sortId, + direction: "up", + }); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "sort", + "grep", + ]); + + // Undo reorder + state = dispatch(state, { type: "UNDO" }); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "grep", + "sort", + ]); + + // Execute — grep then sort + state = { ...state, cache: new Map() }; + const lastId = getLastStageId(state); + const result = await executeToStage(state, lastId); + expect(result.recordCount).toBe(2); // x > 1 → x=2,3, sorted desc + expect(result.records[0]!.get("x")).toBe(3); + expect(result.records[1]!.get("x")).toBe(2); + }); +}); + +// ── 5. Session Save/Load Roundtrip ─────────────────────────────────── + +describe("Session save/load comprehensive roundtrip", () => { + let tempDir: string; + let manager: SessionManager; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "recs-e2e-session-")); + manager = new SessionManager(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("complex state with forks, inputs, and pinned stages survives roundtrip", async () => { + let state = createInitialState(); + + // Add file input + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: "/tmp/access.log" }, + label: "access.log", + }); + + // Add stdin input + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [new Record({ x: 1 })] }, + label: "piped-data", + }); + // Build pipeline + state = addStage(state, "grep", ["{{status}} > 200"]); + state = addStage(state, "sort", ["--key", "time=n"]); + const sortId = getLastStageId(state); + state = addStage(state, "collate", ["--key", "host", "-a", "count"]); + + // Pin a stage + state = dispatch(state, { type: "PIN_STAGE", stageId: sortId }); + + // Create a fork + state = dispatch(state, { + type: "CREATE_FORK", + name: "alt-analysis", + atStageId: sortId, + }); + + // Set session name + state = dispatch(state, { type: "SET_SESSION_NAME", name: "log analysis v2" }); + + // Set view mode + state = dispatch(state, { type: "SET_VIEW_MODE", viewMode: "json" }); + + // Save + await manager.save(state); + + // Load and hydrate + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + // Verify stages + expect(hydrated.stages.size).toBe(state.stages.size); + for (const [id, stage] of state.stages) { + const restored = hydrated.stages.get(id); + expect(restored).toBeDefined(); + expect(restored!.config.operationName).toBe(stage.config.operationName); + expect(restored!.config.args).toEqual(stage.config.args); + expect(restored!.config.enabled).toBe(stage.config.enabled); + } + + // Verify forks + expect(hydrated.forks.size).toBe(state.forks.size); + const forkNames = Array.from(hydrated.forks.values()).map((f) => f.name); + expect(forkNames).toContain("main"); + expect(forkNames).toContain("alt-analysis"); + + // Verify inputs + expect(hydrated.inputs.size).toBe(state.inputs.size); + + // Verify session name + expect(hydrated.sessionName).toBe("log analysis v2"); + + // Verify pinned stages + expect(hydrated.cacheConfig.pinnedStageIds.has(sortId)).toBe(true); + }); + + test("saveAs creates new session with different ID", async () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + state = addStage(state, "grep", ["{{x}} > 0"]); + state = dispatch(state, { type: "SET_SESSION_NAME", name: "original" }); + + await manager.save(state); + + const newSessionId = await manager.saveAs(state, "copy of analysis"); + + expect(newSessionId).not.toBe(state.sessionId); + + // Both sessions should be loadable + const original = await manager.load(state.sessionId); + const copy = await manager.load(newSessionId); + + expect(original.name).toBe("original"); + expect(copy.name).toBe("copy of analysis"); + }); + + test("session list returns sessions sorted by last accessed", async () => { + // Create 3 sessions with different times + for (let i = 0; i < 3; i++) { + let state = createInitialState(); + state = dispatch(state, { type: "SET_SESSION_NAME", name: `session-${i}` }); + await manager.save(state); + // Small delay to ensure different timestamps + await new Promise((r) => setTimeout(r, 10)); + } + + const sessions = await manager.list(); + expect(sessions).toHaveLength(3); + // Most recent first + expect(sessions[0]!.name).toBe("session-2"); + expect(sessions[2]!.name).toBe("session-0"); + }); + + test("session delete removes session from disk", async () => { + let state = createInitialState(); + state = dispatch(state, { type: "SET_SESSION_NAME", name: "to-delete" }); + await manager.save(state); + + let sessions = await manager.list(); + expect(sessions).toHaveLength(1); + + await manager.delete(state.sessionId); + + sessions = await manager.list(); + expect(sessions).toHaveLength(0); + }); + + test("rename updates session name on disk", async () => { + let state = createInitialState(); + state = dispatch(state, { type: "SET_SESSION_NAME", name: "old-name" }); + await manager.save(state); + + await manager.rename(state.sessionId, "new-name"); + + const loaded = await manager.load(state.sessionId); + expect(loaded.name).toBe("new-name"); + }); + + test("hydrated state with undo stack can still undo/redo correctly", async () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + state = addStage(state, "grep", ["{{x}} > 0"]); + state = addStage(state, "sort", ["--key", "x"]); + state = addStage(state, "collate", ["--key", "x", "-a", "count"]); + + // Undo once (remove collate) + state = dispatch(state, { type: "UNDO" }); + expect(getActivePath(state)).toHaveLength(2); + expect(state.undoStack).toHaveLength(3); // add_input, add_grep, add_sort + expect(state.redoStack).toHaveLength(1); // add_collate + + // Save and load + await manager.save(state); + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + expect(hydrated.undoStack).toHaveLength(3); + expect(hydrated.redoStack).toHaveLength(1); + + // Redo should restore collate + const afterRedo = dispatch(hydrated, { type: "REDO" }); + expect(getActivePath(afterRedo)).toHaveLength(3); + expect(getActivePath(afterRedo)[2]!.config.operationName).toBe("collate"); + + // Undo should remove collate again + const afterUndo = dispatch(afterRedo, { type: "UNDO" }); + expect(getActivePath(afterUndo)).toHaveLength(2); + }); +}); + +// ── 6. Multiple Input Sources ──────────────────────────────────────── + +describe("Multiple input sources", () => { + test("same pipeline, different inputs produce different results", async () => { + let state = createInitialState(); + + // Input 1: scores > 50 + const input1Records = [ + new Record({ name: "Alice", score: 90 }), + new Record({ name: "Bob", score: 30 }), + ]; + state = addInput(state, input1Records, "high-scores"); + const input1Id = state.activeInputId; + + // Input 2: all low scores + const input2Records = [ + new Record({ name: "Charlie", score: 20 }), + new Record({ name: "Dave", score: 10 }), + ]; + state = addInput(state, input2Records, "low-scores"); + + // Add grep that filters score > 50 + state = addStage(state, "grep", ["{{score}} > 50"]); + const grepId = getLastStageId(state); + + // Execute with input 2 (active — all low scores) + let result = await executeToStage(state, grepId); + expect(result.recordCount).toBe(0); // no scores > 50 + + // Switch to input 1 + state = dispatch(state, { type: "SWITCH_INPUT", inputId: input1Id }); + expect(state.activeInputId).toBe(input1Id); + + // Execute with input 1 + result = await executeToStage(state, grepId); + expect(result.recordCount).toBe(1); // Alice's 90 + expect(result.records[0]!.get("name")).toBe("Alice"); + }); + + test("SWITCH_INPUT preserves pipeline stages", () => { + let state = createInitialState(); + + state = addInput(state, [new Record({ x: 1 })], "input-1"); + const input1Id = state.activeInputId; + state = addInput(state, [new Record({ x: 2 })], "input-2"); + + state = addStage(state, "grep", ["{{x}} > 0"]); + state = addStage(state, "sort", ["--key", "x"]); + + expect(getActivePath(state)).toHaveLength(2); + + // Switch inputs — stages should remain + state = dispatch(state, { type: "SWITCH_INPUT", inputId: input1Id }); + expect(getActivePath(state)).toHaveLength(2); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "grep", + "sort", + ]); + }); + + test("REMOVE_INPUT switches to remaining input", () => { + let state = createInitialState(); + + state = addInput(state, [new Record({ x: 1 })], "input-1"); + const input1Id = state.activeInputId; + state = addInput(state, [new Record({ x: 2 })], "input-2"); + const input2Id = state.activeInputId; + + expect(state.inputs.size).toBe(2); + + // Remove active input + state = dispatch(state, { type: "REMOVE_INPUT", inputId: input2Id }); + expect(state.inputs.size).toBe(1); + expect(state.activeInputId).toBe(input1Id); + }); + + test("cache is keyed per input — switching inputs doesn't pollute cache", async () => { + let state = createInitialState(); + + const records1 = [new Record({ x: 10 })]; + state = addInput(state, records1, "input-1"); + const input1Id = state.activeInputId; + + const records2 = [new Record({ x: 20 })]; + state = addInput(state, records2, "input-2"); + const input2Id = state.activeInputId; + + state = addStage(state, "grep", ["{{x}} > 0"]); + const grepId = getLastStageId(state); + + // Execute with input 2 + await executeToStage(state, grepId); + expect(state.cache.has(`${input2Id}:${grepId}`)).toBe(true); + expect(state.cache.has(`${input1Id}:${grepId}`)).toBe(false); + + // Switch to input 1 and execute + state = dispatch(state, { type: "SWITCH_INPUT", inputId: input1Id }); + await executeToStage(state, grepId); + + // Both should now be cached with different keys + expect(state.cache.has(`${input1Id}:${grepId}`)).toBe(true); + expect(state.cache.has(`${input2Id}:${grepId}`)).toBe(true); + + // Verify they have different results + const cache1 = state.cache.get(`${input1Id}:${grepId}`)!; + const cache2 = state.cache.get(`${input2Id}:${grepId}`)!; + expect(cache1.records[0]!.get("x")).toBe(10); + expect(cache2.records[0]!.get("x")).toBe(20); + }); +}); + +// ── 7. All View Modes ──────────────────────────────────────────────── + +describe("All view modes", () => { + test("SET_VIEW_MODE cycles through all valid modes", () => { + let state = createInitialState(); + expect(state.inspector.viewMode).toBe("table"); + + state = dispatch(state, { type: "SET_VIEW_MODE", viewMode: "prettyprint" }); + expect(state.inspector.viewMode).toBe("prettyprint"); + + state = dispatch(state, { type: "SET_VIEW_MODE", viewMode: "json" }); + expect(state.inspector.viewMode).toBe("json"); + + state = dispatch(state, { type: "SET_VIEW_MODE", viewMode: "schema" }); + expect(state.inspector.viewMode).toBe("schema"); + + state = dispatch(state, { type: "SET_VIEW_MODE", viewMode: "table" }); + expect(state.inspector.viewMode).toBe("table"); + }); + + test("view mode is independent of pipeline state", async () => { + let state = createInitialState(); + const records = [new Record({ x: 1 })]; + state = addInput(state, records); + + state = addStage(state, "grep", ["{{x}} > 0"]); + const grepId = getLastStageId(state); + + // Change view mode + state = dispatch(state, { type: "SET_VIEW_MODE", viewMode: "json" }); + + // Execute — view mode should not affect execution + const result = await executeToStage(state, grepId); + expect(result.recordCount).toBe(1); + expect(state.inspector.viewMode).toBe("json"); + }); + + test("TOGGLE_FOCUS switches between pipeline and inspector", () => { + let state = createInitialState(); + expect(state.focusedPanel).toBe("pipeline"); + + state = dispatch(state, { type: "TOGGLE_FOCUS" }); + expect(state.focusedPanel).toBe("inspector"); + + state = dispatch(state, { type: "TOGGLE_FOCUS" }); + expect(state.focusedPanel).toBe("pipeline"); + }); + + test("column highlight navigation works correctly", () => { + let state = createInitialState(); + + // No highlight initially + expect(state.inspector.highlightedColumn).toBeNull(); + + // Move right from null → first column (0) + state = dispatch(state, { + type: "MOVE_COLUMN_HIGHLIGHT", + direction: "right", + fieldCount: 5, + }); + expect(state.inspector.highlightedColumn).toBe(0); + + // Move right + state = dispatch(state, { + type: "MOVE_COLUMN_HIGHLIGHT", + direction: "right", + fieldCount: 5, + }); + expect(state.inspector.highlightedColumn).toBe(1); + + // Move left + state = dispatch(state, { + type: "MOVE_COLUMN_HIGHLIGHT", + direction: "left", + fieldCount: 5, + }); + expect(state.inspector.highlightedColumn).toBe(0); + + // Move left at 0 — clamp + state = dispatch(state, { + type: "MOVE_COLUMN_HIGHLIGHT", + direction: "left", + fieldCount: 5, + }); + expect(state.inspector.highlightedColumn).toBe(0); + + // Clear + state = dispatch(state, { type: "CLEAR_COLUMN_HIGHLIGHT" }); + expect(state.inspector.highlightedColumn).toBeNull(); + }); + + test("column highlight from null going left wraps to last column", () => { + let state = createInitialState(); + + state = dispatch(state, { + type: "MOVE_COLUMN_HIGHLIGHT", + direction: "left", + fieldCount: 5, + }); + expect(state.inspector.highlightedColumn).toBe(4); // max index + }); +}); + +// ── 8. Export Edge Cases ───────────────────────────────────────────── + +describe("Export edge cases", () => { + test("export with all stages disabled produces minimal output", () => { + let state = createInitialState(); + state = addInput(state, []); + + state = addStage(state, "grep", ["{{x}} > 1"]); + const grepId = getLastStageId(state); + state = addStage(state, "sort", ["--key", "x"]); + const sortId = getLastStageId(state); + + // Disable all stages + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: grepId }); + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: sortId }); + + const script = exportAsPipeScript(state); + expect(script).toBe("#!/usr/bin/env bash\n"); + + const chain = exportAsChainCommand(state); + expect(chain).toBe("recs chain"); + }); + + test("export preserves complex args with special characters", () => { + let state = createInitialState(); + state = addInput(state, []); + + state = addStage(state, "xform", ["{{label}} = 'hello world'"]); + state = addStage(state, "grep", ["{{value}} > 100 && {{active}} == true"]); + + const script = exportAsPipeScript(state); + expect(script).toContain("recs xform"); + expect(script).toContain("recs grep"); + // The special chars should be escaped but present + expect(script).toContain("hello world"); + expect(script).toContain("100"); + }); + + test("export chain command with single stage has no pipe separator", () => { + let state = createInitialState(); + state = addInput(state, []); + + state = addStage(state, "sort", ["--key", "x=n"]); + + const chain = exportAsChainCommand(state); + expect(chain).toBe("recs chain sort --key x=n"); + expect(chain).not.toContain("\\|"); + }); + + test("export with mixed enabled/disabled stages only includes enabled", () => { + let state = createInitialState(); + state = addInput(state, []); + + state = addStage(state, "grep", ["{{x}} > 1"]); + state = addStage(state, "sort", ["--key", "x"]); + const sortId = getLastStageId(state); + state = addStage(state, "collate", ["--key", "x", "-a", "count"]); + state = addStage(state, "totable", []); + + // Disable sort and collate + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: sortId }); + const collateId = getActivePath(state)[2]!.id; + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: collateId }); + + const chain = exportAsChainCommand(state); + expect(chain).toContain("grep"); + expect(chain).not.toContain("sort"); + expect(chain).not.toContain("collate"); + expect(chain).toContain("totable"); + }); +}); + +// ── 9. Selectors Comprehensive Tests ───────────────────────────────── + +describe("Selectors comprehensive", () => { + test("getDownstreamStages returns correct stages", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "grep", ["{{x}} > 0"]); + state = addStage(state, "sort", ["--key", "x"]); + state = addStage(state, "collate", ["--key", "x", "-a", "count"]); + state = addStage(state, "totable", []); + + const stageIds = getStageIds(state); + const downstream = getDownstreamStages(state, stageIds[0]!); + + expect(downstream).toHaveLength(3); + expect(downstream.map((s) => s.config.operationName)).toEqual([ + "sort", + "collate", + "totable", + ]); + }); + + test("getStageKind classifies operations correctly", () => { + expect(getStageKind("grep")).toBe("filter"); + expect(getStageKind("sort")).toBe("reorder"); + expect(getStageKind("collate")).toBe("aggregate"); + expect(getStageKind("substream")).toBe("aggregate"); + expect(getStageKind("xform")).toBe("transform"); + expect(getStageKind("totable")).toBe("transform"); + expect(getStageKind("fromcsv")).toBe("input"); + expect(getStageKind("fromjsonarray")).toBe("input"); + }); + + test("getStageDelta computes field changes correctly", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1, y: 2 }), + new Record({ x: 3, y: 4 }), + ]; + state = addInput(state, records); + + state = addStage(state, "xform", ["{{z}} = {{x}} + {{y}}"]); + const xformId = getLastStageId(state); + + // Execute to populate cache + await executeToStage(state, xformId); + + // Manually cache the "input stage" result to simulate parent + // We need the parent of xform to exist in cache for delta to work + // But xform has no parent stage — its input comes from the input source + // So delta should have parentCount = null + const delta = getStageDelta(state, xformId); + expect(delta).toBeDefined(); + expect(delta!.kind).toBe("transform"); + expect(delta!.outputCount).toBe(2); + // No parent stage, so parentCount is null + expect(delta!.parentCount).toBeNull(); + }); + + test("getCursorStage and getCursorOutput return correct values", async () => { + let state = createInitialState(); + state = addInput(state, [ + new Record({ x: 1 }), + new Record({ x: 2 }), + ]); + + state = addStage(state, "grep", ["{{x}} > 0"]); + const grepId = getLastStageId(state); + + // Cursor should be on the grep + const cursor = getCursorStage(state); + expect(cursor).toBeDefined(); + expect(cursor!.config.operationName).toBe("grep"); + + // No cached output yet + expect(getCursorOutput(state)).toBeUndefined(); + + // Execute and cache + const result = await executeToStage(state, grepId); + state = dispatch(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: grepId, + result, + }); + + // Now cursor output should be available + const cursorOutput = getCursorOutput(state); + expect(cursorOutput).toBeDefined(); + expect(cursorOutput!.recordCount).toBe(2); + }); + + test("getTotalCacheSize sums all cache entries", async () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "grep", ["{{x}} > 0"]); + state = addStage(state, "sort", ["--key", "x"]); + + const stageIds = getStageIds(state); + await executeToStage(state, stageIds[stageIds.length - 1]!); + + const totalSize = getTotalCacheSize(state); + expect(totalSize).toBeGreaterThan(0); + + // Manually verify it matches sum + let manualSum = 0; + for (const entry of state.cache.values()) { + manualSum += entry.sizeBytes; + } + expect(totalSize).toBe(manualSum); + }); +}); + +// ── 10. Cursor Movement ────────────────────────────────────────────── + +describe("Cursor movement", () => { + test("MOVE_CURSOR navigates through stages", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "grep", ["{{x}} > 0"]); + state = addStage(state, "sort", ["--key", "x"]); + state = addStage(state, "totable", []); + + const stageIds = getStageIds(state); + + // Cursor should be on the last added stage + expect(state.cursorStageId).toBe(stageIds[2]!); + + // Move up + state = dispatch(state, { type: "MOVE_CURSOR", direction: "up" }); + expect(state.cursorStageId).toBe(stageIds[1]!); + + state = dispatch(state, { type: "MOVE_CURSOR", direction: "up" }); + expect(state.cursorStageId).toBe(stageIds[0]!); + + // Move up at top — should clamp + state = dispatch(state, { type: "MOVE_CURSOR", direction: "up" }); + expect(state.cursorStageId).toBe(stageIds[0]!); + + // Move down + state = dispatch(state, { type: "MOVE_CURSOR", direction: "down" }); + expect(state.cursorStageId).toBe(stageIds[1]!); + + // Move down to end + state = dispatch(state, { type: "MOVE_CURSOR", direction: "down" }); + state = dispatch(state, { type: "MOVE_CURSOR", direction: "down" }); + expect(state.cursorStageId).toBe(stageIds[2]!); + + // Move down at bottom — should clamp + state = dispatch(state, { type: "MOVE_CURSOR", direction: "down" }); + expect(state.cursorStageId).toBe(stageIds[2]!); + }); + + test("SET_CURSOR jumps to a specific stage", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "grep", ["{{x}} > 0"]); + state = addStage(state, "sort", ["--key", "x"]); + state = addStage(state, "totable", []); + + const stageIds = getStageIds(state); + + state = dispatch(state, { type: "SET_CURSOR", stageId: stageIds[0]! }); + expect(state.cursorStageId).toBe(stageIds[0]!); + }); + + test("DELETE_STAGE moves cursor to neighbor", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const stageIds = getStageIds(state); + + // Delete middle stage (b) + state = dispatch(state, { type: "DELETE_STAGE", stageId: stageIds[1]! }); + + // Cursor should move to the stage at the same index or the last one + expect(state.cursorStageId).not.toBeNull(); + const remainingOps = getActivePath(state).map((s) => s.config.operationName); + expect(remainingOps).toEqual(["a", "c"]); + }); + + test("DELETE_STAGE of last stage moves cursor to previous", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "a"); + state = addStage(state, "b"); + + const stageIds = getStageIds(state); + + // Delete last stage (b) + state = dispatch(state, { type: "DELETE_STAGE", stageId: stageIds[1]! }); + expect(state.cursorStageId).toBe(stageIds[0]!); + }); + + test("DELETE_STAGE of only stage sets cursor to null", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "a"); + const stageIds = getStageIds(state); + + state = dispatch(state, { type: "DELETE_STAGE", stageId: stageIds[0]! }); + expect(state.cursorStageId).toBeNull(); + expect(getActivePath(state)).toHaveLength(0); + }); +}); + +// ── 11. Error State Management ─────────────────────────────────────── + +describe("Error state management", () => { + test("SET_ERROR and CLEAR_ERROR work correctly", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + state = addStage(state, "grep", ["bad"]); + const grepId = getLastStageId(state); + + expect(state.lastError).toBeNull(); + + state = dispatch(state, { + type: "SET_ERROR", + stageId: grepId, + message: "Parse error in expression", + }); + + expect(state.lastError).not.toBeNull(); + expect(state.lastError!.stageId).toBe(grepId); + expect(state.lastError!.message).toBe("Parse error in expression"); + + state = dispatch(state, { type: "CLEAR_ERROR" }); + expect(state.lastError).toBeNull(); + }); + + test("deleting the error stage clears the error", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + state = addStage(state, "grep", ["bad"]); + const grepId = getLastStageId(state); + + state = dispatch(state, { + type: "SET_ERROR", + stageId: grepId, + message: "Error", + }); + expect(state.lastError).not.toBeNull(); + + state = dispatch(state, { type: "DELETE_STAGE", stageId: grepId }); + expect(state.lastError).toBeNull(); + }); + + test("isDownstreamOfError correctly identifies downstream stages", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + const stageIds = getStageIds(state); + + // Set error on stage b + state = dispatch(state, { + type: "SET_ERROR", + stageId: stageIds[1]!, + message: "Error", + }); + + expect(isDownstreamOfError(state, stageIds[0]!)).toBe(false); // upstream + expect(isDownstreamOfError(state, stageIds[1]!)).toBe(false); // error stage itself + expect(isDownstreamOfError(state, stageIds[2]!)).toBe(true); // downstream + }); +}); + +// ── 12. Execution State ────────────────────────────────────────────── + +describe("Execution state", () => { + test("SET_EXECUTING toggles executing flag", () => { + let state = createInitialState(); + + expect(state.executing).toBe(false); + + state = dispatch(state, { type: "SET_EXECUTING", executing: true }); + expect(state.executing).toBe(true); + + state = dispatch(state, { type: "SET_EXECUTING", executing: false }); + expect(state.executing).toBe(false); + }); +}); + +// ── 13. Cache Policy Integration ───────────────────────────────────── + +describe("Cache policy integration", () => { + test("PIN_STAGE toggles pin status", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + state = addStage(state, "grep", ["{{x}} > 0"]); + const grepId = getLastStageId(state); + + expect(state.cacheConfig.pinnedStageIds.has(grepId)).toBe(false); + + // Pin + state = dispatch(state, { type: "PIN_STAGE", stageId: grepId }); + expect(state.cacheConfig.pinnedStageIds.has(grepId)).toBe(true); + + // Unpin + state = dispatch(state, { type: "PIN_STAGE", stageId: grepId }); + expect(state.cacheConfig.pinnedStageIds.has(grepId)).toBe(false); + }); + + test("SET_CACHE_POLICY changes policy", () => { + let state = createInitialState(); + + expect(state.cacheConfig.cachePolicy).toBe("all"); + + state = dispatch(state, { type: "SET_CACHE_POLICY", policy: "selective" }); + expect(state.cacheConfig.cachePolicy).toBe("selective"); + + state = dispatch(state, { type: "SET_CACHE_POLICY", policy: "none" }); + expect(state.cacheConfig.cachePolicy).toBe("none"); + + state = dispatch(state, { type: "SET_CACHE_POLICY", policy: "all" }); + expect(state.cacheConfig.cachePolicy).toBe("all"); + }); +}); + +// ── 14. Noop Guards ────────────────────────────────────────────────── + +describe("Noop guards", () => { + test("DELETE_STAGE on nonexistent stage is noop (no undo entry)", () => { + let state = createInitialState(); + const undoCountBefore = state.undoStack.length; + + state = dispatch(state, { type: "DELETE_STAGE", stageId: "nonexistent" }); + expect(state.undoStack.length).toBe(undoCountBefore); + }); + + test("UPDATE_STAGE_ARGS on nonexistent stage is noop", () => { + let state = createInitialState(); + const undoCountBefore = state.undoStack.length; + + state = dispatch(state, { + type: "UPDATE_STAGE_ARGS", + stageId: "nonexistent", + args: ["new-args"], + }); + expect(state.undoStack.length).toBe(undoCountBefore); + }); + + test("TOGGLE_STAGE on nonexistent stage is noop", () => { + let state = createInitialState(); + const before = state; + + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: "nonexistent" }); + expect(state).toBe(before); + }); + + test("REORDER_STAGE at boundary is noop", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + state = addStage(state, "a"); + + const stageId = getLastStageId(state); + const undoCountBefore = state.undoStack.length; + + // Try to move up when already at top + state = dispatch(state, { + type: "REORDER_STAGE", + stageId, + direction: "up", + }); + expect(state.undoStack.length).toBe(undoCountBefore); + }); + + test("REMOVE_INPUT when only one input is noop", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })], "only-input"); + const inputId = state.activeInputId; + const undoCountBefore = state.undoStack.length; + + state = dispatch(state, { type: "REMOVE_INPUT", inputId }); + expect(state.undoStack.length).toBe(undoCountBefore); + expect(state.inputs.size).toBe(1); + }); +}); diff --git a/tests/tui/integration/pipeline-flow.test.ts b/tests/explorer/integration/pipeline-flow.test.ts similarity index 97% rename from tests/tui/integration/pipeline-flow.test.ts rename to tests/explorer/integration/pipeline-flow.test.ts index efd26db..ead5a5a 100644 --- a/tests/tui/integration/pipeline-flow.test.ts +++ b/tests/explorer/integration/pipeline-flow.test.ts @@ -1,5 +1,5 @@ /** - * Integration tests for the TUI pipeline flow. + * Integration tests for the Explorer pipeline flow. * * These tests exercise the real reducer, real executor, and real operations * together to verify the full lifecycle works correctly. @@ -10,22 +10,22 @@ import { Record } from "../../../src/Record.ts"; import { createInitialState, pipelineReducer, -} from "../../../src/tui/model/reducer.ts"; -import { executeToStage } from "../../../src/tui/executor/executor.ts"; +} from "../../../src/explorer/model/reducer.ts"; +import { executeToStage } from "../../../src/explorer/executor/executor.ts"; import { getActivePath, getEnabledStages, isDownstreamOfError, -} from "../../../src/tui/model/selectors.ts"; +} from "../../../src/explorer/model/selectors.ts"; import { exportAsPipeScript, exportAsChainCommand, -} from "../../../src/tui/model/serialization.ts"; +} from "../../../src/explorer/model/serialization.ts"; import type { PipelineState, PipelineAction, StageConfig, -} from "../../../src/tui/model/types.ts"; +} from "../../../src/explorer/model/types.ts"; // ── Helpers ────────────────────────────────────────────────────────── @@ -432,7 +432,7 @@ describe("Large pipeline", () => { 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`; + const tmpFile = `/tmp/recs-explorer-integration-${Date.now()}.csv`; await Bun.write(tmpFile, csvContent); let state = createInitialState(); diff --git a/tests/explorer/integration/smoke.test.ts b/tests/explorer/integration/smoke.test.ts new file mode 100644 index 0000000..844bf54 --- /dev/null +++ b/tests/explorer/integration/smoke.test.ts @@ -0,0 +1,868 @@ +/** + * Smoke / integration tests for the Explorer. + * + * These tests verify end-to-end flows that cross module boundaries: + * state management → executor → cache → serialization → session persistence. + * They are designed to catch wiring bugs that unit tests miss (e.g., a hook + * not calling executeToStage, or ADD_STAGE not chaining to editStage). + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { Record } from "../../../src/Record.ts"; +import { + createInitialState, + pipelineReducer, +} from "../../../src/explorer/model/reducer.ts"; +import { executeToStage } from "../../../src/explorer/executor/executor.ts"; +import { + getActivePath, + getCursorStage, + getStageOutput, +} from "../../../src/explorer/model/selectors.ts"; +import { + exportAsPipeScript, + exportAsChainCommand, +} from "../../../src/explorer/model/serialization.ts"; +import { detectInputOperation } from "../../../src/explorer/utils/file-detect.ts"; +import { SessionManager } from "../../../src/explorer/session/session-manager.ts"; +import type { + PipelineState, + PipelineAction, + StageConfig, +} from "../../../src/explorer/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. App initialisation smoke ────────────────────────────────────── + +describe("App initialisation smoke", () => { + test("createInitialState produces a valid empty state", () => { + const state = createInitialState(); + + expect(state.stages).toBeInstanceOf(Map); + expect(state.stages.size).toBe(0); + expect(state.forks).toBeInstanceOf(Map); + expect(state.forks.size).toBe(1); // "main" fork + expect(state.cache).toBeInstanceOf(Map); + expect(state.cursorStageId).toBeNull(); + expect(state.focusedPanel).toBe("pipeline"); + expect(state.executing).toBe(false); + expect(state.lastError).toBeNull(); + expect(state.undoStack).toHaveLength(0); + expect(state.redoStack).toHaveLength(0); + expect(state.sessionId).toBeTruthy(); + }); + + test("adding input and stage produces executable state", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 1 }), + new Record({ x: 2 }), + new Record({ x: 3 }), + ]; + state = addInput(state, records); + state = addStage(state, "grep", ["{{x}} > 1"]); + + expect(state.inputs.size).toBe(1); + expect(state.stages.size).toBe(1); + expect(state.cursorStageId).not.toBeNull(); + + const result = await executeToStage(state, state.cursorStageId!); + expect(result.recordCount).toBe(2); + }); +}); + +// ── 2. Pipeline execution actually runs ────────────────────────────── + +describe("Pipeline execution end-to-end", () => { + test("state → executor → cache: full pipeline lifecycle", async () => { + let state = createInitialState(); + const records = [ + new Record({ name: "Alice", score: 90 }), + new Record({ name: "Bob", score: 60 }), + new Record({ name: "Charlie", score: 85 }), + new Record({ name: "Dave", score: 70 }), + ]; + state = addInput(state, records); + + // Build pipeline: grep (score > 70) → sort (score desc) → xform (add rank) + state = addStage(state, "grep", ["{{score}} > 70"]); + const grepId = getLastStageId(state); + + state = addStage(state, "sort", ["--key", "score=-n"]); + const sortId = getLastStageId(state); + + // Execute to final stage + const result = await executeToStage(state, sortId); + + // Verify correct result + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("name")).toBe("Alice"); + expect(result.records[0]!.get("score")).toBe(90); + expect(result.records[1]!.get("name")).toBe("Charlie"); + expect(result.records[1]!.get("score")).toBe(85); + + // Verify caching happened for both stages + expect(state.cache.has(`${state.activeInputId}:${grepId}`)).toBe(true); + expect(state.cache.has(`${state.activeInputId}:${sortId}`)).toBe(true); + + // Verify the cached grep result has intermediate data + const grepCached = state.cache.get(`${state.activeInputId}:${grepId}`)!; + expect(grepCached.recordCount).toBe(2); + expect(grepCached.fieldNames).toContain("name"); + expect(grepCached.fieldNames).toContain("score"); + }); + + test("useExecution would trigger: cache miss leads to execution", async () => { + // Simulate what useExecution does on a cache miss: + // 1. Check cache for cursorStageId → miss + // 2. Call executeToStage + // 3. Dispatch CACHE_RESULT + let state = createInitialState(); + const records = [new Record({ val: 10 }), new Record({ val: 20 })]; + state = addInput(state, records); + state = addStage(state, "grep", ["{{val}} > 15"]); + + const cursorId = state.cursorStageId!; + const cacheKey = `${state.activeInputId}:${cursorId}`; + + // Verify cache miss + expect(state.cache.has(cacheKey)).toBe(false); + + // Execute (what useExecution would do) + const result = await executeToStage(state, cursorId); + + // Dispatch CACHE_RESULT (what useExecution would do) + state = dispatch(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: cursorId, + result, + }); + + // Verify cache now populated + expect(state.cache.has(cacheKey)).toBe(true); + expect(getStageOutput(state, cursorId)!.recordCount).toBe(1); + expect(getStageOutput(state, cursorId)!.records[0]!.get("val")).toBe(20); + }); +}); + +// ── 3. File type auto-detection → execution ────────────────────────── + +describe("File type auto-detection → execution", () => { + test(".csv file triggers fromcsv stage insertion", () => { + const config = detectInputOperation("/tmp/data.csv"); + expect(config).not.toBeNull(); + expect(config!.operationName).toBe("fromcsv"); + expect(config!.args).toContain("--header"); + expect(config!.enabled).toBe(true); + }); + + test(".tsv file triggers fromcsv with tab delimiter", () => { + const config = detectInputOperation("/tmp/data.tsv"); + expect(config).not.toBeNull(); + expect(config!.operationName).toBe("fromcsv"); + expect(config!.args).toContain("--delim"); + }); + + test(".xml file triggers fromxml stage", () => { + const config = detectInputOperation("/tmp/data.xml"); + expect(config).not.toBeNull(); + expect(config!.operationName).toBe("fromxml"); + }); + + test(".jsonl returns null (native format)", () => { + expect(detectInputOperation("/tmp/data.jsonl")).toBeNull(); + }); + + test(".json returns null (native format)", () => { + expect(detectInputOperation("/tmp/data.json")).toBeNull(); + }); + + test("auto-detected stage integrates into pipeline", async () => { + // Simulate what App.tsx does on startup with a CSV file + const csvContent = "name,age\nAlice,30\nBob,25\n"; + const tmpFile = `/tmp/recs-smoke-csv-${Date.now()}.csv`; + await Bun.write(tmpFile, csvContent); + + let state = createInitialState(); + + // Step 1: Add file input (what App does) + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: tmpFile }, + label: "data.csv", + }); + + // Step 2: Auto-detect and add stage (what App does) + const autoStage = detectInputOperation(tmpFile); + expect(autoStage).not.toBeNull(); + state = dispatch(state, { + type: "ADD_STAGE", + afterStageId: null, + config: autoStage!, + }); + + // Step 3: Add a downstream stage + state = addStage(state, "sort", ["--key", "name"]); + + // Verify pipeline structure + const path = getActivePath(state); + expect(path).toHaveLength(2); + expect(path[0]!.config.operationName).toBe("fromcsv"); + expect(path[1]!.config.operationName).toBe("sort"); + + // Step 4: Execute the pipeline + const sortId = getLastStageId(state); + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("name")).toBe("Alice"); + expect(result.records[1]!.get("name")).toBe("Bob"); + + // Cleanup + const fs = await import("node:fs"); + fs.unlinkSync(tmpFile); + }); +}); + +// ── 4. Add stage → edit flow ───────────────────────────────────────── + +describe("Add stage → edit flow (C1 fix)", () => { + test("ADD_STAGE sets cursor to new stage for edit chaining", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + // Add first stage + state = addStage(state, "grep", ["{{x}} > 0"]); + const firstId = state.cursorStageId; + expect(firstId).not.toBeNull(); + + // Add second stage — cursor should move to it + state = addStage(state, "sort", ["--key", "x"]); + const secondId = state.cursorStageId; + expect(secondId).not.toBeNull(); + expect(secondId).not.toBe(firstId); + + // The cursor stage should be the sort we just added + const cursorStage = getCursorStage(state); + expect(cursorStage).toBeDefined(); + expect(cursorStage!.config.operationName).toBe("sort"); + }); + + test("ADD_STAGE → modal transitions: add then immediately edit", () => { + // Simulate what handleAddStageSelect does in App.tsx: + // 1. Dispatch ADD_STAGE with blank args + // 2. Set modal to editStage + // 3. On submit, dispatch UPDATE_STAGE_ARGS + + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + // Step 1: User picks "grep" from AddStageModal + const config: StageConfig = { + operationName: "grep", + args: [], + enabled: true, + }; + state = dispatch(state, { + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config, + }); + + // The cursor should be on the newly-added stage + const newStageId = state.cursorStageId!; + expect(newStageId).toBeTruthy(); + const stage = state.stages.get(newStageId)!; + expect(stage.config.operationName).toBe("grep"); + expect(stage.config.args).toEqual([]); + + // Step 2: EditStageModal would open — user types args and submits + state = dispatch(state, { + type: "UPDATE_STAGE_ARGS", + stageId: newStageId, + args: ["{{x}} > 5"], + }); + + // Verify the args were updated on the correct stage + expect(state.stages.get(newStageId)!.config.args).toEqual(["{{x}} > 5"]); + }); + + test("INSERT_STAGE_BEFORE also chains correctly", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + state = addStage(state, "sort", ["--key", "x"]); + const sortId = state.cursorStageId!; + + // Insert before the sort + state = dispatch(state, { + type: "INSERT_STAGE_BEFORE", + beforeStageId: sortId, + config: { operationName: "grep", args: [], enabled: true }, + }); + + // Cursor should be on the new grep + expect(state.cursorStageId).not.toBe(sortId); + const cursor = getCursorStage(state)!; + expect(cursor.config.operationName).toBe("grep"); + + // Pipeline order should be grep → sort + const names = getActivePath(state).map((s) => s.config.operationName); + expect(names).toEqual(["grep", "sort"]); + }); +}); + +// ── 5. Export produces valid output ────────────────────────────────── + +describe("Export produces valid output", () => { + test("multi-stage pipe script is syntactically valid", () => { + let state = createInitialState(); + state = addInput(state, []); + state = addStage(state, "grep", ["{{status}} > 200"]); + state = addStage(state, "sort", ["--key", "time=n"]); + state = addStage(state, "xform", ["{{latency_ms}} = {{latency}} * 1000"]); + state = addStage(state, "collate", ["--key", "host", "-a", "count"]); + state = addStage(state, "totable", []); + + const script = exportAsPipeScript(state); + + // Shebang + expect(script.startsWith("#!/usr/bin/env bash\n")).toBe(true); + + // All stages present + expect(script).toContain("recs grep"); + expect(script).toContain("recs sort --key time=n"); + expect(script).toContain("recs xform"); + expect(script).toContain("recs collate --key host -a count"); + expect(script).toContain("recs totable"); + + // Multi-line pipe continuation + expect(script).toContain("\\\n"); + expect(script).toContain("| recs sort"); + + // Should be parseable: count pipes (each stage after the first has a pipe) + const pipeCount = (script.match(/\| recs /g) ?? []).length; + expect(pipeCount).toBe(4); + }); + + test("chain command is syntactically valid", () => { + let state = createInitialState(); + state = addInput(state, []); + state = addStage(state, "grep", ["{{x}} > 1"]); + state = addStage(state, "sort", ["--key", "x=n"]); + state = addStage(state, "totable", []); + + const chain = exportAsChainCommand(state); + + // Starts with "recs chain" + expect(chain.startsWith("recs chain ")).toBe(true); + + // Uses \\| separator between stages + const parts = chain.replace("recs chain ", "").split(" \\| "); + expect(parts).toHaveLength(3); + expect(parts[0]).toContain("grep"); + expect(parts[1]).toContain("sort"); + expect(parts[2]).toContain("totable"); + }); + + test("disabled stages are excluded from export", () => { + let state = createInitialState(); + state = addInput(state, []); + state = addStage(state, "grep", ["{{x}} > 1"]); + const grepId = getLastStageId(state); + state = addStage(state, "sort", ["--key", "x"]); + state = addStage(state, "totable", []); + + // Disable grep + state = dispatch(state, { type: "TOGGLE_STAGE", stageId: grepId }); + + const script = exportAsPipeScript(state); + expect(script).not.toContain("recs grep"); + expect(script).toContain("recs sort"); + expect(script).toContain("recs totable"); + + const chain = exportAsChainCommand(state); + expect(chain).not.toContain("grep"); + expect(chain).toContain("sort"); + }); + + test("export with file input includes path in first stage", () => { + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: "/tmp/data.jsonl" }, + label: "data.jsonl", + }); + state = addStage(state, "grep", ["{{x}} > 1"]); + + const script = exportAsPipeScript(state); + expect(script).toContain("/tmp/data.jsonl"); + }); +}); + +// ── 6. Session save/load roundtrip ─────────────────────────────────── + +describe("Session save/load roundtrip", () => { + let tempDir: string; + let manager: SessionManager; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "recs-smoke-session-")); + manager = new SessionManager(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("full roundtrip: stages, forks, inputs, and name survive", async () => { + // Build complex state + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: "/tmp/data.jsonl" }, + label: "data.jsonl", + }); + state = addStage(state, "grep", ["{{status}} == 200"]); + state = addStage(state, "sort", ["--key", "time=n"]); + state = addStage(state, "collate", ["--key", "host", "-a", "count"]); + + // Create a fork + const stageIds = getStageIds(state); + state = dispatch(state, { + type: "CREATE_FORK", + name: "experiment", + atStageId: stageIds[0]!, + }); + + // Set session name + state = dispatch(state, { type: "SET_SESSION_NAME", name: "my analysis" }); + + // Pin a stage for selective caching + state = dispatch(state, { type: "PIN_STAGE", stageId: stageIds[1]! }); + + // Save + await manager.save(state); + + // Load and hydrate + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + // Verify all stages survived + expect(hydrated.stages.size).toBe(state.stages.size); + for (const [id, stage] of state.stages) { + const restored = hydrated.stages.get(id); + expect(restored).toBeDefined(); + expect(restored!.config.operationName).toBe(stage.config.operationName); + expect(restored!.config.args).toEqual(stage.config.args); + expect(restored!.config.enabled).toBe(stage.config.enabled); + } + + // Verify forks survived + expect(hydrated.forks.size).toBe(state.forks.size); + const restoredForkNames = Array.from(hydrated.forks.values()).map((f) => f.name); + expect(restoredForkNames).toContain("main"); + expect(restoredForkNames).toContain("experiment"); + + // Verify inputs survived + expect(hydrated.inputs.size).toBe(state.inputs.size); + + // Verify session name + expect(hydrated.sessionName).toBe("my analysis"); + + // Verify pinned stage + expect(hydrated.cacheConfig.pinnedStageIds).toBeInstanceOf(Set); + expect(hydrated.cacheConfig.pinnedStageIds.has(stageIds[1]!)).toBe(true); + }); + + test("undo/redo stacks survive save/load roundtrip", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = addStage(state, "sort"); + state = addStage(state, "totable"); + + // Undo one action + state = dispatch(state, { type: "UNDO" }); + expect(state.undoStack).toHaveLength(2); + expect(state.redoStack).toHaveLength(1); + + await manager.save(state); + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + expect(hydrated.undoStack).toHaveLength(2); + expect(hydrated.redoStack).toHaveLength(1); + + // Verify we can still undo/redo after hydration + const afterUndo = dispatch(hydrated, { type: "UNDO" }); + expect(getActivePath(afterUndo)).toHaveLength(1); + + const afterRedo = dispatch(afterUndo, { type: "REDO" }); + expect(getActivePath(afterRedo)).toHaveLength(2); + }); + + test("hydrated state can be executed", async () => { + let state = createInitialState(); + state = addInput(state, [ + new Record({ x: 5 }), + new Record({ x: 10 }), + new Record({ x: 15 }), + ]); + state = addStage(state, "grep", ["{{x}} > 7"]); + state = addStage(state, "sort", ["--key", "x=-n"]); + + await manager.save(state); + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + // Re-add the input records (stdin-capture doesn't persist records in session) + const inputId = hydrated.activeInputId; + const existingInput = hydrated.inputs.get(inputId); + if (existingInput && existingInput.source.kind !== "stdin-capture") { + // File inputs would work; but for stdin-capture we need to re-inject + } + // Re-add stdin input for execution test + let execState = dispatch(hydrated, { + type: "ADD_INPUT", + source: { + kind: "stdin-capture", + records: [ + new Record({ x: 5 }), + new Record({ x: 10 }), + new Record({ x: 15 }), + ], + }, + label: "re-injected", + }); + + const stageIds = getStageIds(execState); + const lastId = stageIds[stageIds.length - 1]!; + const result = await executeToStage(execState, lastId); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("x")).toBe(15); + expect(result.records[1]!.get("x")).toBe(10); + }); +}); + +// ── 7. Undo/redo full cycle ────────────────────────────────────────── + +describe("Undo/redo full cycle", () => { + test("add 3 stages, undo all 3, redo all 3 — verify state at each step", () => { + let state = createInitialState(); + state = addInput(state, [new Record({ x: 1 })]); + + // ADD_INPUT is undoable, so undo stack already has 1 entry + const baseUndoCount = state.undoStack.length; + + // Add 3 stages + state = addStage(state, "grep", ["{{x}} > 0"]); + expect(getActivePath(state)).toHaveLength(1); + expect(state.undoStack).toHaveLength(baseUndoCount + 1); + + state = addStage(state, "sort", ["--key", "x"]); + expect(getActivePath(state)).toHaveLength(2); + expect(state.undoStack).toHaveLength(baseUndoCount + 2); + + state = addStage(state, "totable", []); + expect(getActivePath(state)).toHaveLength(3); + expect(state.undoStack).toHaveLength(baseUndoCount + 3); + expect(state.redoStack).toHaveLength(0); + + // Undo all 3 stage additions + state = dispatch(state, { type: "UNDO" }); // remove totable + expect(getActivePath(state)).toHaveLength(2); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "grep", + "sort", + ]); + expect(state.redoStack).toHaveLength(1); + + state = dispatch(state, { type: "UNDO" }); // remove sort + expect(getActivePath(state)).toHaveLength(1); + expect(getActivePath(state)[0]!.config.operationName).toBe("grep"); + expect(state.redoStack).toHaveLength(2); + + state = dispatch(state, { type: "UNDO" }); // remove grep + expect(getActivePath(state)).toHaveLength(0); + expect(state.undoStack).toHaveLength(baseUndoCount); + expect(state.redoStack).toHaveLength(3); + + // Redo all 3 + state = dispatch(state, { type: "REDO" }); // restore grep + expect(getActivePath(state)).toHaveLength(1); + expect(getActivePath(state)[0]!.config.operationName).toBe("grep"); + + state = dispatch(state, { type: "REDO" }); // restore sort + expect(getActivePath(state)).toHaveLength(2); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "grep", + "sort", + ]); + + state = dispatch(state, { type: "REDO" }); // restore totable + expect(getActivePath(state)).toHaveLength(3); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "grep", + "sort", + "totable", + ]); + expect(state.redoStack).toHaveLength(0); + }); + + test("new action after undo clears redo stack", () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = addStage(state, "sort"); + + // Undo sort + state = dispatch(state, { type: "UNDO" }); + expect(state.redoStack).toHaveLength(1); + + // New action should clear redo + state = addStage(state, "collate"); + expect(state.redoStack).toHaveLength(0); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "grep", + "collate", + ]); + }); + + test("undo/redo + execution: pipeline executes correctly at each step", 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, "sort", ["--key", "x=n"]); + state = addStage(state, "grep", ["{{x}} > 1"]); + + // Execute the 2-stage pipeline + let stageIds = getStageIds(state); + let result = await executeToStage(state, stageIds[stageIds.length - 1]!); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("x")).toBe(2); + expect(result.records[1]!.get("x")).toBe(3); + + // Undo grep — now only sort remains + state = dispatch(state, { type: "UNDO" }); + state = { ...state, cache: new Map() }; // clear cache after undo + stageIds = getStageIds(state); + expect(stageIds).toHaveLength(1); + + result = await executeToStage(state, stageIds[0]!); + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("x")).toBe(1); + + // Redo grep + state = dispatch(state, { type: "REDO" }); + state = { ...state, cache: new Map() }; + stageIds = getStageIds(state); + expect(stageIds).toHaveLength(2); + + result = await executeToStage(state, stageIds[stageIds.length - 1]!); + expect(result.recordCount).toBe(2); + }); +}); + +// ── 8. Keyboard binding coverage ───────────────────────────────────── + +describe("Keyboard binding coverage", () => { + /** + * Parse HelpPanel's HELP_TEXT to extract all documented key bindings. + * Each binding is of the form " key Description" in the help text. + */ + test("every key in HelpPanel has a handler in App.tsx", async () => { + // Read HelpPanel source to extract HELP_TEXT + const helpSource = await Bun.file( + join( + import.meta.dir, + "../../../src/explorer/components/modals/HelpPanel.tsx", + ), + ).text(); + + // Extract key bindings from HELP_SECTIONS structured data + // Entries look like: { key: "↑/k ↓/j", desc: "Move cursor between stages" } + const entryPattern = /\{\s*key:\s*"([^"]+)"\s*,\s*desc:\s*"([^"]+)"\s*\}/g; + const keyBindings: KeyBinding[] = []; + let entryMatch: RegExpExecArray | null; + while ((entryMatch = entryPattern.exec(helpSource)) !== null) { + const keyPart = entryMatch[1]!; + const description = entryMatch[2]!; + const keys = keyPart.split(/[\s/,]+/).filter(Boolean); + keyBindings.push({ keys, description }); + } + expect(keyBindings.length).toBeGreaterThan(0); + + // Read App.tsx to extract keyboard handlers + const appSource = await Bun.file( + join(import.meta.dir, "../../../src/explorer/components/App.tsx"), + ).text(); + + // Extract all key checks from App.tsx + const handledKeys = extractHandledKeys(appSource); + + // Verify coverage: every documented key should have a handler + const unhandled: string[] = []; + for (const binding of keyBindings) { + const keys = binding.keys; + const hasHandler = keys.some((k) => handledKeys.has(normalizeKey(k))); + if (!hasHandler) { + unhandled.push(`${keys.join("/")} — ${binding.description}`); + } + } + + if (unhandled.length > 0) { + // This is a soft assertion — report what's missing + console.warn( + `Keys documented in HelpPanel but not found in App.tsx handlers:\n` + + unhandled.map((u) => ` - ${u}`).join("\n"), + ); + } + + // At minimum, these essential keys must be handled + const essentialKeys = [ + "j", "k", // cursor movement + "a", // add stage + "d", // delete stage + "e", // edit stage + " ", // toggle stage (Space) + "u", // undo + "tab", // toggle focus + "q", // quit + "?", // help + "x", // export + "f", // fork + ]; + + for (const key of essentialKeys) { + expect(handledKeys.has(key)).toBe(true); + } + }); +}); + +// ── Keyboard parsing helpers ───────────────────────────────────────── + +interface KeyBinding { + keys: string[]; + description: string; +} + +/** + * Extract all keys that App.tsx checks for in its useInput handler. + * Looks for Ink-style patterns like: + * input === "a" + * key.upArrow + * key.tab + * key.escape + * key.return + * input === "c" && key.ctrl → "ctrl+c" + */ +function extractHandledKeys(source: string): Set { + const keys = new Set(); + + // Match input === "X" or input.includes("X") (single-char keys) + const inputEquals = source.matchAll(/input === ["'](.+?)["']/g); + for (const m of inputEquals) { + keys.add(m[1]!); + } + const inputIncludes = source.matchAll(/input\.includes\(["'](.+?)["']\)/g); + for (const m of inputIncludes) { + keys.add(m[1]!); + } + + // Match key.upArrow, key.downArrow, key.leftArrow, key.rightArrow + const arrowMap: { [k: string]: string } = { + upArrow: "up", + downArrow: "down", + leftArrow: "left", + rightArrow: "right", + }; + for (const [prop, name] of Object.entries(arrowMap)) { + if (source.includes(`key.${prop}`)) { + keys.add(name as string); + } + } + + // Match key.tab, key.escape, key.return + for (const prop of ["tab", "escape", "return"]) { + if (source.includes(`key.${prop}`)) { + keys.add(prop); + } + } + + // Match ctrl combinations: input === "r" && key.ctrl or key.ctrl && input === "c" + const ctrlPatterns = source.matchAll( + /input === ["'](\w)["'].*?key\.ctrl|key\.ctrl.*?input === ["'](\w)["']/g, + ); + for (const m of ctrlPatterns) { + const keyName = m[1] ?? m[2]; + if (keyName) { + keys.add(`ctrl+${keyName}`); + } + } + + return keys; +} + +/** + * Normalize a help-text key name to match what extractHandledKeys returns. + */ +function normalizeKey(key: string): string { + const map: { [k: string]: string } = { + "↑": "up", + "↓": "down", + "←": "left", + "→": "right", + "Space": " ", + "Esc": "escape", + "Enter": "return", + "Tab": "tab", + "Ctrl+R": "ctrl+r", + "Ctrl+C": "ctrl+c", + }; + return map[key] ?? key.toLowerCase(); +} diff --git a/tests/explorer/integration/unicode.test.ts b/tests/explorer/integration/unicode.test.ts new file mode 100644 index 0000000..c08df10 --- /dev/null +++ b/tests/explorer/integration/unicode.test.ts @@ -0,0 +1,1114 @@ +/** + * Comprehensive Unicode/UTF-8 tests for the Explorer pipeline builder. + * + * RecordStream has Perl heritage where Unicode handling was problematic. + * These tests verify that the TypeScript/Bun implementation handles + * Unicode correctly across all layers: Records, pipeline operations, + * serialization, export, and display. + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { Record } from "../../../src/Record.ts"; +import { + createInitialState, + pipelineReducer, +} from "../../../src/explorer/model/reducer.ts"; +import { executeToStage } from "../../../src/explorer/executor/executor.ts"; +import { + getActivePath, +} from "../../../src/explorer/model/selectors.ts"; +import { + exportAsPipeScript, + exportAsChainCommand, + shellEscape, +} from "../../../src/explorer/model/serialization.ts"; +import { InterceptReceiver } from "../../../src/explorer/executor/intercept-receiver.ts"; +import { createOperation } from "../../../src/operations/transform/chain.ts"; +import { SessionManager } from "../../../src/explorer/session/session-manager.ts"; +import type { + PipelineState, + PipelineAction, + StageConfig, +} from "../../../src/explorer/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; +} + +// ── 1. Records with unicode field names ────────────────────────────── + +describe("Records with unicode field names", () => { + test("emoji field names", () => { + const record = new Record({ "🔑": "key-value", "🏠": "home", "🎉": 42 }); + expect(record.get("🔑")).toBe("key-value"); + expect(record.get("🏠")).toBe("home"); + expect(record.get("🎉")).toBe(42); + expect(record.keys()).toContain("🔑"); + }); + + test("CJK field names", () => { + const record = new Record({ "名前": "太郎", "年齢": 25, "住所": "東京" }); + expect(record.get("名前")).toBe("太郎"); + expect(record.get("年齢")).toBe(25); + expect(record.has("住所")).toBe(true); + }); + + test("accented/diacritical field names", () => { + const record = new Record({ "café": "latte", "naïve": true, "über": "cool" }); + expect(record.get("café")).toBe("latte"); + expect(record.get("naïve")).toBe(true); + expect(record.get("über")).toBe("cool"); + }); + + test("RTL text field names (Arabic/Hebrew)", () => { + const record = new Record({ "عربي": "arabic", "עברית": "hebrew" }); + expect(record.get("عربي")).toBe("arabic"); + expect(record.get("עברית")).toBe("hebrew"); + expect(record.keys()).toHaveLength(2); + }); + + test("mixed-script field names in same record", () => { + const record = new Record({ + name: "English", + "名前": "Japanese", + "이름": "Korean", + "имя": "Russian", + "🔑": "emoji", + }); + expect(record.keys()).toHaveLength(5); + expect(record.get("이름")).toBe("Korean"); + expect(record.get("имя")).toBe("Russian"); + }); + + test("clone preserves unicode field names", () => { + const original = new Record({ "🎉": "party", "名前": "太郎" }); + const cloned = original.clone(); + expect(cloned.get("🎉")).toBe("party"); + expect(cloned.get("名前")).toBe("太郎"); + + // Mutation of clone does not affect original + cloned.set("🎉", "changed"); + expect(original.get("🎉")).toBe("party"); + }); + + test("rename with unicode field names", () => { + const record = new Record({ "old_name": "value" }); + record.rename("old_name", "名前"); + expect(record.get("名前")).toBe("value"); + expect(record.has("old_name")).toBe(false); + }); + + test("pruneTo with unicode field names", () => { + const record = new Record({ "名前": "太郎", "年齢": 25, "住所": "東京" }); + record.pruneTo("名前", "年齢"); + expect(record.has("名前")).toBe(true); + expect(record.has("年齢")).toBe(true); + expect(record.has("住所")).toBe(false); + }); + + test("toJSON roundtrip preserves unicode", () => { + const original = new Record({ "🔑": "émoji", "名前": "太郎" }); + const json = original.toJSON(); + const restored = new Record(json); + expect(restored.get("🔑")).toBe("émoji"); + expect(restored.get("名前")).toBe("太郎"); + }); + + test("toString/fromJSON roundtrip preserves unicode", () => { + const original = new Record({ "🎵": "music", "café": "latte" }); + const serialized = original.toString(); + const parsed = Record.fromJSON(serialized); + expect(parsed.get("🎵")).toBe("music"); + expect(parsed.get("café")).toBe("latte"); + }); +}); + +// ── 2. Records with unicode values ─────────────────────────────────── + +describe("Records with unicode values", () => { + test("multi-byte emoji values", () => { + const record = new Record({ + simple: "😀", + family: "👨‍👩‍👧‍👦", + flag: "🇯🇵", + skin: "👋🏽", + }); + expect(record.get("simple")).toBe("😀"); + expect(record.get("family")).toBe("👨‍👩‍👧‍👦"); + expect(record.get("flag")).toBe("🇯🇵"); + expect(record.get("skin")).toBe("👋🏽"); + }); + + test("CJK text values", () => { + const record = new Record({ + japanese: "日本語テスト", + chinese: "中文测试", + korean: "한국어 테스트", + }); + expect(record.get("japanese")).toBe("日本語テスト"); + expect(record.get("chinese")).toBe("中文测试"); + expect(record.get("korean")).toBe("한국어 테스트"); + }); + + test("Devanagari and other Indic scripts", () => { + const record = new Record({ + hindi: "हिन्दी", + tamil: "தமிழ்", + bengali: "বাংলা", + }); + expect(record.get("hindi")).toBe("हिन्दी"); + expect(record.get("tamil")).toBe("தமிழ்"); + expect(record.get("bengali")).toBe("বাংলা"); + }); + + test("combining characters", () => { + // é can be a single codepoint (U+00E9) or e + combining acute (U+0065 U+0301) + const precomposed = "é"; // U+00E9 + const decomposed = "e\u0301"; // e + combining acute accent + + const record = new Record({ precomposed, decomposed }); + expect(record.get("precomposed")).toBe(precomposed); + expect(record.get("decomposed")).toBe(decomposed); + + // These are different strings in JavaScript + expect(precomposed).not.toBe(decomposed); + // But they are equal when normalized + expect(precomposed.normalize("NFC")).toBe(decomposed.normalize("NFC")); + }); + + test("zero-width joiners and other invisible characters", () => { + const zwj = "\u200D"; // zero-width joiner + const zwnj = "\u200C"; // zero-width non-joiner + const record = new Record({ + with_zwj: `a${zwj}b`, + with_zwnj: `a${zwnj}b`, + zwsp: "a\u200Bb", // zero-width space + }); + expect(record.get("with_zwj")).toBe(`a${zwj}b`); + expect(record.get("with_zwnj")).toBe(`a${zwnj}b`); + }); + + test("surrogate-pair characters (astral plane)", () => { + // Characters outside BMP require surrogate pairs in UTF-16 + const mathAlpha = "𝕳𝖊𝖑𝖑𝖔"; // Mathematical Fraktur + const musical = "𝄞"; // Musical symbol G clef (U+1D11E) + const ancient = "𐀀"; // Linear B Syllable (U+10000) + + const record = new Record({ math: mathAlpha, music: musical, ancient }); + expect(record.get("math")).toBe(mathAlpha); + expect(record.get("music")).toBe(musical); + expect(record.get("ancient")).toBe(ancient); + }); + + test("mixed ASCII and unicode in same value", () => { + const record = new Record({ + mixed: "Hello 世界! 🌍 café", + path: "/data/日本語/file.txt", + }); + expect(record.get("mixed")).toBe("Hello 世界! 🌍 café"); + expect(record.get("path")).toBe("/data/日本語/file.txt"); + }); +}); + +// ── 3. Pipeline operations with unicode ────────────────────────────── + +describe("Pipeline operations with unicode", () => { + test("grep filters records with unicode field values", async () => { + let state = createInitialState(); + const records = [ + new Record({ name: "Alice", lang: "English" }), + new Record({ name: "太郎", lang: "日本語" }), + new Record({ name: "Pierre", lang: "Français" }), + ]; + state = addInput(state, records); + + // Grep for records where lang contains non-ASCII characters + // Use a simple expression that matches the Japanese entry + state = addStage(state, "grep", ['{{lang}} === "日本語"']); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(1); + expect(result.records[0]!.get("name")).toBe("太郎"); + }); + + test("grep with unicode in expression text", async () => { + let state = createInitialState(); + const records = [ + new Record({ city: "東京", population: 14000000 }), + new Record({ city: "大阪", population: 2700000 }), + new Record({ city: "New York", population: 8300000 }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ['{{city}} === "東京"']); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(1); + expect(result.records[0]!.get("city")).toBe("東京"); + }); + + test("sort with unicode string values (lexical)", async () => { + let state = createInitialState(); + const records = [ + new Record({ name: "Charlie" }), + new Record({ name: "Alice" }), + new Record({ name: "太郎" }), + new Record({ name: "Bob" }), + ]; + state = addInput(state, records); + + state = addStage(state, "sort", ["--key", "name"]); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(4); + // Lexical sort should put unicode characters after ASCII + const names = result.records.map((r) => r.get("name")); + expect(names).toContain("Alice"); + expect(names).toContain("太郎"); + // Verify it's actually sorted (each name <= next name lexicographically) + for (let i = 0; i < names.length - 1; i++) { + expect(String(names[i]) <= String(names[i + 1])).toBe(true); + } + }); + + test("sort with emoji field names", async () => { + let state = createInitialState(); + const records = [ + new Record({ "🔢": 3, label: "c" }), + new Record({ "🔢": 1, label: "a" }), + new Record({ "🔢": 2, label: "b" }), + ]; + state = addInput(state, records); + + state = addStage(state, "sort", ["--key", "🔢=n"]); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(3); + expect(result.records[0]!.get("🔢")).toBe(1); + expect(result.records[1]!.get("🔢")).toBe(2); + expect(result.records[2]!.get("🔢")).toBe(3); + }); + + test("xform with unicode field names", async () => { + let state = createInitialState(); + const records = [ + new Record({ x: 10 }), + new Record({ x: 20 }), + ]; + state = addInput(state, records); + + // Use xform to create a field with a unicode name + state = addStage(state, "xform", ["{{結果}} = {{x}} * 2"]); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("結果")).toBe(20); + expect(result.records[1]!.get("結果")).toBe(40); + expect(result.fieldNames).toContain("結果"); + }); + + test("collate with unicode group keys", async () => { + let state = createInitialState(); + const records = [ + new Record({ group: "日本", val: 10 }), + new Record({ group: "中国", val: 20 }), + new Record({ group: "日本", val: 30 }), + new Record({ group: "中国", val: 5 }), + ]; + state = addInput(state, records); + + state = addStage(state, "collate", ["--key", "group", "-a", "count"]); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(2); + + const groups = result.records.map((r) => ({ + group: r.get("group"), + count: r.get("count"), + })); + const sorted = groups.sort((a, b) => + String(a.group).localeCompare(String(b.group)), + ); + expect(sorted[0]!.count).toBe(2); + expect(sorted[1]!.count).toBe(2); + }); + + test("multi-stage pipeline with unicode throughout", async () => { + let state = createInitialState(); + const records = [ + new Record({ "名前": "太郎", "年齢": 30, "市": "東京" }), + new Record({ "名前": "花子", "年齢": 20, "市": "大阪" }), + new Record({ "名前": "一郎", "年齢": 35, "市": "東京" }), + new Record({ "名前": "美咲", "年齢": 25, "市": "京都" }), + ]; + state = addInput(state, records); + + // grep: age > 25 + state = addStage(state, "grep", ["{{年齢}} > 25"]); + // sort: by age ascending + state = addStage(state, "sort", ["--key", "年齢=n"]); + const sortId = getLastStageId(state); + + const result = await executeToStage(state, sortId); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("名前")).toBe("太郎"); + expect(result.records[0]!.get("年齢")).toBe(30); + expect(result.records[1]!.get("名前")).toBe("一郎"); + expect(result.records[1]!.get("年齢")).toBe(35); + }); + + test("grep with emoji values", async () => { + let state = createInitialState(); + const records = [ + new Record({ status: "✅", task: "done" }), + new Record({ status: "❌", task: "failed" }), + new Record({ status: "✅", task: "also done" }), + new Record({ status: "⏳", task: "pending" }), + ]; + state = addInput(state, records); + + state = addStage(state, "grep", ['{{status}} === "✅"']); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(2); + expect(result.records.every((r) => r.get("status") === "✅")).toBe(true); + }); +}); + +// ── 4. Direct operation tests with unicode (InterceptReceiver) ─────── + +describe("Direct operations with unicode (InterceptReceiver)", () => { + test("grep filters with unicode expressions", () => { + const receiver = new InterceptReceiver(); + const op = createOperation("grep", ['{{name}} === "太郎"'], receiver); + + op.acceptRecord(new Record({ name: "太郎" })); + op.acceptRecord(new Record({ name: "花子" })); + op.acceptRecord(new Record({ name: "太郎" })); + op.finish(); + + expect(receiver.recordCount).toBe(2); + expect(receiver.records.every((r) => r.get("name") === "太郎")).toBe(true); + }); + + test("sort orders unicode strings correctly", () => { + const receiver = new InterceptReceiver(); + const op = createOperation("sort", ["--key", "name"], receiver); + + op.acceptRecord(new Record({ name: "Charlie" })); + op.acceptRecord(new Record({ name: "Alice" })); + op.acceptRecord(new Record({ name: "太郎" })); + op.acceptRecord(new Record({ name: "Bob" })); + op.finish(); + + expect(receiver.recordCount).toBe(4); + const names = receiver.records.map((r) => String(r.get("name"))); + // Verify sorted order + for (let i = 0; i < names.length - 1; i++) { + expect(names[i]! <= names[i + 1]!).toBe(true); + } + }); + + test("InterceptReceiver tracks unicode field names", () => { + const receiver = new InterceptReceiver(); + const r1 = new Record({ "名前": "太郎", "年齢": 30 }); + const r2 = new Record({ "名前": "花子", "市": "東京" }); + + receiver.acceptRecord(r1); + receiver.acceptRecord(r2); + receiver.finish(); + + expect(receiver.fieldNames).toEqual(new Set(["名前", "年齢", "市"])); + }); + + test("InterceptReceiver clones records with unicode data", () => { + const receiver = new InterceptReceiver(); + const original = new Record({ "🎉": "パーティー", "café": "latté" }); + + receiver.acceptRecord(original); + original.set("🎉", "changed"); + + expect(receiver.records[0]!.get("🎉")).toBe("パーティー"); + expect(receiver.records[0]!.get("café")).toBe("latté"); + }); +}); + +// ── 5. Column width calculation with unicode ───────────────────────── + +describe("Column width calculation with unicode", () => { + // Mirror the column width computation used in RecordTable and AddStageModal + const COL_MIN = 4; + const COL_MAX = 30; + + function computeColumnWidths(fields: string[], records: Record[]): number[] { + return fields.map((field) => { + let maxWidth = field.length; + for (const record of records) { + 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); + }); + } + + test("ASCII values use .length correctly", () => { + const fields = ["name"]; + const records = [new Record({ name: "Alice" })]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe(5); // "Alice".length === 5 + }); + + test("CJK characters have correct .length (1 per char in JS)", () => { + // NOTE: In JS, each CJK character is a single code unit, so .length = 1 + // However, CJK chars are visually double-width in terminals. + // This test documents the CURRENT behavior (using .length). + const fields = ["city"]; + const records = [new Record({ city: "東京" })]; + const widths = computeColumnWidths(fields, records); + // "東京".length === 2 in JavaScript, but visually it's 4 cells wide + expect(widths[0]).toBe(COL_MIN); // 2 < COL_MIN=4, so COL_MIN + }); + + test("emoji characters have varying .length", () => { + // Simple emoji: "😀".length === 2 (surrogate pair) + expect("😀".length).toBe(2); + // Family emoji with ZWJ: much longer + expect("👨‍👩‍👧‍👦".length).toBe(11); // 4 emoji + 3 ZWJ + // Flag emoji: "🇯🇵".length === 4 (two regional indicator symbols) + expect("🇯🇵".length).toBe(4); + }); + + test("column width with emoji field names", () => { + const fields = ["🔑"]; // length 2 (surrogate pair) + const records = [new Record({ "🔑": "value" })]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe(5); // "value".length = 5 > "🔑".length = 2 + }); + + test("column width with CJK field names", () => { + const fields = ["名前"]; // length 2 + const records = [new Record({ "名前": "x" })]; + const widths = computeColumnWidths(fields, records); + expect(widths[0]).toBe(COL_MIN); // max(2, 1) = 2 < COL_MIN=4 + }); + + test("combining characters affect .length", () => { + // Precomposed é (U+00E9) = length 1 + expect("é".length).toBe(1); + // Decomposed e + combining acute (U+0065 U+0301) = length 2 + expect("e\u0301".length).toBe(2); + + const fields = ["val"]; + const precomposed = [new Record({ val: "café" })]; // length 4 + const decomposed = [new Record({ val: "cafe\u0301" })]; // length 5 + + const w1 = computeColumnWidths(fields, precomposed); + const w2 = computeColumnWidths(fields, decomposed); + expect(w1[0]).toBe(4); // "café".length + expect(w2[0]).toBe(5); // "cafe\u0301".length + }); +}); + +// ── 6. Session serialization roundtrip with unicode ────────────────── + +describe("Session serialization roundtrip with unicode", () => { + let tempDir: string; + let manager: SessionManager; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "recs-unicode-test-")); + manager = new SessionManager(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("save/load preserves unicode stage args", async () => { + let state = createInitialState(); + state = addStage(state, "grep", ['{{名前}} === "太郎"']); + state = addStage(state, "sort", ["--key", "年齢=n"]); + + await manager.save(state); + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + const stages = Array.from(hydrated.stages.values()); + expect(stages[0]!.config.args).toEqual(['{{名前}} === "太郎"']); + expect(stages[1]!.config.args).toEqual(["--key", "年齢=n"]); + }); + + test("save/load preserves unicode session name", async () => { + let state = createInitialState(); + state = addStage(state, "grep", ["true"]); + state = { ...state, sessionName: "テスト セッション 🎉" }; + + await manager.save(state); + const loaded = await manager.load(state.sessionId); + expect(loaded.name).toBe("テスト セッション 🎉"); + + const hydrated = manager.hydrate(loaded); + expect(hydrated.sessionName).toBe("テスト セッション 🎉"); + }); + + test("save/load preserves unicode input labels", async () => { + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: "/tmp/日本語データ.jsonl" }, + label: "日本語データ.jsonl", + }); + state = addStage(state, "grep", ["true"]); + + await manager.save(state); + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + const inputs = Array.from(hydrated.inputs.values()); + const fileInput = inputs.find( + (i) => i.source.kind === "file", + ); + expect(fileInput).toBeDefined(); + expect(fileInput!.label).toBe("日本語データ.jsonl"); + if (fileInput!.source.kind === "file") { + expect(fileInput!.source.path).toBe("/tmp/日本語データ.jsonl"); + } + }); + + test("save/load preserves unicode in undo/redo stack", async () => { + let state = createInitialState(); + state = addStage(state, "grep", ['{{名前}} === "太郎"']); + state = addStage(state, "sort", ["--key", "年齢=n"]); + // Undo to push sort onto redo stack + state = dispatch(state, { type: "UNDO" }); + + await manager.save(state); + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + expect(hydrated.undoStack.length).toBeGreaterThanOrEqual(1); + expect(hydrated.redoStack.length).toBeGreaterThanOrEqual(1); + + // The remaining stage should have unicode args + const stages = Array.from(hydrated.stages.values()); + const grepStage = stages.find( + (s) => s.config.operationName === "grep", + ); + expect(grepStage).toBeDefined(); + expect(grepStage!.config.args[0]).toBe('{{名前}} === "太郎"'); + }); + + test("list returns unicode session names in metadata", async () => { + let state = createInitialState(); + state = addStage(state, "grep", ["true"]); + state = { ...state, sessionName: "日本語テスト" }; + await manager.save(state); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.name).toBe("日本語テスト"); + }); + + test("saveAs with unicode name", async () => { + let state = createInitialState(); + state = addStage(state, "grep", ["true"]); + const newId = await manager.saveAs(state, "分析結果 🔍"); + + const loaded = await manager.load(newId); + expect(loaded.name).toBe("分析結果 🔍"); + }); + + test("rename with unicode name", async () => { + let state = createInitialState(); + state = addStage(state, "grep", ["true"]); + state = { ...state, sessionName: "old" }; + await manager.save(state); + + await manager.rename(state.sessionId, "新しい名前 ✨"); + const loaded = await manager.load(state.sessionId); + expect(loaded.name).toBe("新しい名前 ✨"); + }); +}); + +// ── 7. Export with unicode ─────────────────────────────────────────── + +describe("Export with unicode", () => { + test("shellEscape handles unicode characters", () => { + // Unicode characters match SHELL_SPECIAL pattern, so they get quoted + const result = shellEscape("日本語"); + expect(result).toBe("'日本語'"); + }); + + test("shellEscape handles emoji", () => { + const result = shellEscape("🎉party"); + expect(result).toBe("'🎉party'"); + }); + + test("shellEscape handles mixed unicode and special chars", () => { + const result = shellEscape("{{名前}} > 0"); + expect(result).toBe("'{{名前}} > 0'"); + }); + + test("shellEscape handles unicode with single quotes", () => { + const result = shellEscape("it's 日本語"); + expect(result).toBe("$'it\\'s 日本語'"); + }); + + test("exportAsPipeScript with unicode stage args", () => { + let state = createInitialState(); + state = addInput(state, []); + state = addStage(state, "grep", ['{{名前}} === "太郎"']); + state = addStage(state, "sort", ["--key", "年齢=n"]); + + const script = exportAsPipeScript(state); + expect(script).toContain("#!/usr/bin/env bash"); + expect(script).toContain("recs grep"); + expect(script).toContain("名前"); + expect(script).toContain("太郎"); + expect(script).toContain("年齢"); + }); + + test("exportAsChainCommand with unicode stage args", () => { + let state = createInitialState(); + state = addInput(state, []); + state = addStage(state, "grep", ['{{名前}} === "太郎"']); + state = addStage(state, "sort", ["--key", "年齢=n"]); + + const chain = exportAsChainCommand(state); + expect(chain).toContain("recs chain"); + expect(chain).toContain("名前"); + expect(chain).toContain("年齢"); + }); + + test("exportAsPipeScript with unicode file path", () => { + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: "/tmp/データ/テスト.jsonl" }, + label: "テスト.jsonl", + }); + state = addStage(state, "grep", ["true"]); + + const script = exportAsPipeScript(state); + expect(script).toContain("テスト.jsonl"); + expect(script).toContain("データ"); + }); + + test("export pipe script with emoji in args", () => { + let state = createInitialState(); + state = addInput(state, []); + state = addStage(state, "grep", ['{{status}} === "✅"']); + + const script = exportAsPipeScript(state); + expect(script).toContain("✅"); + }); +}); + +// ── 8. Stream preview logic with unicode ───────────────────────────── + +describe("Stream preview with unicode", () => { + // Mirror the tree flattening logic from AddStageModal + interface TreeRow { + depth: number; + label: string; + value: unknown; + isContainer: boolean; + path: string; + childCount: number; + } + + function flattenValue( + value: unknown, + collapsed: Set, + parentPath: string, + depth: number, + label: string, + ): TreeRow[] { + const path = parentPath ? `${parentPath}.${label}` : label; + + if (value === null || value === undefined) { + return [{ depth, label, value: null, isContainer: false, path, childCount: 0 }]; + } + + if (typeof value === "object" && !Array.isArray(value)) { + const keys = Object.keys(value as object); + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: keys.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (const key of keys) { + rows.push(...flattenValue((value as { [k: string]: unknown })[key], collapsed, path, depth + 1, key)); + } + } + return rows; + } + + if (Array.isArray(value)) { + const row: TreeRow = { depth, label, value, isContainer: true, path, childCount: value.length }; + const rows: TreeRow[] = [row]; + if (!collapsed.has(path)) { + for (let i = 0; i < value.length; i++) { + rows.push(...flattenValue(value[i], collapsed, path, depth + 1, `[${i}]`)); + } + } + return rows; + } + + return [{ depth, label, value, isContainer: false, path, childCount: 0 }]; + } + + function flattenRecord(record: Record, collapsed: Set): TreeRow[] { + const data = record.toJSON(); + const rows: TreeRow[] = []; + for (const key of Object.keys(data)) { + rows.push(...flattenValue(data[key]!, collapsed, "", 0, key)); + } + return rows; + } + + test("flattens record with unicode field names", () => { + const record = new Record({ "名前": "太郎", "年齢": 30 }); + const rows = flattenRecord(record, new Set()); + expect(rows).toHaveLength(2); + expect(rows[0]!.label).toBe("名前"); + expect(rows[0]!.value).toBe("太郎"); + expect(rows[1]!.label).toBe("年齢"); + expect(rows[1]!.value).toBe(30); + }); + + test("flattens record with emoji field names", () => { + const record = new Record({ "🔑": "key-val", "🏠": "home-val" }); + const rows = flattenRecord(record, new Set()); + expect(rows).toHaveLength(2); + expect(rows[0]!.label).toBe("🔑"); + expect(rows[0]!.value).toBe("key-val"); + }); + + test("flattens nested objects with unicode keys", () => { + const record = new Record({ + "メタ": { "名前": "太郎", "値": 42 }, + }); + const rows = flattenRecord(record, new Set()); + // メタ (container) + 名前 + 値 = 3 rows + expect(rows).toHaveLength(3); + expect(rows[0]!.label).toBe("メタ"); + expect(rows[0]!.isContainer).toBe(true); + expect(rows[1]!.label).toBe("名前"); + expect(rows[1]!.value).toBe("太郎"); + expect(rows[2]!.label).toBe("値"); + expect(rows[2]!.value).toBe(42); + }); + + test("collapse works with unicode paths", () => { + const record = new Record({ + "メタ": { "名前": "太郎", "値": 42 }, + }); + const collapsed = new Set(["メタ"]); + const rows = flattenRecord(record, collapsed); + expect(rows).toHaveLength(1); + expect(rows[0]!.label).toBe("メタ"); + expect(rows[0]!.isContainer).toBe(true); + }); + + test("unicode array values flatten correctly", () => { + const record = new Record({ + "タグ": ["日本", "東京", "🎉"], + }); + const rows = flattenRecord(record, new Set()); + expect(rows).toHaveLength(4); // container + 3 items + expect(rows[0]!.label).toBe("タグ"); + expect(rows[0]!.isContainer).toBe(true); + expect(rows[1]!.value).toBe("日本"); + expect(rows[2]!.value).toBe("東京"); + expect(rows[3]!.value).toBe("🎉"); + }); +}); + +// ── 9. Edge cases ──────────────────────────────────────────────────── + +describe("Unicode edge cases", () => { + test("BOM marker in record values", () => { + const bom = "\uFEFF"; + const record = new Record({ text: `${bom}Hello` }); + expect(record.get("text")).toBe(`${bom}Hello`); + // BOM is preserved in the string + expect(String(record.get("text")).charCodeAt(0)).toBe(0xFEFF); + }); + + test("null bytes in strings", () => { + const record = new Record({ text: "hello\0world" }); + expect(record.get("text")).toBe("hello\0world"); + expect(String(record.get("text")).length).toBe(11); + }); + + test("empty string vs unicode whitespace", () => { + const record = new Record({ + empty: "", + space: " ", + nbsp: "\u00A0", // non-breaking space + ideographic_space: "\u3000", // CJK ideographic space + thin_space: "\u2009", + }); + expect(record.get("empty")).toBe(""); + expect(record.get("nbsp")).toBe("\u00A0"); + expect(record.get("ideographic_space")).toBe("\u3000"); + expect(record.get("thin_space")).toBe("\u2009"); + }); + + test("very long unicode strings", () => { + const longCJK = "漢".repeat(10000); + const record = new Record({ text: longCJK }); + expect(String(record.get("text")).length).toBe(10000); + + // Verify clone works with long unicode strings + const cloned = record.clone(); + expect(String(cloned.get("text")).length).toBe(10000); + }); + + test("mixed direction text (LTR + RTL)", () => { + const record = new Record({ + bidi: "Hello مرحبا World عالم", + rtl_only: "عربي فقط", + hebrew: "שלום עולם", + }); + expect(record.get("bidi")).toBe("Hello مرحبا World عالم"); + expect(record.get("rtl_only")).toBe("عربي فقط"); + expect(record.get("hebrew")).toBe("שלום עולם"); + }); + + test("mathematical and technical symbols", () => { + const record = new Record({ + math: "∑∏∫∂∇", + arrows: "←→↑↓⇐⇒", + box: "┌─┐│└─┘", + currency: "¥€£₹₩", + }); + expect(record.get("math")).toBe("∑∏∫∂∇"); + expect(record.get("arrows")).toBe("←→↑↓⇐⇒"); + }); + + test("control characters in strings", () => { + const record = new Record({ + tab: "col1\tcol2", + newline: "line1\nline2", + cr: "text\r\nwith cr", + }); + expect(record.get("tab")).toBe("col1\tcol2"); + expect(record.get("newline")).toBe("line1\nline2"); + + // Verify JSON roundtrip preserves control chars + const serialized = record.toString(); + const parsed = Record.fromJSON(serialized); + expect(parsed.get("tab")).toBe("col1\tcol2"); + expect(parsed.get("newline")).toBe("line1\nline2"); + }); + + test("Record.fromJSON with unicode JSON", () => { + const json = '{"名前":"太郎","🎉":"パーティー","emoji":"👨‍👩‍👧‍👦"}'; + const record = Record.fromJSON(json); + expect(record.get("名前")).toBe("太郎"); + expect(record.get("🎉")).toBe("パーティー"); + expect(record.get("emoji")).toBe("👨‍👩‍👧‍👦"); + }); + + test("JSON roundtrip preserves all unicode categories", () => { + const original = new Record({ + latin: "café", + cjk: "日本語", + emoji: "😀👨‍👩‍👧‍👦🇯🇵", + arabic: "عربي", + hebrew: "עברית", + devanagari: "हिन्दी", + cyrillic: "Русский", + combining: "e\u0301", + astral: "𝕳𝖊𝖑𝖑𝖔", + }); + + const serialized = original.toString(); + const restored = Record.fromJSON(serialized); + + for (const key of original.keys()) { + expect(restored.get(key)).toBe(original.get(key)); + } + }); + + test("sort comparison with unicode strings", () => { + const records = [ + new Record({ name: "中文" }), + new Record({ name: "English" }), + new Record({ name: "日本語" }), + new Record({ name: "한국어" }), + ]; + + const sorted = Record.sort(records, "name"); + // Should not throw and should produce a stable order + expect(sorted).toHaveLength(4); + + // Verify sort is stable: each pair should satisfy lexical comparison + for (let i = 0; i < sorted.length - 1; i++) { + const a = String(sorted[i]!.get("name")); + const b = String(sorted[i + 1]!.get("name")); + expect(a <= b).toBe(true); + } + }); + + test("sort comparison with numeric unicode field names", () => { + const records = [ + new Record({ "数値": 30 }), + new Record({ "数値": 10 }), + new Record({ "数値": 20 }), + ]; + + const sorted = Record.sort(records, "数値=n"); + expect(sorted[0]!.get("数値")).toBe(10); + expect(sorted[1]!.get("数値")).toBe(20); + expect(sorted[2]!.get("数値")).toBe(30); + }); + + test("nested key access with unicode", () => { + const record = new Record({ + "メタ": { "名前": "太郎" }, + }); + // Direct nested access using get on nested object + const meta = record.get("メタ") as { "名前": string }; + expect(meta["名前"]).toBe("太郎"); + }); +}); + +// ── 10. File I/O with unicode ──────────────────────────────────────── + +describe("File I/O with unicode content", () => { + test("fromcsv with unicode content via file input", async () => { + const csvContent = "名前,年齢,市\n太郎,30,東京\n花子,25,大阪\n"; + const tmpFile = `/tmp/recs-unicode-csv-${Date.now()}.csv`; + await Bun.write(tmpFile, csvContent); + + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: tmpFile }, + label: "unicode.csv", + }); + state = addStage(state, "fromcsv", ["--header"]); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("名前")).toBe("太郎"); + expect(result.records[0]!.get("年齢")).toBe("30"); + expect(result.records[0]!.get("市")).toBe("東京"); + expect(result.records[1]!.get("名前")).toBe("花子"); + + // Verify field names include unicode + expect(result.fieldNames).toContain("名前"); + expect(result.fieldNames).toContain("年齢"); + expect(result.fieldNames).toContain("市"); + + const fs = await import("node:fs"); + fs.unlinkSync(tmpFile); + }); + + test("JSONL file with unicode records", async () => { + const jsonlContent = [ + JSON.stringify({ "名前": "太郎", "emoji": "🎉" }), + JSON.stringify({ "名前": "花子", "emoji": "🌸" }), + "", + ].join("\n"); + + const tmpFile = `/tmp/recs-unicode-jsonl-${Date.now()}.jsonl`; + await Bun.write(tmpFile, jsonlContent); + + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: tmpFile }, + label: "unicode.jsonl", + }); + state = addStage(state, "grep", ["true"]); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(2); + expect(result.records[0]!.get("名前")).toBe("太郎"); + expect(result.records[0]!.get("emoji")).toBe("🎉"); + expect(result.records[1]!.get("名前")).toBe("花子"); + expect(result.records[1]!.get("emoji")).toBe("🌸"); + + const fs = await import("node:fs"); + fs.unlinkSync(tmpFile); + }); + + test("CSV with BOM marker", async () => { + const bom = "\uFEFF"; + const csvContent = `${bom}name,value\nAlice,1\nBob,2\n`; + const tmpFile = `/tmp/recs-bom-csv-${Date.now()}.csv`; + await Bun.write(tmpFile, csvContent); + + let state = createInitialState(); + state = dispatch(state, { + type: "ADD_INPUT", + source: { kind: "file", path: tmpFile }, + label: "bom.csv", + }); + state = addStage(state, "fromcsv", ["--header"]); + const stageId = getLastStageId(state); + + const result = await executeToStage(state, stageId); + expect(result.recordCount).toBe(2); + // BOM may or may not be stripped by the CSV parser — document behavior + const fieldNames = result.fieldNames; + // The first field name might have BOM prefix + const hasPlainName = fieldNames.includes("name"); + const hasBomName = fieldNames.includes(`${bom}name`); + expect(hasPlainName || hasBomName).toBe(true); + + const fs = await import("node:fs"); + fs.unlinkSync(tmpFile); + }); +}); diff --git a/tests/explorer/integration/wiring.test.ts b/tests/explorer/integration/wiring.test.ts new file mode 100644 index 0000000..00af6d6 --- /dev/null +++ b/tests/explorer/integration/wiring.test.ts @@ -0,0 +1,359 @@ +/** + * Wiring audit test — verifies that all hooks, components, and modules + * defined in src/explorer/ are actually imported and reachable from the + * App.tsx component tree. + * + * This catches "defined but not wired" bugs where a module exists but is + * never imported (like the useExecution bug that went undetected by 325+ tests). + * + * Strategy: statically read the source files and verify import chains, + * ensuring every module is reachable from App.tsx within ≤3 hops. + */ + +import { describe, test, expect } from "bun:test"; +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join, relative, basename } from "node:path"; + +const SRC_DIR = join(import.meta.dir, "../../../src/explorer"); +const APP_PATH = join(SRC_DIR, "components/App.tsx"); + +/** + * Extract all relative import paths from a source file. + * Matches: import ... from "..." and import "..." + */ +function extractImports(filePath: string): string[] { + const content = readFileSync(filePath, "utf-8"); + const importRegex = /(?:import|export)\s+(?:[\s\S]*?from\s+)?["'](\.[^"']+)["']/g; + const imports: string[] = []; + let match: RegExpExecArray | null; + while ((match = importRegex.exec(content)) !== null) { + imports.push(match[1]!); + } + return imports; +} + +/** + * Resolve a relative import path to an absolute file path. + * Handles .ts/.tsx extension matching. + */ +function resolveImport(fromFile: string, importPath: string): string | null { + const dir = join(fromFile, ".."); + // Try exact path first, then with extensions + const candidates = [ + join(dir, importPath), + join(dir, importPath + ".ts"), + join(dir, importPath + ".tsx"), + join(dir, importPath, "index.ts"), + join(dir, importPath, "index.tsx"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Build the full import graph reachable from a root file, following + * only imports within the explorer directory tree. + */ +function buildImportGraph(rootFile: string): Set { + const visited = new Set(); + const queue = [rootFile]; + + while (queue.length > 0) { + const current = queue.pop()!; + if (visited.has(current)) continue; + visited.add(current); + + const imports = extractImports(current); + for (const imp of imports) { + const resolved = resolveImport(current, imp); + if (resolved && resolved.startsWith(SRC_DIR)) { + queue.push(resolved); + } + } + } + + return visited; +} + +/** + * Get all .ts/.tsx source files in src/explorer/ (excluding types.ts). + */ +function getAllExplorerModules(): string[] { + const modules: string[] = []; + + function walk(dir: string): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (/\.(ts|tsx)$/.test(entry.name)) { + modules.push(fullPath); + } + } + } + + walk(SRC_DIR); + return modules; +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("Explorer wiring audit", () => { + const reachable = buildImportGraph(APP_PATH); + const allModules = getAllExplorerModules(); + + test("App.tsx import graph is reachable", () => { + expect(reachable.has(APP_PATH)).toBe(true); + // Should reach at least 20 modules + expect(reachable.size).toBeGreaterThan(20); + }); + + // ── Hooks ─────────────────────────────────────────────────────── + + describe("hooks are wired into the component tree", () => { + test("useExecution is reachable from App.tsx", () => { + const hookPath = join(SRC_DIR, "hooks/useExecution.ts"); + expect(reachable.has(hookPath)).toBe(true); + }); + + test("useAutoSave is reachable from App.tsx", () => { + const hookPath = join(SRC_DIR, "hooks/useAutoSave.ts"); + expect(reachable.has(hookPath)).toBe(true); + }); + + test("useUndoRedo is reachable from App.tsx", () => { + const hookPath = join(SRC_DIR, "hooks/useUndoRedo.ts"); + expect(reachable.has(hookPath)).toBe(true); + }); + + test("useVimIntegration is reachable from App.tsx", () => { + const hookPath = join(SRC_DIR, "hooks/useVimIntegration.ts"); + expect(reachable.has(hookPath)).toBe(true); + }); + + test("useExecution is actually called (not just imported)", () => { + const appContent = readFileSync(APP_PATH, "utf-8"); + expect(appContent).toMatch(/useExecution\s*\(/); + }); + + test("useAutoSave is actually called (not just imported)", () => { + const appContent = readFileSync(APP_PATH, "utf-8"); + expect(appContent).toMatch(/useAutoSave\s*\(/); + }); + + test("useUndoRedo is actually called (not just imported)", () => { + const appContent = readFileSync(APP_PATH, "utf-8"); + expect(appContent).toMatch(/useUndoRedo\s*\(/); + }); + + test("useVimIntegration is actually called (not just imported)", () => { + const appContent = readFileSync(APP_PATH, "utf-8"); + expect(appContent).toMatch(/useVimIntegration\s*\(/); + }); + }); + + // ── Components ────────────────────────────────────────────────── + + describe("components are reachable from App.tsx", () => { + const components = [ + "components/WelcomeScreen.tsx", + "components/TitleBar.tsx", + "components/PipelineBar.tsx", + "components/ForkTabs.tsx", + "components/InspectorPanel.tsx", + "components/StatusBar.tsx", + "components/InspectorHeader.tsx", + "components/RecordView.tsx", + "components/RecordTable.tsx", + "components/SchemaView.tsx", + "components/modals/AddStageModal.tsx", + "components/modals/EditStageModal.tsx", + "components/modals/ConfirmDialog.tsx", + "components/modals/HelpPanel.tsx", + "components/modals/ExportPicker.tsx", + "components/modals/ForkManager.tsx", + "components/modals/InputSwitcher.tsx", + "components/modals/LargeFileWarning.tsx", + "components/modals/SessionPicker.tsx", + "components/modals/SaveSessionModal.tsx", + "components/modals/RecordDetail.tsx", + "components/modals/FieldSpotlight.tsx", + ]; + + for (const component of components) { + test(`${basename(component, ".tsx")} is reachable`, () => { + const fullPath = join(SRC_DIR, component); + expect(reachable.has(fullPath)).toBe(true); + }); + } + }); + + // ── Model modules ────────────────────────────────────────────── + + describe("model modules are reachable from App.tsx", () => { + test("reducer.ts is reachable", () => { + expect(reachable.has(join(SRC_DIR, "model/reducer.ts"))).toBe(true); + }); + + test("selectors.ts is reachable", () => { + expect(reachable.has(join(SRC_DIR, "model/selectors.ts"))).toBe(true); + }); + + test("serialization.ts is reachable", () => { + expect(reachable.has(join(SRC_DIR, "model/serialization.ts"))).toBe(true); + }); + + test("undo.ts is reachable (via reducer)", () => { + expect(reachable.has(join(SRC_DIR, "model/undo.ts"))).toBe(true); + }); + + test("types.ts is reachable", () => { + expect(reachable.has(join(SRC_DIR, "model/types.ts"))).toBe(true); + }); + }); + + // ── Executor modules ────────────────────────────────────────── + + describe("executor modules are reachable from App.tsx", () => { + test("executor.ts is reachable (via useExecution)", () => { + expect(reachable.has(join(SRC_DIR, "executor/executor.ts"))).toBe(true); + }); + + test("input-loader.ts is reachable (via executor)", () => { + expect(reachable.has(join(SRC_DIR, "executor/input-loader.ts"))).toBe(true); + }); + + test("intercept-receiver.ts is reachable (via executor)", () => { + expect(reachable.has(join(SRC_DIR, "executor/intercept-receiver.ts"))).toBe(true); + }); + }); + + // ── Session modules ─────────────────────────────────────────── + + describe("session modules are reachable from App.tsx", () => { + test("session-manager.ts is reachable", () => { + expect(reachable.has(join(SRC_DIR, "session/session-manager.ts"))).toBe(true); + }); + + test("auto-save.ts is reachable (via useAutoSave)", () => { + expect(reachable.has(join(SRC_DIR, "session/auto-save.ts"))).toBe(true); + }); + + test("session-cache-store.ts is reachable (via session-manager)", () => { + expect(reachable.has(join(SRC_DIR, "session/session-cache-store.ts"))).toBe(true); + }); + }); + + // ── Utility modules ─────────────────────────────────────────── + + describe("utility modules are reachable from App.tsx", () => { + test("file-detect.ts is reachable", () => { + expect(reachable.has(join(SRC_DIR, "utils/file-detect.ts"))).toBe(true); + }); + + test("fuzzy-match.ts is reachable (via AddStageModal)", () => { + expect(reachable.has(join(SRC_DIR, "utils/fuzzy-match.ts"))).toBe(true); + }); + }); + + // ── Dead module detection ───────────────────────────────────── + + describe("dead module detection", () => { + test("reports unreachable modules", () => { + const unreachable = allModules.filter((m) => !reachable.has(m)); + const unreachableRelative = unreachable.map((m) => relative(SRC_DIR, m)); + + // Known dead modules (documented in wiring audit): + // - hooks/usePipeline.ts: App.tsx uses inline useReducer instead + // - executor/cache-manager.ts: executor uses state.cache Map directly + // If this list grows, investigate whether new modules need wiring. + const knownDead = new Set([ + "hooks/usePipeline.ts", + "executor/cache-manager.ts", + ]); + + const unknownDead = unreachableRelative.filter((m) => !knownDead.has(m)); + + if (unknownDead.length > 0) { + throw new Error( + `Found unexpected unreachable modules:\n` + + unknownDead.map((m) => ` - ${m}`).join("\n") + + `\n\nThese modules exist in src/explorer/ but are not imported ` + + `from the App.tsx component tree. Either wire them in or add ` + + `them to the knownDead set with a comment explaining why.`, + ); + } + }); + + test("usePipeline.ts is known dead (App.tsx uses inline useReducer)", () => { + const hookPath = join(SRC_DIR, "hooks/usePipeline.ts"); + expect(reachable.has(hookPath)).toBe(false); + }); + + test("cache-manager.ts is known dead (executor uses state.cache directly)", () => { + const cacheMgrPath = join(SRC_DIR, "executor/cache-manager.ts"); + expect(reachable.has(cacheMgrPath)).toBe(false); + }); + }); + + // ── Hook invocation checks ──────────────────────────────────── + // Verify hooks aren't just imported but are actually invoked within + // a component (the useExecution bug was: imported but not called). + + describe("hooks are invoked, not just imported", () => { + const hookNames = [ + "useExecution", + "useAutoSave", + "useUndoRedo", + "useVimIntegration", + ]; + + for (const hookName of hookNames) { + test(`${hookName} is called in a component`, () => { + // Search all reachable component files for actual invocation + let found = false; + for (const filePath of reachable) { + if (!filePath.endsWith(".tsx")) continue; + const content = readFileSync(filePath, "utf-8"); + // Match hook being called (not just imported) + const callPattern = new RegExp(`${hookName}\\s*\\(`); + if (callPattern.test(content)) { + found = true; + break; + } + } + expect(found).toBe(true); + }); + } + }); + + // ── Selector usage checks ───────────────────────────────────── + // Verify key selectors are used from components (not just internally). + + describe("key selectors are used from components", () => { + const keySelectors = [ + "getCursorStage", + "getCursorOutput", + "getActivePath", + "getDownstreamStages", + "getEnabledStages", + ]; + + for (const selector of keySelectors) { + test(`${selector} is used outside selectors.ts`, () => { + let usedExternally = false; + for (const filePath of reachable) { + if (filePath.endsWith("selectors.ts")) continue; + const content = readFileSync(filePath, "utf-8"); + if (content.includes(selector)) { + usedExternally = true; + break; + } + } + expect(usedExternally).toBe(true); + }); + } + }); +}); diff --git a/tests/explorer/model/edge-cases.test.ts b/tests/explorer/model/edge-cases.test.ts new file mode 100644 index 0000000..824fe0a --- /dev/null +++ b/tests/explorer/model/edge-cases.test.ts @@ -0,0 +1,653 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { + pipelineReducer, + createInitialState, +} from "../../../src/explorer/model/reducer.ts"; +import { + getActivePath, + isDownstreamOfError, + getStageOutput, + getDownstreamStages, + getEnabledStages, + getCursorStage, + getCursorOutput, + getTotalCacheSize, +} from "../../../src/explorer/model/selectors.ts"; +import type { + PipelineState, + StageConfig, + CachedResult, +} from "../../../src/explorer/model/types.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 makeCacheResult( + state: PipelineState, + stageId: string, + overrides?: Partial, +): CachedResult { + return { + key: `${state.activeInputId}:${stageId}`, + stageId, + inputId: state.activeInputId, + records: [], + lines: [], + spillFile: null, + recordCount: 10, + fieldNames: ["a"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 5, + ...overrides, + }; +} + +// ── Empty pipeline operations ──────────────────────────────────── + +describe("Empty pipeline operations", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("getActivePath returns empty for fresh state", () => { + expect(getActivePath(state)).toHaveLength(0); + }); + + test("cursorStageId is null in fresh state", () => { + expect(state.cursorStageId).toBeNull(); + }); + + test("MOVE_CURSOR is no-op on empty pipeline", () => { + const before = state; + state = pipelineReducer(state, { + type: "MOVE_CURSOR", + direction: "down", + }); + expect(state).toBe(before); + + state = pipelineReducer(state, { + type: "MOVE_CURSOR", + direction: "up", + }); + expect(state).toBe(before); + }); + + test("TOGGLE_FOCUS works on empty pipeline", () => { + expect(state.focusedPanel).toBe("pipeline"); + state = pipelineReducer(state, { type: "TOGGLE_FOCUS" }); + expect(state.focusedPanel).toBe("inspector"); + }); + + test("getEnabledStages returns empty for fresh state", () => { + expect(getEnabledStages(state)).toHaveLength(0); + }); + + test("getCursorStage returns undefined for fresh state", () => { + expect(getCursorStage(state)).toBeUndefined(); + }); + + test("getCursorOutput returns undefined for fresh state", () => { + expect(getCursorOutput(state)).toBeUndefined(); + }); + + test("getTotalCacheSize returns 0 for fresh state", () => { + expect(getTotalCacheSize(state)).toBe(0); + }); +}); + +// ── Delete last stage ──────────────────────────────────────────── + +describe("Delete last stage", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("deleting the only stage leaves pipeline empty", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: id }); + + expect(getActivePath(state)).toHaveLength(0); + expect(state.cursorStageId).toBeNull(); + expect(state.stages.size).toBe(0); + }); + + test("deleting the last of multiple stages moves cursor to previous", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + const cId = state.cursorStageId!; + + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: cId }); + + expect(getActivePath(state)).toHaveLength(2); + // Cursor should point to the new last stage + const lastStage = getActivePath(state).at(-1)!; + expect(state.cursorStageId).toBe(lastStage.id); + }); + + test("deleting the first of multiple stages moves cursor to new first", () => { + state = addStage(state, "a"); + const aId = getActivePath(state)[0]!.id; + state = addStage(state, "b"); + state = addStage(state, "c"); + + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: aId }); + + expect(getActivePath(state)).toHaveLength(2); + const firstStage = getActivePath(state)[0]!; + expect(state.cursorStageId).toBe(firstStage.id); + expect(firstStage.parentId).toBeNull(); + }); + + test("delete all stages one by one", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + // Delete c + const cId = getActivePath(state)[2]!.id; + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: cId }); + expect(getActivePath(state)).toHaveLength(2); + + // Delete b + const bId = getActivePath(state)[1]!.id; + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: bId }); + expect(getActivePath(state)).toHaveLength(1); + + // Delete a + const aId = getActivePath(state)[0]!.id; + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: aId }); + expect(getActivePath(state)).toHaveLength(0); + expect(state.cursorStageId).toBeNull(); + }); +}); + +// ── Error propagation edge cases ───────────────────────────────── + +describe("Error propagation edge cases", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("error on first stage marks all others as downstream", () => { + 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: "bad expression", + }); + + expect(isDownstreamOfError(state, aId)).toBe(false); + expect(isDownstreamOfError(state, bId)).toBe(true); + expect(isDownstreamOfError(state, cId)).toBe(true); + }); + + test("error on last stage marks nothing as downstream", () => { + 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: cId, + message: "bad expression", + }); + + expect(isDownstreamOfError(state, aId)).toBe(false); + expect(isDownstreamOfError(state, bId)).toBe(false); + expect(isDownstreamOfError(state, cId)).toBe(false); + }); + + test("clearing error resets all downstream flags", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "SET_ERROR", + stageId: aId, + message: "bad", + }); + expect(isDownstreamOfError(state, bId)).toBe(true); + + state = pipelineReducer(state, { type: "CLEAR_ERROR" }); + expect(isDownstreamOfError(state, bId)).toBe(false); + }); + + test("deleting the error stage clears the error", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const bId = state.cursorStageId!; + state = addStage(state, "c"); + + state = pipelineReducer(state, { + type: "SET_ERROR", + stageId: bId, + message: "bad", + }); + expect(state.lastError).not.toBeNull(); + + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: bId }); + expect(state.lastError).toBeNull(); + }); + + test("deleting a non-error stage does not clear the error", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + state = addStage(state, "c"); + const cId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "SET_ERROR", + stageId: aId, + message: "bad", + }); + + state = pipelineReducer(state, { type: "DELETE_STAGE", stageId: cId }); + expect(state.lastError).not.toBeNull(); + expect(state.lastError!.stageId).toBe(aId); + }); + + test("isDownstreamOfError with no error returns false for all", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + expect(isDownstreamOfError(state, aId)).toBe(false); + expect(isDownstreamOfError(state, bId)).toBe(false); + }); +}); + +// ── PIN_STAGE ─────────────────────────────────────────────────── + +describe("PIN_STAGE", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("pinning a stage adds it to pinnedStageIds", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { type: "PIN_STAGE", stageId: id }); + expect(state.cacheConfig.pinnedStageIds.has(id)).toBe(true); + }); + + test("pinning an already-pinned stage unpins it", () => { + state = addStage(state, "grep"); + const id = state.cursorStageId!; + + state = pipelineReducer(state, { type: "PIN_STAGE", stageId: id }); + expect(state.cacheConfig.pinnedStageIds.has(id)).toBe(true); + + state = pipelineReducer(state, { type: "PIN_STAGE", stageId: id }); + expect(state.cacheConfig.pinnedStageIds.has(id)).toBe(false); + }); + + test("multiple stages can be pinned", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + state = pipelineReducer(state, { type: "PIN_STAGE", stageId: aId }); + state = pipelineReducer(state, { type: "PIN_STAGE", stageId: bId }); + + expect(state.cacheConfig.pinnedStageIds.size).toBe(2); + }); +}); + +// ── SET_CACHE_POLICY ───────────────────────────────────────────── + +describe("SET_CACHE_POLICY", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("changes cache policy to selective", () => { + state = pipelineReducer(state, { + type: "SET_CACHE_POLICY", + policy: "selective", + }); + expect(state.cacheConfig.cachePolicy).toBe("selective"); + }); + + test("changes cache policy to none", () => { + state = pipelineReducer(state, { + type: "SET_CACHE_POLICY", + policy: "none", + }); + expect(state.cacheConfig.cachePolicy).toBe("none"); + }); + + test("changes cache policy back to all", () => { + state = pipelineReducer(state, { + type: "SET_CACHE_POLICY", + policy: "none", + }); + state = pipelineReducer(state, { + type: "SET_CACHE_POLICY", + policy: "all", + }); + expect(state.cacheConfig.cachePolicy).toBe("all"); + }); +}); + +// ── SET_VIEW_MODE ─────────────────────────────────────────────── + +describe("SET_VIEW_MODE", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("defaults to table", () => { + expect(state.inspector.viewMode).toBe("table"); + }); + + test("cycles through view modes", () => { + const modes = ["prettyprint", "json", "schema", "table"] as const; + for (const mode of modes) { + state = pipelineReducer(state, { type: "SET_VIEW_MODE", viewMode: mode }); + expect(state.inspector.viewMode).toBe(mode); + } + }); +}); + +// ── SET_SESSION_NAME ───────────────────────────────────────────── + +describe("SET_SESSION_NAME", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("sets session name", () => { + state = pipelineReducer(state, { + type: "SET_SESSION_NAME", + name: "my experiment", + }); + expect(state.sessionName).toBe("my experiment"); + }); + + test("overwrites existing name", () => { + state = pipelineReducer(state, { + type: "SET_SESSION_NAME", + name: "first", + }); + state = pipelineReducer(state, { + type: "SET_SESSION_NAME", + name: "second", + }); + expect(state.sessionName).toBe("second"); + }); +}); + +// ── getDownstreamStages edge cases ─────────────────────────────── + +describe("getDownstreamStages edge cases", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("returns empty for last stage", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + expect(getDownstreamStages(state, bId)).toHaveLength(0); + }); + + test("returns empty for unknown stageId", () => { + state = addStage(state, "a"); + expect(getDownstreamStages(state, "nonexistent")).toHaveLength(0); + }); + + test("returns all subsequent stages", () => { + state = addStage(state, "a"); + const aId = getActivePath(state)[0]!.id; + state = addStage(state, "b"); + state = addStage(state, "c"); + state = addStage(state, "d"); + + const downstream = getDownstreamStages(state, aId); + expect(downstream.map((s) => s.config.operationName)).toEqual([ + "b", + "c", + "d", + ]); + }); +}); + +// ── Cache invalidation on stage operations ─────────────────────── + +describe("Cache invalidation on stage operations", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }); + }); + + test("UPDATE_STAGE_ARGS invalidates modified stage and downstream", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + state = addStage(state, "c"); + const cId = state.cursorStageId!; + + // Populate cache for all stages + for (const id of [aId, bId, cId]) { + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: id, + result: makeCacheResult(state, id), + }); + } + expect(state.cache.size).toBe(3); + + // Update b's args — should invalidate b and c, keep a + state = pipelineReducer(state, { + type: "UPDATE_STAGE_ARGS", + stageId: bId, + args: ["new-arg"], + }); + + expect(getStageOutput(state, aId)).toBeDefined(); + expect(getStageOutput(state, bId)).toBeUndefined(); + expect(getStageOutput(state, cId)).toBeUndefined(); + }); + + test("TOGGLE_STAGE invalidates toggled stage and downstream", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + // Populate cache + for (const id of [aId, bId]) { + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: id, + result: makeCacheResult(state, id), + }); + } + + // Toggle a — should invalidate both + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: aId }); + + expect(getStageOutput(state, aId)).toBeUndefined(); + expect(getStageOutput(state, bId)).toBeUndefined(); + }); + + test("INVALIDATE_STAGE removes all cache entries for that stage", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: aId, + result: makeCacheResult(state, aId), + }); + expect(getStageOutput(state, aId)).toBeDefined(); + + state = pipelineReducer(state, { + type: "INVALIDATE_STAGE", + stageId: aId, + }); + expect(getStageOutput(state, aId)).toBeUndefined(); + }); + + test("getTotalCacheSize tracks cumulative size", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: aId, + result: makeCacheResult(state, aId, { sizeBytes: 500 }), + }); + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: bId, + result: makeCacheResult(state, bId, { sizeBytes: 300 }), + }); + + expect(getTotalCacheSize(state)).toBe(800); + }); +}); + +// ── wouldBeNoop guard ──────────────────────────────────────────── + +describe("wouldBeNoop guard (no-op actions skip undo checkpoint)", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("DELETE_STAGE on nonexistent id is no-op and does not push undo", () => { + state = addStage(state, "a"); + const undoLen = state.undoStack.length; + + state = pipelineReducer(state, { + type: "DELETE_STAGE", + stageId: "nonexistent", + }); + expect(state.undoStack.length).toBe(undoLen); + }); + + test("UPDATE_STAGE_ARGS on nonexistent id is no-op", () => { + state = addStage(state, "a"); + const undoLen = state.undoStack.length; + + state = pipelineReducer(state, { + type: "UPDATE_STAGE_ARGS", + stageId: "nonexistent", + args: ["x"], + }); + expect(state.undoStack.length).toBe(undoLen); + }); + + test("TOGGLE_STAGE on nonexistent id is no-op", () => { + const before = state; + state = pipelineReducer(state, { + type: "TOGGLE_STAGE", + stageId: "nonexistent", + }); + expect(state).toBe(before); + }); + + test("REORDER_STAGE at boundary is no-op and does not push undo", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const aId = getActivePath(state)[0]!.id; + const undoLen = state.undoStack.length; + + // Moving first stage up is a no-op + state = pipelineReducer(state, { + type: "REORDER_STAGE", + stageId: aId, + direction: "up", + }); + expect(state.undoStack.length).toBe(undoLen); + }); + + test("DELETE_FORK on main fork is no-op", () => { + const before = state; + state = pipelineReducer(state, { + type: "DELETE_FORK", + forkId: state.activeForkId, + }); + expect(state).toBe(before); + }); + + test("REMOVE_INPUT with single input is no-op", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "only", + }); + const before = state; + + state = pipelineReducer(state, { + type: "REMOVE_INPUT", + inputId: state.activeInputId, + }); + expect(state).toBe(before); + }); +}); diff --git a/tests/explorer/model/fork-operations.test.ts b/tests/explorer/model/fork-operations.test.ts new file mode 100644 index 0000000..09fd82f --- /dev/null +++ b/tests/explorer/model/fork-operations.test.ts @@ -0,0 +1,570 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { + pipelineReducer, + createInitialState, +} from "../../../src/explorer/model/reducer.ts"; +import { + getActivePath, + getStageOutput, +} from "../../../src/explorer/model/selectors.ts"; +import type { + PipelineState, + StageConfig, +} from "../../../src/explorer/model/types.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), + }); +} + +// ── CREATE_FORK ────────────────────────────────────────────────── + +describe("CREATE_FORK", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("creates a new fork and switches to it", () => { + state = addStage(state, "grep"); + const stageId = state.cursorStageId!; + const originalForkId = state.activeForkId; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "experiment-1", + atStageId: stageId, + }); + + expect(state.activeForkId).not.toBe(originalForkId); + expect(state.forks.size).toBe(2); + + const newFork = state.forks.get(state.activeForkId)!; + expect(newFork.name).toBe("experiment-1"); + expect(newFork.forkPointStageId).toBe(stageId); + expect(newFork.parentForkId).toBe(originalForkId); + expect(newFork.stageIds).toEqual([]); + }); + + test("new fork starts with empty stage list", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const bId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: bId, + }); + + // Active path should be empty (new fork has no stages) + expect(getActivePath(state)).toHaveLength(0); + }); + + test("original fork is preserved after creating a new fork", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const originalForkId = state.activeForkId; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + + // Original fork should still have its stages + const originalFork = state.forks.get(originalForkId)!; + expect(originalFork.stageIds).toHaveLength(2); + }); + + test("multiple forks can be created", () => { + state = addStage(state, "a"); + const stageId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "fork-1", + atStageId: stageId, + }); + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "fork-2", + atStageId: stageId, + }); + + expect(state.forks.size).toBe(3); + }); + + test("CREATE_FORK is undoable", () => { + state = addStage(state, "a"); + const originalForkId = state.activeForkId; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + expect(state.forks.size).toBe(2); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.forks.size).toBe(1); + expect(state.activeForkId).toBe(originalForkId); + }); +}); + +// ── DELETE_FORK ────────────────────────────────────────────────── + +describe("DELETE_FORK", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("deletes a non-main fork", () => { + state = addStage(state, "a"); + const originalForkId = state.activeForkId; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + const branchForkId = state.activeForkId; + + // Add a stage to the new fork + state = addStage(state, "b"); + + state = pipelineReducer(state, { + type: "DELETE_FORK", + forkId: branchForkId, + }); + + expect(state.forks.size).toBe(1); + expect(state.forks.has(branchForkId)).toBe(false); + expect(state.activeForkId).toBe(originalForkId); + }); + + test("deleting a fork removes its stages from the stages map", () => { + state = addStage(state, "a"); + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + const branchForkId = state.activeForkId; + + // Add stages to the branch + state = addStage(state, "b"); + state = addStage(state, "c"); + const stageCountBefore = state.stages.size; + + state = pipelineReducer(state, { + type: "DELETE_FORK", + forkId: branchForkId, + }); + + // The branch stages should be removed + expect(state.stages.size).toBe(stageCountBefore - 2); + }); + + test("cannot delete the main fork (no parent)", () => { + const before = state; + + state = pipelineReducer(state, { + type: "DELETE_FORK", + forkId: state.activeForkId, + }); + + // Should be no-op + expect(state).toBe(before); + expect(state.forks.has(state.activeForkId)).toBe(true); + }); + + test("DELETE_FORK sets cursor to null", () => { + state = addStage(state, "a"); + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + const branchForkId = state.activeForkId; + state = addStage(state, "b"); + expect(state.cursorStageId).not.toBeNull(); + + state = pipelineReducer(state, { + type: "DELETE_FORK", + forkId: branchForkId, + }); + + expect(state.cursorStageId).toBeNull(); + }); + + test("DELETE_FORK is undoable", () => { + state = addStage(state, "a"); + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + const branchForkId = state.activeForkId; + state = addStage(state, "b"); + + state = pipelineReducer(state, { + type: "DELETE_FORK", + forkId: branchForkId, + }); + expect(state.forks.size).toBe(1); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.forks.size).toBe(2); + expect(state.forks.has(branchForkId)).toBe(true); + }); + + test("deleting nonexistent fork is no-op", () => { + const before = state; + state = pipelineReducer(state, { + type: "DELETE_FORK", + forkId: "nonexistent", + }); + expect(state).toBe(before); + }); +}); + +// ── SWITCH_FORK ───────────────────────────────────────────────── + +describe("SWITCH_FORK", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("switches active fork", () => { + state = addStage(state, "a"); + const mainForkId = state.activeForkId; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + const branchForkId = state.activeForkId; + + // Switch back to main + state = pipelineReducer(state, { + type: "SWITCH_FORK", + forkId: mainForkId, + }); + expect(state.activeForkId).toBe(mainForkId); + + // Switch to branch + state = pipelineReducer(state, { + type: "SWITCH_FORK", + forkId: branchForkId, + }); + expect(state.activeForkId).toBe(branchForkId); + }); + + test("switching fork resets cursor to null", () => { + state = addStage(state, "a"); + const mainForkId = state.activeForkId; + expect(state.cursorStageId).not.toBeNull(); + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + + // Switch back to main + state = pipelineReducer(state, { + type: "SWITCH_FORK", + forkId: mainForkId, + }); + expect(state.cursorStageId).toBeNull(); + }); + + test("getActivePath returns stages from the active fork", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const mainForkId = state.activeForkId; + + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: state.cursorStageId!, + }); + state = addStage(state, "c"); + + // Active fork is branch — path should have 1 stage + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "c", + ]); + + // Switch back to main + state = pipelineReducer(state, { + type: "SWITCH_FORK", + forkId: mainForkId, + }); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "a", + "b", + ]); + }); +}); + +// ── Fork-aware caching ────────────────────────────────────────── + +describe("Fork-aware caching", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("cache entries from one fork are separate from another", () => { + state = addStage(state, "grep"); + const grepId = state.cursorStageId!; + const mainForkId = state.activeForkId; + + // Cache a result for grep in main fork + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: grepId, + result: { + key: `${state.activeInputId}:${grepId}`, + stageId: grepId, + inputId: state.activeInputId, + records: [], + lines: [], + spillFile: null, + recordCount: 5, + fieldNames: ["x"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 5, + }, + }); + + expect(getStageOutput(state, grepId)).toBeDefined(); + + // Create a branch and add a different stage + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: grepId, + }); + state = addStage(state, "sort"); + const sortId = state.cursorStageId!; + + // Sort in the branch should NOT have cached output + expect(getStageOutput(state, sortId)).toBeUndefined(); + + // Switch back to main — grep cache should still be there + state = pipelineReducer(state, { + type: "SWITCH_FORK", + forkId: mainForkId, + }); + expect(getStageOutput(state, grepId)).toBeDefined(); + }); + + test("toggling a stage in one fork does not affect another fork's cache", () => { + state = addStage(state, "a"); + const aId = state.cursorStageId!; + state = addStage(state, "b"); + const bId = state.cursorStageId!; + const mainForkId = state.activeForkId; + + // Cache both stages + for (const id of [aId, bId]) { + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: id, + result: { + key: `${state.activeInputId}:${id}`, + stageId: id, + inputId: state.activeInputId, + records: [], + lines: [], + spillFile: null, + recordCount: 5, + fieldNames: ["x"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 5, + }, + }); + } + + // Create a branch + state = pipelineReducer(state, { + type: "CREATE_FORK", + name: "branch", + atStageId: bId, + }); + state = addStage(state, "c"); + const cId = state.cursorStageId!; + + // Cache the branch stage + state = pipelineReducer(state, { + type: "CACHE_RESULT", + inputId: state.activeInputId, + stageId: cId, + result: { + key: `${state.activeInputId}:${cId}`, + stageId: cId, + inputId: state.activeInputId, + records: [], + lines: [], + spillFile: null, + recordCount: 3, + fieldNames: ["x"], + computedAt: Date.now(), + sizeBytes: 50, + computeTimeMs: 2, + }, + }); + + // Switch to main and toggle stage a — invalidates a and b in main fork + state = pipelineReducer(state, { + type: "SWITCH_FORK", + forkId: mainForkId, + }); + state = pipelineReducer(state, { type: "TOGGLE_STAGE", stageId: aId }); + + // Main fork caches should be invalidated + expect(getStageOutput(state, aId)).toBeUndefined(); + expect(getStageOutput(state, bId)).toBeUndefined(); + + // Branch stage cache should still be intact (different key) + expect(state.cache.has(`${state.activeInputId}:${cId}`)).toBe(true); + }); +}); + +// ── ADD_INPUT / REMOVE_INPUT ──────────────────────────────────── + +describe("ADD_INPUT / REMOVE_INPUT", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("ADD_INPUT creates a new input and switches to it", () => { + const oldInputId = state.activeInputId; + + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "test-input", + }); + + expect(state.inputs.size).toBe(1); + expect(state.activeInputId).not.toBe(oldInputId); + }); + + test("ADD_INPUT with file source", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "file", path: "/tmp/data.jsonl" }, + label: "data.jsonl", + }); + + const input = state.inputs.get(state.activeInputId)!; + expect(input.source.kind).toBe("file"); + expect(input.label).toBe("data.jsonl"); + }); + + test("ADD_INPUT is undoable", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }); + expect(state.inputs.size).toBe(1); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.inputs.size).toBe(0); + }); + + test("REMOVE_INPUT removes an input", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "input-1", + }); + const input1Id = state.activeInputId; + + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "input-2", + }); + expect(state.inputs.size).toBe(2); + + state = pipelineReducer(state, { + type: "REMOVE_INPUT", + inputId: input1Id, + }); + expect(state.inputs.size).toBe(1); + expect(state.inputs.has(input1Id)).toBe(false); + }); + + test("REMOVE_INPUT is no-op when only one input remains", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "only-input", + }); + const inputId = state.activeInputId; + const before = state; + + state = pipelineReducer(state, { + type: "REMOVE_INPUT", + inputId: inputId, + }); + expect(state).toBe(before); + }); + + test("REMOVE_INPUT switches activeInputId if the active one is removed", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "input-1", + }); + const input1Id = state.activeInputId; + + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "input-2", + }); + const input2Id = state.activeInputId; + + // Remove active input + state = pipelineReducer(state, { + type: "REMOVE_INPUT", + inputId: input2Id, + }); + + expect(state.activeInputId).toBe(input1Id); + }); +}); diff --git a/tests/tui/model/reducer.test.ts b/tests/explorer/model/reducer.test.ts similarity index 98% rename from tests/tui/model/reducer.test.ts rename to tests/explorer/model/reducer.test.ts index a699632..dc4c144 100644 --- a/tests/tui/model/reducer.test.ts +++ b/tests/explorer/model/reducer.test.ts @@ -2,18 +2,18 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { pipelineReducer, createInitialState, -} from "../../../src/tui/model/reducer.ts"; +} from "../../../src/explorer/model/reducer.ts"; import type { PipelineState, StageConfig, -} from "../../../src/tui/model/types.ts"; +} from "../../../src/explorer/model/types.ts"; import { getActivePath, isDownstreamOfError, getStageOutput, getDownstreamStages, -} from "../../../src/tui/model/selectors.ts"; -import { extractSnapshot, describeAction } from "../../../src/tui/model/undo.ts"; +} from "../../../src/explorer/model/selectors.ts"; +import { extractSnapshot, describeAction } from "../../../src/explorer/model/undo.ts"; function makeConfig(name: string, args: string[] = []): StageConfig { return { operationName: name, args, enabled: true }; @@ -310,6 +310,7 @@ describe("pipelineReducer", () => { stageId, inputId: state.activeInputId, records: [], + lines: [] as string[], spillFile: null, recordCount: 10, fieldNames: ["a"], @@ -368,6 +369,7 @@ describe("pipelineReducer", () => { stageId, inputId: state.activeInputId, records: [], + lines: [] as string[], spillFile: null, recordCount: 10, fieldNames: ["a"], @@ -608,6 +610,7 @@ describe("pipelineReducer", () => { stageId: id, inputId: state.activeInputId, records: [], + lines: [] as string[], spillFile: null, recordCount: 5, fieldNames: ["ip", "status"], diff --git a/tests/tui/model/serialization.test.ts b/tests/explorer/model/serialization.test.ts similarity index 97% rename from tests/tui/model/serialization.test.ts rename to tests/explorer/model/serialization.test.ts index b087a53..5e059f7 100644 --- a/tests/tui/model/serialization.test.ts +++ b/tests/explorer/model/serialization.test.ts @@ -3,14 +3,14 @@ import { exportAsPipeScript, exportAsChainCommand, shellEscape, -} from "../../../src/tui/model/serialization.ts"; +} from "../../../src/explorer/model/serialization.ts"; import type { PipelineState, Stage, InputSource, CacheConfig, InspectorState, -} from "../../../src/tui/model/types.ts"; +} from "../../../src/explorer/model/types.ts"; // ── Helpers ────────────────────────────────────────────────────────── @@ -66,6 +66,7 @@ function makePipelineState( viewMode: "table", scrollOffset: 0, searchQuery: null, + highlightedColumn: null, }; return { @@ -96,7 +97,7 @@ function makePipelineState( undoStack: [], redoStack: [], sessionId: "test-session", - sessionDir: "/tmp/recs-tui-test", + sessionDir: "/tmp/recs-explorer-test", }; } diff --git a/tests/explorer/model/undo.test.ts b/tests/explorer/model/undo.test.ts new file mode 100644 index 0000000..de77256 --- /dev/null +++ b/tests/explorer/model/undo.test.ts @@ -0,0 +1,466 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { + extractSnapshot, + describeAction, + UNDOABLE_ACTIONS, + MAX_UNDO_ENTRIES, +} from "../../../src/explorer/model/undo.ts"; +import { + pipelineReducer, + createInitialState, +} from "../../../src/explorer/model/reducer.ts"; +import { getActivePath } from "../../../src/explorer/model/selectors.ts"; +import type { + PipelineState, + PipelineAction, + StageConfig, +} from "../../../src/explorer/model/types.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), + }); +} + +// ── extractSnapshot ────────────────────────────────────────────── + +describe("extractSnapshot", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("creates a deep copy of stages map", () => { + state = addStage(state, "grep"); + const snapshot = extractSnapshot(state); + + state.stages.clear(); + expect(snapshot.stages.size).toBe(1); + }); + + test("creates a deep copy of forks map", () => { + state = addStage(state, "grep"); + const snapshot = extractSnapshot(state); + + state.forks.clear(); + expect(snapshot.forks.size).toBe(1); + }); + + test("creates a deep copy of inputs map", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }); + const snapshot = extractSnapshot(state); + + state.inputs.clear(); + expect(snapshot.inputs.size).toBe(1); + }); + + test("deep-copies childIds arrays on stages", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const snapshot = extractSnapshot(state); + + // Mutate the original stage's childIds + const firstStage = Array.from(state.stages.values())[0]!; + firstStage.childIds.push("fake-id"); + + // Snapshot should not be affected + const snapshotStage = Array.from(snapshot.stages.values())[0]!; + expect(snapshotStage.childIds).not.toContain("fake-id"); + }); + + test("deep-copies stageIds arrays on forks", () => { + state = addStage(state, "a"); + const snapshot = extractSnapshot(state); + + // Mutate the original fork's stageIds + const fork = Array.from(state.forks.values())[0]!; + fork.stageIds.push("fake-id"); + + // Snapshot should not be affected + const snapshotFork = Array.from(snapshot.forks.values())[0]!; + expect(snapshotFork.stageIds).not.toContain("fake-id"); + }); + + test("preserves cursor position", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + const cursorId = state.cursorStageId; + + const snapshot = extractSnapshot(state); + expect(snapshot.cursorStageId).toBe(cursorId); + }); + + test("preserves activeInputId and activeForkId", () => { + state = pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "test", + }); + const snapshot = extractSnapshot(state); + + expect(snapshot.activeInputId).toBe(state.activeInputId); + expect(snapshot.activeForkId).toBe(state.activeForkId); + }); + + test("snapshot of empty state has empty maps", () => { + const snapshot = extractSnapshot(state); + expect(snapshot.stages.size).toBe(0); + expect(snapshot.cursorStageId).toBeNull(); + }); +}); + +// ── describeAction ─────────────────────────────────────────────── + +describe("describeAction", () => { + test("ADD_STAGE includes operation name", () => { + const result = describeAction({ + type: "ADD_STAGE", + afterStageId: null, + config: makeConfig("grep"), + }); + expect(result).toBe("Add grep stage"); + }); + + test("ADD_STAGE with different operation", () => { + const result = describeAction({ + type: "ADD_STAGE", + afterStageId: "some-id", + config: makeConfig("sort", ["--key", "x=n"]), + }); + expect(result).toBe("Add sort stage"); + }); + + test("DELETE_STAGE", () => { + expect(describeAction({ type: "DELETE_STAGE", stageId: "x" })).toBe( + "Delete stage", + ); + }); + + test("UPDATE_STAGE_ARGS", () => { + expect( + describeAction({ + type: "UPDATE_STAGE_ARGS", + stageId: "x", + args: ["new-args"], + }), + ).toBe("Update stage arguments"); + }); + + test("TOGGLE_STAGE", () => { + expect( + describeAction({ type: "TOGGLE_STAGE", stageId: "x" }), + ).toBe("Toggle stage enabled"); + }); + + test("INSERT_STAGE_BEFORE includes operation name", () => { + expect( + describeAction({ + type: "INSERT_STAGE_BEFORE", + beforeStageId: "x", + config: makeConfig("fromcsv"), + }), + ).toBe("Insert fromcsv stage"); + }); + + test("CREATE_FORK includes fork name", () => { + expect( + describeAction({ + type: "CREATE_FORK", + name: "experiment-1", + atStageId: "x", + }), + ).toBe('Create fork "experiment-1"'); + }); + + test("DELETE_FORK", () => { + expect( + describeAction({ type: "DELETE_FORK", forkId: "x" }), + ).toBe("Delete fork"); + }); + + test("ADD_INPUT includes label", () => { + expect( + describeAction({ + type: "ADD_INPUT", + source: { kind: "stdin-capture", records: [] }, + label: "data.jsonl", + }), + ).toBe('Add input "data.jsonl"'); + }); + + test("REMOVE_INPUT", () => { + expect( + describeAction({ type: "REMOVE_INPUT", inputId: "x" }), + ).toBe("Remove input"); + }); + + test("REORDER_STAGE up", () => { + expect( + describeAction({ + type: "REORDER_STAGE", + stageId: "x", + direction: "up", + }), + ).toBe("Move stage up"); + }); + + test("REORDER_STAGE down", () => { + expect( + describeAction({ + type: "REORDER_STAGE", + stageId: "x", + direction: "down", + }), + ).toBe("Move stage down"); + }); + + test("non-undoable action returns type name", () => { + expect( + describeAction({ type: "TOGGLE_FOCUS" } as PipelineAction), + ).toBe("TOGGLE_FOCUS"); + }); +}); + +// ── UNDOABLE_ACTIONS ────────────────────────────────────────────── + +describe("UNDOABLE_ACTIONS", () => { + test("contains all structural actions", () => { + const expected = [ + "ADD_STAGE", + "DELETE_STAGE", + "UPDATE_STAGE_ARGS", + "TOGGLE_STAGE", + "INSERT_STAGE_BEFORE", + "CREATE_FORK", + "DELETE_FORK", + "ADD_INPUT", + "REMOVE_INPUT", + "REORDER_STAGE", + ]; + for (const action of expected) { + expect(UNDOABLE_ACTIONS.has(action as PipelineAction["type"])).toBe(true); + } + }); + + test("does not contain non-structural actions", () => { + const nonUndoable = [ + "UNDO", + "REDO", + "MOVE_CURSOR", + "SET_CURSOR", + "SWITCH_INPUT", + "SWITCH_FORK", + "CACHE_RESULT", + "INVALIDATE_STAGE", + "PIN_STAGE", + "SET_CACHE_POLICY", + "SET_ERROR", + "CLEAR_ERROR", + "SET_EXECUTING", + "TOGGLE_FOCUS", + "SET_VIEW_MODE", + "SET_SESSION_NAME", + ]; + for (const action of nonUndoable) { + expect(UNDOABLE_ACTIONS.has(action as PipelineAction["type"])).toBe( + false, + ); + } + }); + + test("has exactly 10 entries", () => { + expect(UNDOABLE_ACTIONS.size).toBe(10); + }); +}); + +// ── MAX_UNDO_ENTRIES ───────────────────────────────────────────── + +describe("MAX_UNDO_ENTRIES", () => { + test("is 200", () => { + expect(MAX_UNDO_ENTRIES).toBe(200); + }); +}); + +// ── Undo/redo edge cases ───────────────────────────────────────── + +describe("Undo/redo edge cases", () => { + let state: PipelineState; + + beforeEach(() => { + state = createInitialState(); + }); + + test("undo on empty pipeline is no-op", () => { + const before = state; + const after = pipelineReducer(state, { type: "UNDO" }); + expect(after).toBe(before); + expect(after.undoStack).toHaveLength(0); + expect(after.redoStack).toHaveLength(0); + }); + + test("redo on empty pipeline is no-op", () => { + const before = state; + const after = pipelineReducer(state, { type: "REDO" }); + expect(after).toBe(before); + }); + + test("undo past empty: add stage then undo returns to empty", () => { + state = addStage(state, "grep"); + expect(getActivePath(state)).toHaveLength(1); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(getActivePath(state)).toHaveLength(0); + expect(state.cursorStageId).toBeNull(); + }); + + test("undo past empty then redo restores stage", () => { + 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("multiple undos past empty: add 3, undo 3", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + state = addStage(state, "c"); + + state = pipelineReducer(state, { type: "UNDO" }); + state = pipelineReducer(state, { type: "UNDO" }); + state = pipelineReducer(state, { type: "UNDO" }); + + expect(getActivePath(state)).toHaveLength(0); + expect(state.cursorStageId).toBeNull(); + + // Extra undo should be no-op + const before = state; + state = pipelineReducer(state, { type: "UNDO" }); + expect(state).toBe(before); + }); + + test("undo + new action clears redo stack", () => { + state = addStage(state, "a"); + state = addStage(state, "b"); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.redoStack).toHaveLength(1); + + // New structural action clears redo + state = addStage(state, "c"); + expect(state.redoStack).toHaveLength(0); + + // Redo should now be no-op + const before = state; + state = pipelineReducer(state, { type: "REDO" }); + expect(state).toBe(before); + }); + + test("undo stack is capped at MAX_UNDO_ENTRIES", () => { + for (let i = 0; i < MAX_UNDO_ENTRIES + 20; i++) { + state = addStage(state, `op-${i}`); + } + expect(state.undoStack.length).toBe(MAX_UNDO_ENTRIES); + // All stages should still exist — cap only limits undo history + expect(getActivePath(state)).toHaveLength(MAX_UNDO_ENTRIES + 20); + }); + + test("undo restores stage args after UPDATE_STAGE_ARGS", () => { + 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"]); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(state.stages.get(id)!.config.args).toEqual(["status=200"]); + }); + + test("undo restores reorder", () => { + 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", + }); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "b", + "a", + "c", + ]); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "a", + "b", + "c", + ]); + }); + + test("undo DELETE_STAGE restores the stage", () => { + 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 }); + expect(getActivePath(state)).toHaveLength(2); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(getActivePath(state)).toHaveLength(3); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "a", + "b", + "c", + ]); + }); + + test("undo INSERT_STAGE_BEFORE restores previous state", () => { + state = addStage(state, "a"); + state = addStage(state, "c"); + const cId = state.cursorStageId!; + + state = pipelineReducer(state, { + type: "INSERT_STAGE_BEFORE", + beforeStageId: cId, + config: makeConfig("b"), + }); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "a", + "b", + "c", + ]); + + state = pipelineReducer(state, { type: "UNDO" }); + expect(getActivePath(state).map((s) => s.config.operationName)).toEqual([ + "a", + "c", + ]); + }); +}); diff --git a/tests/explorer/session/auto-save.test.ts b/tests/explorer/session/auto-save.test.ts new file mode 100644 index 0000000..e0a2917 --- /dev/null +++ b/tests/explorer/session/auto-save.test.ts @@ -0,0 +1,241 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { createAutoSave } from "../../../src/explorer/session/auto-save.ts"; +import { SessionManager } from "../../../src/explorer/session/session-manager.ts"; +import { + createInitialState, + pipelineReducer, +} from "../../../src/explorer/model/reducer.ts"; +import type { + PipelineState, + StageConfig, +} from "../../../src/explorer/model/types.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), + }); +} + +describe("createAutoSave", () => { + let tempDir: string; + let manager: SessionManager; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "recs-autosave-test-")); + manager = new SessionManager(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("saveNow writes session to disk immediately", async () => { + const controller = createAutoSave(manager, { + intervalMs: 60_000, // long interval to prevent interference + debounceMs: 60_000, + }); + + let state = createInitialState(); + state = addStage(state, "grep", ["status=200"]); + + await controller.saveNow(state); + controller.dispose(); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.sessionId).toBe(state.sessionId); + }); + + test("onAction marks dirty on structural actions", async () => { + const controller = createAutoSave(manager, { + intervalMs: 60_000, + debounceMs: 100, // short debounce for testing + }); + + let state = createInitialState(); + state = addStage(state, "grep"); + + // Notify auto-save of the structural action + controller.onAction( + { + type: "ADD_STAGE", + afterStageId: null, + config: makeConfig("grep"), + }, + state, + ); + + // Wait for debounce to fire + await new Promise((r) => setTimeout(r, 200)); + controller.dispose(); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + }); + + test("onAction does NOT trigger save for non-structural actions", async () => { + const controller = createAutoSave(manager, { + intervalMs: 60_000, + debounceMs: 50, + }); + + let state = createInitialState(); + state = addStage(state, "grep"); + + // Dispatch a non-structural action + controller.onAction({ type: "TOGGLE_FOCUS" }, state); + + // Wait beyond the debounce period + await new Promise((r) => setTimeout(r, 150)); + controller.dispose(); + + // No save should have occurred + const sessions = await manager.list(); + expect(sessions).toHaveLength(0); + }); + + test("dispose cancels pending timers", async () => { + const controller = createAutoSave(manager, { + intervalMs: 100, + debounceMs: 100, + }); + + let state = createInitialState(); + state = addStage(state, "grep"); + + controller.onAction( + { + type: "ADD_STAGE", + afterStageId: null, + config: makeConfig("grep"), + }, + state, + ); + + // Dispose before debounce fires + controller.dispose(); + + // Wait to verify no save happens + await new Promise((r) => setTimeout(r, 200)); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(0); + }); + + test("debounced save consolidates rapid structural changes", async () => { + const controller = createAutoSave(manager, { + intervalMs: 60_000, + debounceMs: 100, + }); + + let state = createInitialState(); + state = addStage(state, "grep"); + controller.onAction( + { + type: "ADD_STAGE", + afterStageId: null, + config: makeConfig("grep"), + }, + state, + ); + + state = addStage(state, "sort"); + controller.onAction( + { + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config: makeConfig("sort"), + }, + state, + ); + + state = addStage(state, "totable"); + controller.onAction( + { + type: "ADD_STAGE", + afterStageId: state.cursorStageId, + config: makeConfig("totable"), + }, + state, + ); + + // Wait for debounce + await new Promise((r) => setTimeout(r, 200)); + controller.dispose(); + + // Should have saved once with all 3 stages + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.stageCount).toBe(3); + }); + + test("saveNow cancels pending debounce and saves current state", async () => { + const controller = createAutoSave(manager, { + intervalMs: 60_000, + debounceMs: 500, + }); + + let state = createInitialState(); + state = addStage(state, "grep"); + + // Trigger debounced save + controller.onAction( + { + type: "ADD_STAGE", + afterStageId: null, + config: makeConfig("grep"), + }, + state, + ); + + // Immediately save (should cancel the pending debounce) + state = addStage(state, "sort"); + await controller.saveNow(state); + controller.dispose(); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + // Should have 2 stages (from saveNow state, not debounced state) + expect(sessions[0]!.stageCount).toBe(2); + }); + + test("interval save triggers after intervalMs when dirty", async () => { + const controller = createAutoSave(manager, { + intervalMs: 100, + debounceMs: 60_000, // long debounce to prevent interference + }); + + let state = createInitialState(); + state = addStage(state, "grep"); + + // Mark as dirty via structural action + controller.onAction( + { + type: "ADD_STAGE", + afterStageId: null, + config: makeConfig("grep"), + }, + state, + ); + + // Wait for interval to fire + await new Promise((r) => setTimeout(r, 250)); + controller.dispose(); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + }); +}); diff --git a/tests/explorer/session/session-manager.test.ts b/tests/explorer/session/session-manager.test.ts new file mode 100644 index 0000000..4fb34af --- /dev/null +++ b/tests/explorer/session/session-manager.test.ts @@ -0,0 +1,552 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { join } from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { SessionManager } from "../../../src/explorer/session/session-manager.ts"; +import { SessionCacheStore } from "../../../src/explorer/session/session-cache-store.ts"; +import { + createInitialState, + pipelineReducer, +} from "../../../src/explorer/model/reducer.ts"; +import type { + PipelineState, + StageConfig, + CachedResult, +} from "../../../src/explorer/model/types.ts"; +import { Record } from "../../../src/Record.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 addInput( + state: PipelineState, + path: string, + label: string, +): PipelineState { + return pipelineReducer(state, { + type: "ADD_INPUT", + source: { kind: "file", path }, + label, + }); +} + +describe("SessionManager", () => { + let tempDir: string; + let manager: SessionManager; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "recs-explorer-test-")); + manager = new SessionManager(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + // ── Save and Load ───────────────────────────────────────────── + + describe("save and load", () => { + test("saves and loads a session with pipeline state", async () => { + let state = createInitialState(); + state = addInput(state, "/tmp/test.jsonl", "test.jsonl"); + state = addStage(state, "grep", ["status=200"]); + state = addStage(state, "sort", ["--key", "time=n"]); + + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + expect(loaded.version).toBe(1); + expect(loaded.sessionId).toBe(state.sessionId); + }); + + test("hydrate restores Maps from serialized data", async () => { + let state = createInitialState(); + state = addInput(state, "/tmp/test.jsonl", "test.jsonl"); + state = addStage(state, "grep", ["status=200"]); + state = addStage(state, "sort"); + + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + expect(hydrated.stages).toBeInstanceOf(Map); + expect(hydrated.forks).toBeInstanceOf(Map); + expect(hydrated.inputs).toBeInstanceOf(Map); + expect(hydrated.stages.size).toBe(state.stages.size); + expect(hydrated.forks.size).toBe(state.forks.size); + }); + + test("hydrate preserves stage configs", async () => { + let state = createInitialState(); + state = addStage(state, "grep", ["status=200"]); + + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + const stages = Array.from(hydrated.stages.values()); + expect(stages).toHaveLength(1); + expect(stages[0]!.config.operationName).toBe("grep"); + expect(stages[0]!.config.args).toEqual(["status=200"]); + }); + + test("hydrate preserves undo/redo stacks", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = addStage(state, "sort"); + // Undo one action to populate redo stack + state = pipelineReducer(state, { type: "UNDO" }); + + expect(state.undoStack).toHaveLength(1); + expect(state.redoStack).toHaveLength(1); + + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + expect(hydrated.undoStack).toHaveLength(1); + expect(hydrated.redoStack).toHaveLength(1); + // Undo entries contain Maps in their snapshots + expect(hydrated.undoStack[0]!.snapshot.stages).toBeInstanceOf(Map); + }); + + test("hydrate restores cacheConfig with Set", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + const stageId = state.cursorStageId!; + + // Pin a stage + state = pipelineReducer(state, { + type: "PIN_STAGE", + stageId, + }); + state = pipelineReducer(state, { + type: "SET_CACHE_POLICY", + policy: "selective", + }); + + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + expect(hydrated.cacheConfig.pinnedStageIds).toBeInstanceOf(Set); + expect(hydrated.cacheConfig.pinnedStageIds.has(stageId)).toBe(true); + expect(hydrated.cacheConfig.cachePolicy).toBe("selective"); + }); + + test("hydrate initializes transient state", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + + // Transient state should be initialized to defaults + expect(hydrated.focusedPanel).toBe("pipeline"); + expect(hydrated.cache).toBeInstanceOf(Map); + expect(hydrated.cache.size).toBe(0); + expect(hydrated.executing).toBe(false); + expect(hydrated.lastError).toBeNull(); + expect(hydrated.inspector.viewMode).toBe("table"); + }); + + test("load updates lastAccessedAt", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + + await manager.save(state); + + const before = Date.now(); + const loaded = await manager.load(state.sessionId); + expect(loaded.lastAccessedAt).toBeGreaterThanOrEqual(before); + }); + }); + + // ── List ────────────────────────────────────────────────────── + + describe("list", () => { + test("lists all saved sessions", async () => { + let state1 = createInitialState(); + state1 = addInput(state1, "/tmp/a.jsonl", "a.jsonl"); + state1 = addStage(state1, "grep"); + + let state2 = createInitialState(); + state2 = addInput(state2, "/tmp/b.jsonl", "b.jsonl"); + state2 = addStage(state2, "sort"); + + await manager.save(state1); + await manager.save(state2); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(2); + }); + + test("returns empty array when no sessions exist", async () => { + const sessions = await manager.list(); + expect(sessions).toHaveLength(0); + }); + + test("sessions are sorted by lastAccessedAt descending", async () => { + let state1 = createInitialState(); + state1 = addStage(state1, "grep"); + await manager.save(state1); + + // Small delay to ensure different timestamps + await new Promise((r) => setTimeout(r, 50)); + + let state2 = createInitialState(); + state2 = addStage(state2, "sort"); + await manager.save(state2); + + const sessions = await manager.list(); + expect(sessions[0]!.lastAccessedAt).toBeGreaterThanOrEqual( + sessions[1]!.lastAccessedAt, + ); + }); + + test("metadata includes pipeline summary", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = addStage(state, "sort"); + state = addStage(state, "totable"); + await manager.save(state); + + const sessions = await manager.list(); + expect(sessions[0]!.pipelineSummary).toContain("grep"); + expect(sessions[0]!.stageCount).toBe(3); + }); + }); + + // ── Find by input path ──────────────────────────────────────── + + describe("findByInputPath", () => { + test("finds session by exact input path", async () => { + let state = createInitialState(); + state = addInput(state, "/tmp/access.log", "access.log"); + state = addStage(state, "grep"); + await manager.save(state); + + const found = await manager.findByInputPath("/tmp/access.log"); + expect(found).not.toBeNull(); + expect(found!.sessionId).toBe(state.sessionId); + }); + + test("finds session by basename match", async () => { + let state = createInitialState(); + state = addInput(state, "/tmp/access.log", "access.log"); + state = addStage(state, "grep"); + await manager.save(state); + + const found = await manager.findByInputPath("/other/path/access.log"); + expect(found).not.toBeNull(); + expect(found!.sessionId).toBe(state.sessionId); + }); + + test("returns null when no matching session", async () => { + const found = await manager.findByInputPath("/nonexistent.jsonl"); + expect(found).toBeNull(); + }); + }); + + // ── Clean ───────────────────────────────────────────────────── + + describe("clean", () => { + test("removes sessions older than maxAgeMs", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + await manager.save(state); + + // Override meta.json with old timestamp + const metaPath = join(tempDir, state.sessionId, "meta.json"); + const meta = JSON.parse(await Bun.file(metaPath).text()) as { [key: string]: unknown }; + meta["lastAccessedAt"] = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago + await Bun.write(metaPath, JSON.stringify(meta)); + + const removed = await manager.clean(); + expect(removed).toBe(1); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(0); + }); + + test("keeps recent sessions", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + await manager.save(state); + + const removed = await manager.clean(); + expect(removed).toBe(0); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + }); + + test("custom maxAgeMs parameter", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + await manager.save(state); + + // Override to 1ms ago + const metaPath = join(tempDir, state.sessionId, "meta.json"); + const meta = JSON.parse(await Bun.file(metaPath).text()) as { [key: string]: unknown }; + meta["lastAccessedAt"] = Date.now() - 5000; // 5 seconds ago + await Bun.write(metaPath, JSON.stringify(meta)); + + const removed = await manager.clean(1000); // 1 second max age + expect(removed).toBe(1); + }); + + test("returns 0 when no sessions exist", async () => { + const removed = await manager.clean(); + expect(removed).toBe(0); + }); + }); + + // ── Delete ──────────────────────────────────────────────────── + + describe("delete", () => { + test("removes a specific session", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + await manager.save(state); + + const sessionsBefore = await manager.list(); + expect(sessionsBefore).toHaveLength(1); + + await manager.delete(state.sessionId); + + const sessionsAfter = await manager.list(); + expect(sessionsAfter).toHaveLength(0); + }); + + test("does not throw for nonexistent session", async () => { + await expect(manager.delete("nonexistent")).resolves.toBeUndefined(); + }); + }); + + // ── Verify input files ──────────────────────────────────────── + + describe("verifyInputFiles", () => { + test("returns empty array when input files exist", async () => { + // Use a file we know exists + const existingFile = import.meta.path; + let state = createInitialState(); + state = addInput(state, existingFile, "self"); + state = addStage(state, "grep"); + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const missing = await manager.verifyInputFiles(loaded); + expect(missing).toHaveLength(0); + }); + + test("returns missing file paths", async () => { + let state = createInitialState(); + state = addInput(state, "/nonexistent/file.jsonl", "missing"); + state = addStage(state, "grep"); + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const missing = await manager.verifyInputFiles(loaded); + expect(missing).toContain("/nonexistent/file.jsonl"); + }); + }); + + // ── Named sessions ─────────────────────────────────────────── + + describe("named sessions", () => { + test("save persists sessionName to session.json and meta.json", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = { ...state, sessionName: "my filter" }; + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + expect(loaded.name).toBe("my filter"); + + const metaPath = join(tempDir, state.sessionId, "meta.json"); + const meta = JSON.parse( + await Bun.file(metaPath).text(), + ) as { name?: string }; + expect(meta.name).toBe("my filter"); + }); + + test("hydrate restores sessionName from SessionFile", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = { ...state, sessionName: "experiment 1" }; + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + expect(hydrated.sessionName).toBe("experiment 1"); + }); + + test("hydrate handles missing name gracefully", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + await manager.save(state); + + const loaded = await manager.load(state.sessionId); + const hydrated = manager.hydrate(loaded); + expect(hydrated.sessionName).toBeUndefined(); + }); + + test("list returns name in metadata", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = { ...state, sessionName: "named session" }; + await manager.save(state); + + const sessions = await manager.list(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.name).toBe("named session"); + }); + + test("saveAs creates new session with name", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + const newId = await manager.saveAs(state, "forked session"); + + expect(newId).not.toBe(state.sessionId); + + const loaded = await manager.load(newId); + expect(loaded.name).toBe("forked session"); + expect(loaded.sessionId).toBe(newId); + }); + + test("rename updates name on disk", async () => { + let state = createInitialState(); + state = addStage(state, "grep"); + state = { ...state, sessionName: "old name" }; + await manager.save(state); + + await manager.rename(state.sessionId, "new name"); + + const loaded = await manager.load(state.sessionId); + expect(loaded.name).toBe("new name"); + + const metaPath = join(tempDir, state.sessionId, "meta.json"); + const meta = JSON.parse( + await Bun.file(metaPath).text(), + ) as { name?: string }; + expect(meta.name).toBe("new name"); + }); + + test("rename is safe for nonexistent session", async () => { + await expect( + manager.rename("nonexistent-id", "some name"), + ).resolves.toBeUndefined(); + }); + }); +}); + +// ── SessionCacheStore tests ────────────────────────────────────── + +describe("SessionCacheStore", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "recs-cache-test-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function makeCachedResult(inputId: string, stageId: string): CachedResult { + return { + key: `${inputId}:${stageId}`, + stageId, + inputId, + records: [ + new Record({ name: "alice", age: 30 }), + new Record({ name: "bob", age: 25 }), + ], + lines: [], + spillFile: null, + recordCount: 2, + fieldNames: ["name", "age"], + computedAt: Date.now(), + sizeBytes: 100, + computeTimeMs: 5, + }; + } + + test("writes and reads cache records", async () => { + const store = new SessionCacheStore(tempDir); + const result = makeCachedResult("input1", "stage1"); + + const manifest = await store.writeCache(result); + expect(manifest.key).toBe("input1:stage1"); + expect(manifest.recordCount).toBe(2); + expect(manifest.file).toBe("cache/input1-stage1.jsonl"); + + const loaded = await store.readCache(manifest, tempDir); + expect(loaded.records).toHaveLength(2); + expect(loaded.records[0]!.get("name")).toBe("alice"); + expect(loaded.records[1]!.get("age")).toBe(25); + expect(loaded.stageId).toBe("stage1"); + expect(loaded.inputId).toBe("input1"); + }); + + test("writeAllCaches writes multiple cache files", async () => { + const store = new SessionCacheStore(tempDir); + const cache = new Map(); + cache.set("input1:stage1", makeCachedResult("input1", "stage1")); + cache.set("input1:stage2", makeCachedResult("input1", "stage2")); + + const manifests = await store.writeAllCaches(cache); + expect(manifests).toHaveLength(2); + }); + + test("removeCache removes a specific cache file", async () => { + const store = new SessionCacheStore(tempDir); + const result = makeCachedResult("input1", "stage1"); + await store.writeCache(result); + + await store.removeCache("input1", "stage1"); + + // File should no longer exist + const filePath = join(tempDir, "cache", "input1-stage1.jsonl"); + expect(await Bun.file(filePath).exists()).toBe(false); + }); + + test("clearAll removes the entire cache directory", async () => { + const store = new SessionCacheStore(tempDir); + await store.writeCache(makeCachedResult("input1", "stage1")); + await store.writeCache(makeCachedResult("input1", "stage2")); + + await store.clearAll(); + + const cacheDir = join(tempDir, "cache"); + const { stat } = await import("node:fs/promises"); + await expect(stat(cacheDir)).rejects.toThrow(); + }); + + test("removeCache is no-op for nonexistent file", async () => { + const store = new SessionCacheStore(tempDir); + await expect( + store.removeCache("nonexistent", "nonexistent"), + ).resolves.toBeUndefined(); + }); +}); diff --git a/tests/explorer/utils/file-detect.test.ts b/tests/explorer/utils/file-detect.test.ts new file mode 100644 index 0000000..254daff --- /dev/null +++ b/tests/explorer/utils/file-detect.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from "bun:test"; +import { + detectInputOperation, + isNativeFormat, +} from "../../../src/explorer/utils/file-detect.ts"; + +describe("detectInputOperation", () => { + test("returns fromcsv --header for .csv files", () => { + const result = detectInputOperation("data.csv"); + expect(result).not.toBeNull(); + expect(result!.operationName).toBe("fromcsv"); + expect(result!.args).toEqual(["--header"]); + expect(result!.enabled).toBe(true); + }); + + test("returns fromcsv --header --delim for .tsv files", () => { + const result = detectInputOperation("data.tsv"); + expect(result).not.toBeNull(); + expect(result!.operationName).toBe("fromcsv"); + expect(result!.args).toEqual(["--header", "--delim", "\t"]); + expect(result!.enabled).toBe(true); + }); + + test("returns fromxml for .xml files", () => { + const result = detectInputOperation("feed.xml"); + expect(result).not.toBeNull(); + expect(result!.operationName).toBe("fromxml"); + expect(result!.args).toEqual([]); + expect(result!.enabled).toBe(true); + }); + + test("returns null for .jsonl files", () => { + expect(detectInputOperation("data.jsonl")).toBeNull(); + }); + + test("returns null for .json files", () => { + expect(detectInputOperation("data.json")).toBeNull(); + }); + + test("returns null for .ndjson files", () => { + expect(detectInputOperation("data.ndjson")).toBeNull(); + }); + + test("returns null for unknown extensions", () => { + expect(detectInputOperation("data.log")).toBeNull(); + expect(detectInputOperation("data.txt")).toBeNull(); + expect(detectInputOperation("data.parquet")).toBeNull(); + }); + + test("returns null for files with no extension", () => { + expect(detectInputOperation("data")).toBeNull(); + expect(detectInputOperation("/tmp/myfile")).toBeNull(); + }); + + test("handles full paths correctly", () => { + const result = detectInputOperation("/home/user/data/report.csv"); + expect(result).not.toBeNull(); + expect(result!.operationName).toBe("fromcsv"); + }); + + test("is case-insensitive for extensions", () => { + expect(detectInputOperation("data.CSV")).not.toBeNull(); + expect(detectInputOperation("data.Csv")).not.toBeNull(); + expect(detectInputOperation("data.TSV")).not.toBeNull(); + expect(detectInputOperation("data.XML")).not.toBeNull(); + expect(detectInputOperation("data.JSONL")).toBeNull(); + }); +}); + +describe("isNativeFormat", () => { + test("returns true for .jsonl", () => { + expect(isNativeFormat("data.jsonl")).toBe(true); + }); + + test("returns true for .json", () => { + expect(isNativeFormat("data.json")).toBe(true); + }); + + test("returns true for .ndjson", () => { + expect(isNativeFormat("data.ndjson")).toBe(true); + }); + + test("returns false for .csv", () => { + expect(isNativeFormat("data.csv")).toBe(false); + }); + + test("returns false for unknown extensions", () => { + expect(isNativeFormat("data.log")).toBe(false); + }); +}); diff --git a/tests/explorer/utils/fuzzy-match.test.ts b/tests/explorer/utils/fuzzy-match.test.ts new file mode 100644 index 0000000..0c55b98 --- /dev/null +++ b/tests/explorer/utils/fuzzy-match.test.ts @@ -0,0 +1,196 @@ +import { describe, test, expect } from "bun:test"; +import { fuzzyMatch, fuzzyFilter } from "../../../src/explorer/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"); + }); +}); + +describe("fuzzyFilter with getName (name-first matching)", () => { + // Simulate the operation list from AddStageModal + const ops = [ + { name: "fromapache", desc: "Each line of input is parsed to produce an output record for each request" }, + { name: "fromatomfeed", desc: "Produce records from atom feed entries" }, + { name: "fromcsv", desc: "Each line of input is split on commas to produce a record" }, + { name: "fromps", desc: "Generate records from the process table" }, + { name: "fromsplit", desc: "Each line of input is split on the provided delimiter" }, + { name: "grep", desc: "Filter records where an expression evaluates to true" }, + { name: "sort", desc: "Sort records from input or from files" }, + { name: "collate", desc: "Take records, grouped together by keys, and compute statistics" }, + { name: "xform", desc: "Transform records with a JS snippet" }, + { name: "totable", desc: "Pretty prints a table of records to the screen" }, + { name: "toptable", desc: "Creates a multi-dimensional pivot table" }, + { name: "tohtml", desc: "Prints out an HTML table for the records" }, + { name: "tocsv", desc: "Write records as CSV output" }, + ]; + + const filter = (query: string) => + fuzzyFilter(ops, query, (d) => `${d.name} ${d.desc}`, { + getName: (d) => d.name, + minScore: 50, + }); + + test("'fromps' returns fromps as the first (and ideally only) result", () => { + const result = filter("fromps"); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]!.name).toBe("fromps"); + // Should NOT include unrelated from* operations + expect(result.some((r) => r.name === "fromapache")).toBe(false); + expect(result.some((r) => r.name === "xform")).toBe(false); + }); + + test("'totable' returns totable first, not buried under other results", () => { + const result = filter("totable"); + expect(result[0]!.name).toBe("totable"); + // toptable is a reasonable fuzzy-name match + if (result.length > 1) { + expect(result[1]!.name).toBe("toptable"); + } + // Should NOT include unrelated operations like fromps + expect(result.some((r) => r.name === "fromps")).toBe(false); + }); + + test("'grep' returns only grep, not scattered description matches", () => { + const result = filter("grep"); + expect(result[0]!.name).toBe("grep"); + // Most other operations should be filtered out + expect(result.length).toBeLessThan(5); + }); + + test("'from' prefix shows all from* operations ranked by name relevance", () => { + const result = filter("from"); + // All from* operations should appear + const fromOps = result.filter((r) => r.name.startsWith("from")); + expect(fromOps.length).toBe(5); // all 5 from* ops in our list + // from* operations should come before any description-only matches + const firstNonFrom = result.findIndex((r) => !r.name.startsWith("from")); + if (firstNonFrom !== -1) { + expect(firstNonFrom).toBeGreaterThanOrEqual(5); + } + }); + + test("name matches always rank above description-only matches", () => { + const result = filter("table"); + // totable, toptable have "table" in their name → should rank first + const nameMatches = result.filter( + (r) => r.name === "totable" || r.name === "toptable", + ); + expect(nameMatches.length).toBe(2); + expect(result.indexOf(nameMatches[0]!)).toBeLessThan( + result.findIndex((r) => r.name !== "totable" && r.name !== "toptable" && r.name !== "stream2table"), + ); + }); + + test("minScore filters out low-quality fuzzy description matches", () => { + // "fromps" should NOT appear when searching "totable" (it only matched + // via scattered chars in the description in the old implementation) + const result = filter("totable"); + expect(result.some((r) => r.name === "fromps")).toBe(false); + expect(result.some((r) => r.name === "grep")).toBe(false); + }); + + test("fuzzy name matching still works for abbreviated queries", () => { + // "fmps" is a fuzzy abbreviation of "fromps" + const result = filter("fmps"); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]!.name).toBe("fromps"); + }); + + test("description substring matches still work when name doesn't match", () => { + // "process" doesn't match any name but appears in fromps description + const result = filter("process"); + expect(result.some((r) => r.name === "fromps")).toBe(true); + }); + + test("empty query returns all items unchanged", () => { + const result = filter(""); + expect(result).toHaveLength(ops.length); + }); + + test("backward compatible — works without options", () => { + const result = fuzzyFilter(ops, "grep", (d) => `${d.name} ${d.desc}`); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]!.name).toBe("grep"); + }); +}); diff --git a/tests/explorer/utils/vim-text-engine.test.ts b/tests/explorer/utils/vim-text-engine.test.ts new file mode 100644 index 0000000..4f23e1c --- /dev/null +++ b/tests/explorer/utils/vim-text-engine.test.ts @@ -0,0 +1,1189 @@ +import { describe, test, expect } from "bun:test"; +import { + processInput, + initialState, + charClass, + findNextWordStart, + findPrevWordStart, + findNextWORDStart, + findPrevWORDStart, + findCharForward, + findCharBackward, + findWordEnd, + findPrevWordEnd, + type VimState, +} from "../../../src/explorer/utils/vim-text-engine.ts"; +import type { Key } from "ink"; + +// ── Helpers ────────────────────────────────────────────────── + +/** Create a default Key object with all flags false. */ +function makeKey(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + home: false, + end: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + super: false, + hyper: false, + capsLock: false, + numLock: false, + ...overrides, + }; +} + +/** Shorthand: press a printable key in a given state. */ +function press(input: string, state: VimState, value: string, keyOverrides: Partial = {}) { + return processInput(input, makeKey(keyOverrides), state, value); +} + +// ── charClass ──────────────────────────────────────────────── + +describe("charClass", () => { + test("classifies word characters", () => { + expect(charClass("a")).toBe("word"); + expect(charClass("Z")).toBe("word"); + expect(charClass("0")).toBe("word"); + expect(charClass("_")).toBe("word"); + }); + + test("classifies punctuation", () => { + expect(charClass("-")).toBe("punct"); + expect(charClass(".")).toBe("punct"); + expect(charClass("!")).toBe("punct"); + expect(charClass("(")).toBe("punct"); + }); + + test("classifies whitespace", () => { + expect(charClass(" ")).toBe("space"); + expect(charClass("\t")).toBe("space"); + }); +}); + +// ── findNextWordStart ───────────────────────────────────────── + +describe("findNextWordStart", () => { + test("jumps past current word", () => { + expect(findNextWordStart("hello world", 0)).toBe(6); + }); + + test("jumps past whitespace between words", () => { + expect(findNextWordStart("hello world", 4)).toBe(6); + }); + + test("stops at punct boundary", () => { + expect(findNextWordStart("foo.bar", 0)).toBe(3); + }); + + test("handles end of string", () => { + expect(findNextWordStart("abc", 2)).toBe(3); + }); + + test("at end returns length", () => { + expect(findNextWordStart("abc", 3)).toBe(3); + }); +}); + +// ── findPrevWordStart ───────────────────────────────────────── + +describe("findPrevWordStart", () => { + test("jumps back to start of current word", () => { + expect(findPrevWordStart("hello world", 8)).toBe(6); + }); + + test("jumps back past whitespace", () => { + expect(findPrevWordStart("hello world", 6)).toBe(0); + }); + + test("returns 0 at start", () => { + expect(findPrevWordStart("hello", 0)).toBe(0); + }); + + test("handles punct boundary", () => { + // "foo.bar": f(0) o(1) o(2) .(3) b(4) a(5) r(6) + // from 4 ("b"): back past "." punct to start of punct run → 3 + expect(findPrevWordStart("foo.bar", 4)).toBe(3); + // from 3 ("."): back past "foo" word to start → 0 + expect(findPrevWordStart("foo.bar", 3)).toBe(0); + }); +}); + +// ── findNextWORDStart ───────────────────────────────────────── + +describe("findNextWORDStart", () => { + test("jumps past WORD (whitespace-delimited)", () => { + expect(findNextWORDStart("foo.bar baz", 0)).toBe(8); + }); + + test("from middle of WORD", () => { + expect(findNextWORDStart("foo.bar baz", 2)).toBe(8); + }); +}); + +// ── findPrevWORDStart ───────────────────────────────────────── + +describe("findPrevWORDStart", () => { + test("jumps back past WORD (whitespace-delimited)", () => { + expect(findPrevWORDStart("foo.bar baz", 8)).toBe(0); + }); + + test("from middle of WORD", () => { + expect(findPrevWORDStart("foo.bar baz", 10)).toBe(8); + }); + + test("at start returns 0", () => { + expect(findPrevWORDStart("hello world", 0)).toBe(0); + }); + + test("treats punctuation as part of WORD", () => { + expect(findPrevWORDStart("abc --key val", 10)).toBe(4); + }); +}); + +// ── findCharForward ─────────────────────────────────────────── + +describe("findCharForward", () => { + test("finds character forward", () => { + expect(findCharForward("hello world", 0, "o")).toBe(4); + }); + + test("returns -1 when not found", () => { + expect(findCharForward("hello", 0, "z")).toBe(-1); + }); + + test("searches after current position", () => { + expect(findCharForward("abcabc", 0, "a")).toBe(3); + }); +}); + +// ── Insert mode ────────────────────────────────────────────── + +describe("insert mode", () => { + test("starts in insert mode", () => { + const state = initialState(0); + expect(state.mode).toBe("insert"); + }); + + test("inserts printable characters", () => { + const state = initialState(0); + const r = press("a", state, ""); + expect(r.value).toBe("a"); + expect(r.state.cursorOffset).toBe(1); + }); + + test("inserts at cursor position", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("X", state, "hello"); + expect(r.value).toBe("helXlo"); + expect(r.state.cursorOffset).toBe(4); + }); + + test("backspace deletes backward", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("", state, "hello", { backspace: true }); + expect(r.value).toBe("helo"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("backspace at start does nothing", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + const r = press("", state, "hello", { backspace: true }); + expect(r.value).toBe("hello"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("left arrow moves cursor left", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("", state, "hello", { leftArrow: true }); + expect(r.state.cursorOffset).toBe(2); + }); + + test("right arrow moves cursor right", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("", state, "hello", { rightArrow: true }); + expect(r.state.cursorOffset).toBe(4); + }); + + test("Ctrl+U clears entire line", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("u", state, "hello", { ctrl: true }); + expect(r.value).toBe(""); + expect(r.state.cursorOffset).toBe(0); + }); + + test("Ctrl+K clears from cursor to end", () => { + const state: VimState = { mode: "insert", cursorOffset: 2, pending: null }; + const r = press("k", state, "hello", { ctrl: true }); + expect(r.value).toBe("he"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("Ctrl+W deletes backward word", () => { + const state: VimState = { mode: "insert", cursorOffset: 11, pending: null }; + const r = press("w", state, "hello world", { ctrl: true }); + expect(r.value).toBe("hello "); + expect(r.state.cursorOffset).toBe(6); + }); + + test("Alt+F moves forward by word", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + const r = press("f", state, "hello world", { meta: true }); + expect(r.state.cursorOffset).toBe(6); + }); + + test("Alt+B moves backward by word", () => { + const state: VimState = { mode: "insert", cursorOffset: 8, pending: null }; + const r = press("b", state, "hello world", { meta: true }); + expect(r.state.cursorOffset).toBe(6); + }); + + test("Enter submits", () => { + const state: VimState = { mode: "insert", cursorOffset: 5, pending: null }; + const r = press("", state, "hello", { return: true }); + expect(r.submitted).toBe(true); + }); + + test("Escape switches to normal mode", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("", state, "hello", { escape: true }); + expect(r.state.mode).toBe("normal"); + expect(r.state.cursorOffset).toBe(2); // cursor moves left 1 + expect(r.escaped).toBe(false); // NOT propagated + }); + + test("Escape at offset 0 clamps to 0", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + const r = press("", state, "hello", { escape: true }); + expect(r.state.cursorOffset).toBe(0); + }); + + test("Tab passes through", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + const r = press("", state, "hello", { tab: true }); + expect(r.passThrough).toBe(true); + }); + + test("Up/Down arrows pass through", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + expect(press("", state, "hello", { upArrow: true }).passThrough).toBe(true); + expect(press("", state, "hello", { downArrow: true }).passThrough).toBe(true); + }); + + test("Ctrl+C passes through", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + const r = press("c", state, "hello", { ctrl: true }); + expect(r.passThrough).toBe(true); + }); +}); + +// ── Normal mode ────────────────────────────────────────────── + +describe("normal mode", () => { + test("h moves cursor left", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press("h", state, "hello"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("h clamps at 0", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("h", state, "hello"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("l moves cursor right", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + const r = press("l", state, "hello"); + expect(r.state.cursorOffset).toBe(3); + }); + + test("l clamps at len-1", () => { + const state: VimState = { mode: "normal", cursorOffset: 4, pending: null }; + const r = press("l", state, "hello"); + expect(r.state.cursorOffset).toBe(4); + }); + + test("0 moves to line start", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press("0", state, "hello"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("$ moves to line end", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("$", state, "hello"); + expect(r.state.cursorOffset).toBe(4); + }); + + test("w moves to next word start", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("w", state, "hello world"); + expect(r.state.cursorOffset).toBe(6); + }); + + test("b moves to prev word start", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + const r = press("b", state, "hello world"); + expect(r.state.cursorOffset).toBe(6); + }); + + test("W moves to next WORD start", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("W", state, "foo.bar baz"); + expect(r.state.cursorOffset).toBe(8); + }); + + test("B moves to previous WORD start", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + const r = press("B", state, "foo.bar baz"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("i enters insert mode at cursor", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press("i", state, "hello"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(3); + }); + + test("a enters insert mode after cursor", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press("a", state, "hello"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(4); + }); + + test("A enters insert mode at end of line", () => { + const state: VimState = { mode: "normal", cursorOffset: 1, pending: null }; + const r = press("A", state, "hello"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(5); + }); + + test("s substitutes char and enters insert", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + const r = press("s", state, "hello"); + expect(r.state.mode).toBe("insert"); + expect(r.value).toBe("helo"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("s on empty value enters insert", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("s", state, ""); + expect(r.state.mode).toBe("insert"); + expect(r.value).toBe(""); + }); + + test("x deletes char at cursor", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + const r = press("x", state, "hello"); + expect(r.value).toBe("helo"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("x on last char clamps cursor", () => { + const state: VimState = { mode: "normal", cursorOffset: 4, pending: null }; + const r = press("x", state, "hello"); + expect(r.value).toBe("hell"); + expect(r.state.cursorOffset).toBe(3); // clamps to new len-1 + }); + + test("x on empty value does nothing", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("x", state, ""); + expect(r.value).toBe(""); + }); + + test("Escape with no pending propagates (escaped=true)", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + const r = press("", state, "hello", { escape: true }); + expect(r.escaped).toBe(true); + }); + + test("Enter submits from normal mode", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + const r = press("", state, "hello", { return: true }); + expect(r.submitted).toBe(true); + }); + + test("Tab passes through", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("", state, "hello", { tab: true }); + expect(r.passThrough).toBe(true); + }); +}); + +// ── D (delete to end of line) ──────────────────────────────── + +describe("D (delete to end of line)", () => { + test("D deletes from cursor to end of line", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press("D", state, "hello world"); + expect(r.value).toBe("hel"); + expect(r.state.cursorOffset).toBe(2); // clampNormal on "hel" → 2 + expect(r.state.mode).toBe("normal"); + }); + + test("D at start deletes entire line", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("D", state, "hello"); + expect(r.value).toBe(""); + expect(r.state.cursorOffset).toBe(0); + }); + + test("D at last char deletes that char", () => { + const state: VimState = { mode: "normal", cursorOffset: 4, pending: null }; + const r = press("D", state, "hello"); + expect(r.value).toBe("hell"); + expect(r.state.cursorOffset).toBe(3); + }); + + test("D on empty value does nothing", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("D", state, ""); + expect(r.value).toBe(""); + expect(r.state.cursorOffset).toBe(0); + }); +}); + +// ── C (change to end of line) ──────────────────────────────── + +describe("C (change to end of line)", () => { + test("C deletes from cursor to end and enters insert mode", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press("C", state, "hello world"); + expect(r.value).toBe("hel"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(3); // insert cursor at end of remaining text + }); + + test("C at start clears line and enters insert", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("C", state, "hello"); + expect(r.value).toBe(""); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("C at last char deletes it and enters insert", () => { + const state: VimState = { mode: "normal", cursorOffset: 4, pending: null }; + const r = press("C", state, "hello"); + expect(r.value).toBe("hell"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(4); + }); + + test("C on empty value enters insert", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("C", state, ""); + expect(r.value).toBe(""); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(0); + }); +}); + +// ── d operator ─────────────────────────────────────────────── + +describe("d operator", () => { + test("dd clears entire line", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + let r = press("d", state, "hello"); + expect(r.state.pending).toEqual({ kind: "d" }); + + r = press("d", r.state, r.value); + expect(r.value).toBe(""); + expect(r.state.cursorOffset).toBe(0); + expect(r.state.pending).toBeNull(); + }); + + test("dw deletes to next word start", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("d", state, "hello world"); + r = press("w", r.state, r.value); + expect(r.value).toBe("world"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("db deletes backward to prev word start", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("d", state, "hello world"); + r = press("b", r.state, r.value); + expect(r.value).toBe("hello rld"); + expect(r.state.cursorOffset).toBe(6); + }); + + test("dB deletes backward to prev WORD start", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("d", state, "foo.bar baz"); + r = press("B", r.state, r.value); + expect(r.value).toBe("baz"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("d$ deletes to end of line", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + let r = press("d", state, "hello"); + r = press("$", r.state, r.value); + expect(r.value).toBe("hel"); + }); + + test("d0 deletes to start of line", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + let r = press("d", state, "hello"); + r = press("0", r.state, r.value); + expect(r.value).toBe("lo"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("dh deletes character to the left", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + let r = press("d", state, "hello"); + r = press("h", r.state, r.value); + expect(r.value).toBe("helo"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("dl deletes character at cursor", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + let r = press("d", state, "hello"); + r = press("l", r.state, r.value); + expect(r.value).toBe("helo"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("df{char} deletes through character", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("d", state, "hello world"); + r = press("f", r.state, r.value); + expect(r.state.pending).toEqual({ kind: "df" }); + r = press("o", r.state, r.value); + expect(r.value).toBe(" world"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("dt{char} deletes till character", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("d", state, "hello world"); + r = press("t", r.state, r.value); + expect(r.state.pending).toEqual({ kind: "dt" }); + r = press("o", r.state, r.value); + expect(r.value).toBe("o world"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("d + unknown key cancels pending", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + let r = press("d", state, "hello"); + r = press("z", r.state, r.value); + expect(r.state.pending).toBeNull(); + expect(r.value).toBe("hello"); + }); + + test("Escape cancels pending d", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + let r = press("d", state, "hello"); + r = press("", r.state, r.value, { escape: true }); + expect(r.state.pending).toBeNull(); + expect(r.escaped).toBe(false); // NOT propagated + }); +}); + +// ── c operator ─────────────────────────────────────────────── + +describe("c operator", () => { + test("cc clears line and enters insert", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + let r = press("c", state, "hello"); + r = press("c", r.state, r.value); + expect(r.value).toBe(""); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("cw changes to next word start", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("c", state, "hello world"); + r = press("w", r.state, r.value); + expect(r.value).toBe("world"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("cf{char} changes through character", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("c", state, "hello world"); + r = press("f", r.state, r.value); + r = press("o", r.state, r.value); + expect(r.value).toBe(" world"); + expect(r.state.mode).toBe("insert"); + }); + + test("ct{char} changes till character", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("c", state, "hello world"); + r = press("t", r.state, r.value); + r = press("o", r.state, r.value); + expect(r.value).toBe("o world"); + expect(r.state.mode).toBe("insert"); + }); +}); + +// ── f/t/F/T as standalone motions ───────────────────────────── + +describe("standalone f/t/F/T motions in normal mode", () => { + test("f{char} moves cursor to char", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("f", state, "hello world"); + expect(r.state.pending).toEqual({ kind: "f" }); + r = press("w", r.state, r.value); + expect(r.state.cursorOffset).toBe(6); + expect(r.state.pending).toBeNull(); + expect(r.value).toBe("hello world"); // no deletion + }); + + test("t{char} moves cursor to just before char", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("t", state, "hello world"); + expect(r.state.pending).toEqual({ kind: "t" }); + r = press("w", r.state, r.value); + expect(r.state.cursorOffset).toBe(5); + expect(r.value).toBe("hello world"); + }); + + test("F{char} moves cursor backward to char", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("F", state, "hello world"); + expect(r.state.pending).toEqual({ kind: "F" }); + r = press("e", r.state, r.value); + expect(r.state.cursorOffset).toBe(1); + expect(r.value).toBe("hello world"); + }); + + test("T{char} moves cursor backward to just after char", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("T", state, "hello world"); + expect(r.state.pending).toEqual({ kind: "T" }); + r = press("e", r.state, r.value); + expect(r.state.cursorOffset).toBe(2); + expect(r.value).toBe("hello world"); + }); + + test("f{char} not found cancels pending", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("f", state, "hello"); + r = press("z", r.state, r.value); + expect(r.state.pending).toBeNull(); + expect(r.state.cursorOffset).toBe(0); + }); + + test("f/t store lastFind for ; and ,", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("f", state, "abcabc"); + r = press("b", r.state, r.value); + expect(r.state.lastFind).toEqual({ dir: "forward", inclusive: true, char: "b" }); + }); +}); + +// ── findCharBackward ───────────────────────────────────────── + +describe("findCharBackward", () => { + test("finds character backward", () => { + expect(findCharBackward("hello world", 5, "e")).toBe(1); + }); + + test("finds nearest char backward", () => { + // "hello world": o at 4 and 7. From pos 8, nearest backward o is at 7. + expect(findCharBackward("hello world", 8, "o")).toBe(7); + }); + + test("returns -1 when not found", () => { + expect(findCharBackward("hello", 3, "z")).toBe(-1); + }); + + test("does not include current position", () => { + expect(findCharBackward("aba", 2, "a")).toBe(0); + }); +}); + +// ── findWordEnd ────────────────────────────────────────────── + +describe("findWordEnd", () => { + test("jumps to end of current word", () => { + expect(findWordEnd("hello world", 0)).toBe(4); + }); + + test("jumps to end of next word from end of current word", () => { + expect(findWordEnd("hello world", 4)).toBe(10); + }); + + test("jumps to end of next word from whitespace", () => { + expect(findWordEnd("hello world", 5)).toBe(10); + }); + + test("handles end of string", () => { + expect(findWordEnd("abc", 2)).toBe(2); + }); + + test("stops at punct boundary", () => { + expect(findWordEnd("foo.bar", 0)).toBe(2); + }); +}); + +// ── findPrevWordEnd ───────────────────────────────────────── + +describe("findPrevWordEnd", () => { + test("jumps to end of previous word from middle", () => { + expect(findPrevWordEnd("hello world", 8)).toBe(4); + }); + + test("jumps to end of previous word from start of word", () => { + expect(findPrevWordEnd("hello world", 6)).toBe(4); + }); + + test("returns 0 at start", () => { + expect(findPrevWordEnd("hello", 0)).toBe(0); + }); + + test("from end of first word returns 0", () => { + expect(findPrevWordEnd("hello world", 4)).toBe(0); + }); +}); + +// ── S (substitute entire line) ────────────────────────────── + +describe("S (substitute entire line)", () => { + test("S clears line and enters insert mode", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press("S", state, "hello world"); + expect(r.value).toBe(""); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("S on empty value enters insert", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("S", state, ""); + expect(r.value).toBe(""); + expect(r.state.mode).toBe("insert"); + }); +}); + +// ── I (insert at line start) ───────────────────────────────── + +describe("I (insert at line start)", () => { + test("I enters insert mode at start of line", () => { + const state: VimState = { mode: "normal", cursorOffset: 5, pending: null }; + const r = press("I", state, "hello world"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(0); + }); +}); + +// ── r (replace single character) ───────────────────────────── + +describe("r (replace single character)", () => { + test("r{char} replaces char at cursor", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("r", state, "hello"); + expect(r.state.pending).toEqual({ kind: "r" }); + r = press("H", r.state, r.value); + expect(r.value).toBe("Hello"); + expect(r.state.mode).toBe("normal"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("r on empty value cancels", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("r", state, ""); + r = press("x", r.state, r.value); + expect(r.value).toBe(""); + expect(r.state.pending).toBeNull(); + }); + + test("r replaces in middle of string", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + let r = press("r", state, "hello"); + r = press("L", r.state, r.value); + expect(r.value).toBe("heLlo"); + expect(r.state.cursorOffset).toBe(2); + }); +}); + +// ── ~ (toggle case) ───────────────────────────────────────── + +describe("~ (toggle case)", () => { + test("toggles lowercase to uppercase and moves right", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("~", state, "hello"); + expect(r.value).toBe("Hello"); + expect(r.state.cursorOffset).toBe(1); + }); + + test("toggles uppercase to lowercase", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("~", state, "HELLO"); + expect(r.value).toBe("hELLO"); + expect(r.state.cursorOffset).toBe(1); + }); + + test("at last char clamps cursor", () => { + const state: VimState = { mode: "normal", cursorOffset: 4, pending: null }; + const r = press("~", state, "hellO"); + expect(r.value).toBe("hello"); + expect(r.state.cursorOffset).toBe(4); // clampNormal at end + }); + + test("on empty value does nothing", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("~", state, ""); + expect(r.value).toBe(""); + }); +}); + +// ── e (end of word) ────────────────────────────────────────── + +describe("e (end of word motion)", () => { + test("e moves to end of current word", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + const r = press("e", state, "hello world"); + expect(r.state.cursorOffset).toBe(4); + }); + + test("e from end of word moves to end of next word", () => { + const state: VimState = { mode: "normal", cursorOffset: 4, pending: null }; + const r = press("e", state, "hello world"); + expect(r.state.cursorOffset).toBe(10); + }); + + test("de deletes to end of word", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("d", state, "hello world"); + r = press("e", r.state, r.value); + expect(r.value).toBe(" world"); + expect(r.state.cursorOffset).toBe(0); + }); + + test("ce changes to end of word", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("c", state, "hello world"); + r = press("e", r.state, r.value); + expect(r.value).toBe(" world"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(0); + }); +}); + +// ── ge (end of previous word) ──────────────────────────────── + +describe("ge (end of previous word)", () => { + test("ge moves to end of previous word", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("g", state, "hello world"); + expect(r.state.pending).toEqual({ kind: "g" }); + r = press("e", r.state, r.value); + expect(r.state.cursorOffset).toBe(4); + expect(r.state.pending).toBeNull(); + }); + + test("ge at start of second word goes to end of first", () => { + const state: VimState = { mode: "normal", cursorOffset: 6, pending: null }; + let r = press("g", state, "hello world"); + r = press("e", r.state, r.value); + expect(r.state.cursorOffset).toBe(4); + }); + + test("ge at start returns 0", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("g", state, "hello"); + r = press("e", r.state, r.value); + expect(r.state.cursorOffset).toBe(0); + }); + + test("g + unknown key cancels pending", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("g", state, "hello"); + r = press("z", r.state, r.value); + expect(r.state.pending).toBeNull(); + }); +}); + +// ── gi (resume insert at last position) ────────────────────── + +describe("gi (resume insert at last position)", () => { + test("gi resumes insert at last exit position", () => { + // Type, escape (stores lastInsertOffset), move, then gi + let state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + // Escape stores lastInsertOffset = 3 + let r = press("", state, "hello", { escape: true }); + expect(r.state.lastInsertOffset).toBe(3); + // Move cursor + r = press("$", r.state, r.value); + expect(r.state.cursorOffset).toBe(4); + // gi should go back to offset 3 + r = press("g", r.state, r.value); + r = press("i", r.state, r.value); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(3); + }); + + test("gi with no previous insert goes to end", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("g", state, "hello"); + r = press("i", r.state, r.value); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(5); // value.length + }); +}); + +// ── ; and , (repeat last find) ─────────────────────────────── + +describe("; and , (repeat last find)", () => { + test("; repeats last f search forward", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + // f → b finds first 'b' at 1 + let r = press("f", state, "abcabc"); + r = press("b", r.state, r.value); + expect(r.state.cursorOffset).toBe(1); + // ; repeats → finds next 'b' at 4 + r = press(";", r.state, r.value); + expect(r.state.cursorOffset).toBe(4); + }); + + test(", reverses last f search (goes backward)", () => { + const state: VimState = { mode: "normal", cursorOffset: 4, pending: null }; + // f → c finds 'c' at 5 + let r = press("f", state, "abcabc"); + r = press("c", r.state, r.value); + expect(r.state.cursorOffset).toBe(5); + // , reverses → goes backward, finds 'c' at 2 + r = press(",", r.state, r.value); + expect(r.state.cursorOffset).toBe(2); + }); + + test("; with no previous find does nothing", () => { + const state: VimState = { mode: "normal", cursorOffset: 3, pending: null }; + const r = press(";", state, "hello"); + expect(r.state.cursorOffset).toBe(3); + }); + + test("; repeats t search (non-inclusive)", () => { + const state: VimState = { mode: "normal", cursorOffset: 0, pending: null }; + let r = press("t", state, "abcabc"); + r = press("c", r.state, r.value); + expect(r.state.cursorOffset).toBe(1); // before first 'c' at 2 + // ; should repeat t → cursor before next 'c' at 5 → 4 + r = press(";", r.state, r.value); + expect(r.state.cursorOffset).toBe(4); + }); +}); + +// ── F/T backward find operators ────────────────────────────── + +describe("dF/dT/cF/cT (backward find operators)", () => { + test("dF{char} deletes backward through char", () => { + // "hello world" pos 8='r', find 'e' at 1 + // Deletes [1, 8) → "h" + "rld" = "hrld" + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("d", state, "hello world"); + r = press("F", r.state, r.value); + expect(r.state.pending).toEqual({ kind: "dF" }); + r = press("e", r.state, r.value); + expect(r.value).toBe("hrld"); + expect(r.state.cursorOffset).toBe(1); + }); + + test("dT{char} deletes backward till char (exclusive)", () => { + // "hello world" pos 8='r', find 'e' at 1, till = don't include 'e' + // Deletes [2, 8) → "he" + "rld" = "herld" + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("d", state, "hello world"); + r = press("T", r.state, r.value); + expect(r.state.pending).toEqual({ kind: "dT" }); + r = press("e", r.state, r.value); + expect(r.value).toBe("herld"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("cF{char} changes backward through char", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("c", state, "hello world"); + r = press("F", r.state, r.value); + r = press("e", r.state, r.value); + expect(r.value).toBe("hrld"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(1); + }); + + test("cT{char} changes backward till char", () => { + const state: VimState = { mode: "normal", cursorOffset: 8, pending: null }; + let r = press("c", state, "hello world"); + r = press("T", r.state, r.value); + r = press("e", r.state, r.value); + expect(r.value).toBe("herld"); + expect(r.state.mode).toBe("insert"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("dF not found cancels", () => { + const state: VimState = { mode: "normal", cursorOffset: 5, pending: null }; + let r = press("d", state, "hello world"); + r = press("F", r.state, r.value); + r = press("z", r.state, r.value); + expect(r.value).toBe("hello world"); + expect(r.state.pending).toBeNull(); + }); +}); + +// ── Insert mode readline shortcuts ─────────────────────────── + +describe("insert mode readline shortcuts", () => { + test("Ctrl+A moves to line start", () => { + const state: VimState = { mode: "insert", cursorOffset: 5, pending: null }; + const r = press("a", state, "hello", { ctrl: true }); + expect(r.state.cursorOffset).toBe(0); + expect(r.value).toBe("hello"); + }); + + test("Ctrl+E moves to line end", () => { + const state: VimState = { mode: "insert", cursorOffset: 2, pending: null }; + const r = press("e", state, "hello", { ctrl: true }); + expect(r.state.cursorOffset).toBe(5); + expect(r.value).toBe("hello"); + }); + + test("Ctrl+H deletes backward (like backspace)", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("h", state, "hello", { ctrl: true }); + expect(r.value).toBe("helo"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("Ctrl+H at start does nothing", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + const r = press("h", state, "hello", { ctrl: true }); + expect(r.value).toBe("hello"); + }); + + test("Ctrl+D deletes char at cursor", () => { + const state: VimState = { mode: "insert", cursorOffset: 2, pending: null }; + const r = press("d", state, "hello", { ctrl: true }); + expect(r.value).toBe("helo"); + expect(r.state.cursorOffset).toBe(2); + }); + + test("Ctrl+D at end does nothing", () => { + const state: VimState = { mode: "insert", cursorOffset: 5, pending: null }; + const r = press("d", state, "hello", { ctrl: true }); + expect(r.value).toBe("hello"); + }); + + test("Ctrl+T transposes characters before cursor", () => { + const state: VimState = { mode: "insert", cursorOffset: 3, pending: null }; + const r = press("t", state, "teh", { ctrl: true }); + expect(r.value).toBe("the"); + }); + + test("Ctrl+T at start (offset < 2) does nothing", () => { + const state: VimState = { mode: "insert", cursorOffset: 1, pending: null }; + const r = press("t", state, "hello", { ctrl: true }); + expect(r.value).toBe("hello"); + }); + + test("Ctrl+L passes through", () => { + const state: VimState = { mode: "insert", cursorOffset: 0, pending: null }; + const r = press("l", state, "hello", { ctrl: true }); + expect(r.passThrough).toBe(true); + }); +}); + +// ── Integration: full editing sequences ────────────────────── + +describe("integration sequences", () => { + test("type, escape, dw, i, type more", () => { + let state = initialState(0); + let value = ""; + + // Type "hello world" + for (const ch of "hello world") { + const r = press(ch, state, value); + state = r.state; + value = r.value; + } + expect(value).toBe("hello world"); + + // Escape to normal + let r = press("", state, value, { escape: true }); + state = r.state; + expect(state.mode).toBe("normal"); + + // Move to start with 0 + r = press("0", state, value); + state = r.state; + expect(state.cursorOffset).toBe(0); + + // dw → delete "hello " + r = press("d", state, value); + state = r.state; + r = press("w", state, value); + state = r.state; + value = r.value; + expect(value).toBe("world"); + + // i → insert mode, type "hey " + r = press("i", state, value); + state = r.state; + for (const ch of "hey ") { + const r2 = press(ch, state, value); + state = r2.state; + value = r2.value; + } + expect(value).toBe("hey world"); + }); + + test("double-escape propagates from normal mode", () => { + let state = initialState(5); + const value = "hello"; + + // First escape: insert → normal (not propagated) + let r = press("", state, value, { escape: true }); + state = r.state; + expect(r.escaped).toBe(false); + expect(state.mode).toBe("normal"); + + // Second escape: normal → propagated + r = press("", state, value, { escape: true }); + expect(r.escaped).toBe(true); + }); + + test("escape cancels pending operator, does not propagate", () => { + const state: VimState = { mode: "normal", cursorOffset: 2, pending: null }; + // d → pending + let r = press("d", state, "hello"); + expect(r.state.pending).not.toBeNull(); + + // Escape → cancel pending, NOT propagated + r = press("", r.state, r.value, { escape: true }); + expect(r.state.pending).toBeNull(); + expect(r.escaped).toBe(false); + }); + + test("Ctrl+W at word boundary", () => { + const state: VimState = { mode: "insert", cursorOffset: 6, pending: null }; + const r = press("w", state, "hello world", { ctrl: true }); + expect(r.value).toBe("world"); + expect(r.state.cursorOffset).toBe(0); + }); +}); diff --git a/tests/tui/utils/fuzzy-match.test.ts b/tests/tui/utils/fuzzy-match.test.ts deleted file mode 100644 index d2d4b99..0000000 --- a/tests/tui/utils/fuzzy-match.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 433b6c9..8cad139 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "noEmit": true, "jsx": "react-jsx", - "jsxImportSource": "@opentui/react", + "jsxImportSource": "react", "strict": true, "skipLibCheck": true,