diff --git a/Justfile-apps b/Justfile-apps index ade51026e..da7ac994d 100644 --- a/Justfile-apps +++ b/Justfile-apps @@ -1,6 +1,6 @@ start: #!/bin/bash - ARGS=(server client) + ARGS=(server client seeds-webui) echo "Starting: ${ARGS[@]}" set -e @@ -20,4 +20,7 @@ server: bun --watch src/main.ts client: - cd typescript/client && bun vite \ No newline at end of file + cd typescript/client && bun vite + +seeds-webui: + cd typescript/seeds-webui && bun vite diff --git a/bun.lock b/bun.lock index 1d2addcc3..79da9ed50 100644 --- a/bun.lock +++ b/bun.lock @@ -309,6 +309,42 @@ "tsconfig-paths": "catalog:", }, }, + "typescript/seeds-webui": { + "name": "tachi-seeds-webui", + "version": "0.1.0-see-project-root", + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@lezer/highlight": "^1.2.3", + "@sqlite.org/sqlite-wasm": "^3.51.2-build9", + "codemirror": "^6.0.2", + "comlink": "^4.4.2", + "fast-json-patch": "^3.1.1", + "nanoid": "catalog:", + "natural-compare": "catalog:", + "react": "catalog:", + "react-bootstrap": "catalog:", + "react-dom": "catalog:", + "react-query": "catalog:", + "react-router-dom": "catalog:", + "tachi-common": "workspace:*", + "zod": "catalog:", + }, + "devDependencies": { + "@types/natural-compare": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@types/react-router-dom": "catalog:", + "@typescript/native-preview": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bootstrap-scss": "catalog:", + "eslint-config-tachi": "workspace:*", + "sass": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-html": "catalog:", + }, + }, "typescript/server": { "name": "tachi-server", "version": "3.0.0", @@ -780,6 +816,22 @@ "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.11.0", "", { "dependencies": { "@bufbuild/protobuf": "2.11.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], + + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + + "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], + + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], + + "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + + "@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="], + "@connectrpc/connect": ["@connectrpc/connect@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ=="], "@connectrpc/connect-node": ["@connectrpc/connect-node@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.1" } }, "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg=="], @@ -936,6 +988,14 @@ "@kristiandupont/recase": ["@kristiandupont/recase@1.4.1", "", { "dependencies": { "ramda": "^0.30.0" } }, "sha512-e5t4YqhnRGbS9sU4N52cQgTn37qKwTsxDDcIuIkgPvX0UmnL+7eoOR6oFXeCib5zYuw03vYKpR55NBq+W43j1A=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -1182,6 +1242,8 @@ "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + "@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.51.2-build9", "", {}, "sha512-tj2Sa6X4gVZqS9SiT2xlEeChSUklSGq8Hz6zKNbHW6NRiGyrSXiFcspnrWru1wPBT76RX/VxweESKxawCxfDxg=="], + "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], @@ -1574,6 +1636,8 @@ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1582,6 +1646,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], @@ -1620,6 +1686,8 @@ "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cron-parser": ["cron-parser@2.18.0", "", { "dependencies": { "is-nan": "^1.3.0", "moment-timezone": "^0.5.31" } }, "sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1906,6 +1974,8 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="], + "fast-json-stable-hash": ["fast-json-stable-hash@1.0.3", "", {}, "sha512-iLK3Tq2J/KbiUn/5OHyhxikHqzwFXSM2e803Dp2JtBdQjsCVuZwWvL+WqWXPPRUEuDX4c0kqID1SU84RCfxs3A=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -2816,6 +2886,8 @@ "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-to-object": ["style-to-object@0.3.0", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA=="], "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], @@ -2850,6 +2922,8 @@ "tachi-seeds-scripts": ["tachi-seeds-scripts@workspace:typescript/seeds-scripts"], + "tachi-seeds-webui": ["tachi-seeds-webui@workspace:typescript/seeds-webui"], + "tachi-server": ["tachi-server@workspace:typescript/server"], "tachi-sieglinde": ["tachi-sieglinde@workspace:typescript/sieglinde"], @@ -3004,6 +3078,8 @@ "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/typescript/client/.env b/typescript/client/.env index c39d5e790..90eb78c5d 100644 --- a/typescript/client/.env +++ b/typescript/client/.env @@ -9,6 +9,7 @@ VITE_FLO_CLIENT_ID="" VITE_RULES_READ_TIME=1 VITE_IS_LOCAL_DEV=true VITE_IS_DEVELOPMENT=true +VITE_SEEDS_URL="http://127.0.0.1:3100" # Where routing should "base" from. # shouldn't be used, really. VITE_BASE_PATH="" diff --git a/typescript/client/example/.env b/typescript/client/example/.env index 06df5ef33..00fa0c5f2 100644 --- a/typescript/client/example/.env +++ b/typescript/client/example/.env @@ -9,6 +9,7 @@ VITE_MIN_CLIENT_ID="" VITE_FLO_CLIENT_ID="" VITE_RULES_READ_TIME=30 VITE_IS_LOCAL_DEV=true +VITE_SEEDS_URL="https://seeds.tachi.ac" TACHI_NAME="Tachi Local Dev" # turn this off if you're on linux for actually good performance diff --git a/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx b/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx index 022a36ac4..7c86fc736 100644 --- a/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx +++ b/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx @@ -1,226 +1,13 @@ -import useSetSubheader from "#components/layout/header/useSetSubheader"; -import SeedsPicker from "#components/seeds/SeedsPicker"; -import SeedsStateViewer from "#components/seeds/SeedsStateViewer"; -import Divider from "#components/util/Divider"; -import ExternalLink from "#components/util/ExternalLink"; -import Loading from "#components/util/Loading"; -import useApiQuery from "#components/util/query/useApiQuery"; -import SelectButton from "#components/util/SelectButton"; -import { TachiConfig } from "#lib/config"; -import { type Revision } from "#types/git"; -import { LoadCommit } from "#util/seeds"; -import React, { useEffect, useState } from "react"; -import { Alert, Col, Row } from "react-bootstrap"; +import { useEffect } from "react"; + +const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined; export default function SeedsViewer() { - useSetSubheader(["Developer Utils", "Database Seeds Management"]); - - const { data, error: failedToGetLocalAPI } = useApiQuery>("/seeds"); - - const { data: hasUncommittedRes } = useApiQuery("/seeds/has-uncommitted-changes"); - - if (!data && !failedToGetLocalAPI) { - return ; - } - - return ( - <> -

{TachiConfig.NAME} Database Management

- - - This tool is for viewing the database that powers {TachiConfig.NAME} in a more - efficient manner. -
- To view the state of a given commit or repository, use the select boxes below. -
- For more information what all of this is about and how it works, see{" "} - - the documentation - - . - {!failedToGetLocalAPI && ( - <> -
-
- - Also, it seems like you're running {TachiConfig.NAME} in local - development! - {" "} -
- You can use this UI to view your current changes on-your-disk before you - commit them! - - )} -
- - {hasUncommittedRes && ( - <> - -
You have uncommitted changes on your local disk.
-
- {/* this deliberately reloads the page. */} - - Click to view what you've changed. - -
-
{" "} - - - )} - - - ); -} - -function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) { - // base, rev a-la traditional git comparisons. Head is expected to be 'after' - // the base, but no order is enforced. - const [baseRev, setBaseRev] = useState(null); - const [headRev, setHeadRev] = useState(null); - const [urlLoading, setURLLoading] = useState(false); - const [isFirstLoad, setIsFirstLoad] = useState(true); - - const params = new URLSearchParams(window.location.search); - const sha = params.get("sha"); - const repo = params.get("repo"); - const compareRepo = params.get("compareRepo"); - const compareSHA = params.get("compareSHA"); - - const [view, setView] = useState<"DIFF" | "FULL">("DIFF"); - useEffect(() => { - if (!isFirstLoad) { - return; + if (SEEDS_URL) { + window.location.replace(SEEDS_URL); } + }, []); - // if we've been given stuff in the URL and don't already have a baseRev - if (sha && repo && !baseRev) { - setURLLoading(true); - - (async () => { - const baseCommit = await LoadCommit(repo, sha); - - if (baseCommit) { - setBaseRev({ c: baseCommit, repo }); - } else { - setBaseRev(null); - } - - if (compareRepo && compareSHA) { - const headCommit = await LoadCommit(compareRepo, compareSHA); - - if (headCommit) { - setHeadRev({ c: headCommit, repo: compareRepo }); - } else { - setHeadRev(null); - } - } - - setURLLoading(false); - - setIsFirstLoad(false); - })(); - } - }, [isFirstLoad]); - - useEffect(() => { - if (baseRev) { - const params = new URLSearchParams({ - repo: baseRev.repo, - sha: baseRev.c.sha, - }); - - if (headRev) { - params.set("compareRepo", headRev.repo); - params.set("compareSHA", headRev.c.sha); - } - - window.history.replaceState(null, "", `?${params.toString()}`); - } else { - window.history.replaceState(null, "", ""); - } - }, [baseRev, headRev]); - - // if sha XOR repo, this should not be allowed. - if (!!sha !== !!repo) { - return
Invalid reference, both a repo and a sha must be provided in the URL.
; - } - - if (urlLoading) { - return ( -
- -
- Fetching information for{" "} - - {repo}/{sha} - - ... -
-
- ); - } - - return ( - <> - - - - - {baseRev && ( - - - - )} - - {baseRev && ( - - - - {!headRev && ( -
- - View Changes - - - View Full State - -
- )} - - - - -
- )} - - ); + return null; } diff --git a/typescript/client/src/components/layout/header/UtilsDropdown.tsx b/typescript/client/src/components/layout/header/UtilsDropdown.tsx index d5297453a..b2d95feeb 100644 --- a/typescript/client/src/components/layout/header/UtilsDropdown.tsx +++ b/typescript/client/src/components/layout/header/UtilsDropdown.tsx @@ -2,13 +2,22 @@ import DropdownNavLink from "#components/ui/DropdownNavLink"; import QuickDropdown from "#components/ui/QuickDropdown"; import { type SetState } from "#types/react"; import React from "react"; +import DropdownItem from "react-bootstrap/DropdownItem"; + +const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined; function UtilsLinks({ onClick }: { onClick: React.MouseEventHandler }) { return ( <> - + Seeds Management - + Import Management diff --git a/typescript/common/src/config/config.ts b/typescript/common/src/config/config.ts index e9052ca46..979255270 100644 --- a/typescript/common/src/config/config.ts +++ b/typescript/common/src/config/config.ts @@ -144,14 +144,23 @@ const v3GameMappings: Record = { "ddr:DP": "ddr-dp", }; +/** + * @deprecated + */ export function LEGACY_GameGroupPTToGame(game: GameGroup, playtype: LEGACY_Playtype): V3Game { - return GPTStringToGame(LEGACY_GetGPTString(game, playtype)); + return LEGACY_GPTStringToGame(LEGACY_GetGPTString(game, playtype)); } -export function GPTStringToGame(gptString: LEGACY_GPTString): V3Game { +/** + * @deprecated + */ +export function LEGACY_GPTStringToGame(gptString: LEGACY_GPTString): V3Game { return v3GameMappings[gptString]; } +/** + * @deprecated + */ export function LEGACY_GameToGPTString(game: V3Game): LEGACY_GPTString { const mapping: Record = { "iidx-sp": "iidx:SP", @@ -241,7 +250,7 @@ export function LEGACY_GetGamePTConfig( } export function LEGACY_GetGPTConfig(gptString: LEGACY_GPTString): GameConfig { - const game = GPTStringToGame(gptString); + const game = LEGACY_GPTStringToGame(gptString); return GAME_CONFIGS[game] as unknown as GameConfig; } diff --git a/typescript/common/src/lib/folder-slug.ts b/typescript/common/src/lib/folder-slug.ts index 93e5b6c8f..01fe41162 100644 --- a/typescript/common/src/lib/folder-slug.ts +++ b/typescript/common/src/lib/folder-slug.ts @@ -357,23 +357,35 @@ function encodeBmsTableKey(game: string, key: string): string { function encodeBmsTableValue(value: string): string { const parts: Array = []; + let alphanumRun = ""; for (const ch of value) { if (/[a-z0-9]/iu.test(ch)) { - parts.push(ch.toLowerCase()); - } else if (ch === "+") { - parts.push("p"); - } else if (ch === "?") { - parts.push("q"); - } else if (ch === "-") { - parts.push("dash"); - } else if (ch === "!") { - parts.push("excl"); + alphanumRun += ch.toLowerCase(); } else { - parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`); + if (alphanumRun.length > 0) { + parts.push(alphanumRun); + alphanumRun = ""; + } + + if (ch === "+") { + parts.push("p"); + } else if (ch === "?") { + parts.push("q"); + } else if (ch === "-") { + parts.push("dash"); + } else if (ch === "!") { + parts.push("excl"); + } else { + parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`); + } } } + if (alphanumRun.length > 0) { + parts.push(alphanumRun); + } + return parts.join("-"); } @@ -386,7 +398,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string { const key = mKv[1].replaceAll("''", "'"); const val = mKv[2].replaceAll("''", "'"); - return `tf-${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`; + return `${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`; } const mPr = /tableFolders'\) \? '((?:[^']|'')+)'/u.exec(w); @@ -394,7 +406,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string { if (mPr !== null) { const key = mPr[1].replaceAll("''", "'"); - return `tf-${encodeBmsTableKey(folder.game, key)}-present`; + return `${encodeBmsTableKey(folder.game, key)}-present`; } const mRg = diff --git a/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts b/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts index 2159147e0..ddd7ed182 100644 --- a/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts +++ b/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts @@ -8,8 +8,8 @@ import fs from "fs"; import path from "path"; import { type GameGroup, - GPTStringToGame, LEGACY_GetGPTString, + LEGACY_GPTStringToGame, type LEGACY_Playtype, } from "tachi-common"; @@ -58,7 +58,7 @@ for (const entry of folders) { delete entry.folderID; } - entry.game = GPTStringToGame( + entry.game = LEGACY_GPTStringToGame( LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype), ); delete entry.playtype; @@ -82,7 +82,7 @@ for (const entry of tables) { delete entry.tableID; } - entry.game = GPTStringToGame( + entry.game = LEGACY_GPTStringToGame( LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype), ); delete entry.playtype; diff --git a/typescript/seeds-scripts/sort-seeds.js b/typescript/seeds-scripts/sort-seeds.js index 0180f50ed..7feca09ea 100644 --- a/typescript/seeds-scripts/sort-seeds.js +++ b/typescript/seeds-scripts/sort-seeds.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { spawnSync } = require("child_process"); function ChartSort(a, b) { // sink all 2dxtra charts to the bottom @@ -98,6 +99,17 @@ function SortSeeds() { fs.writeFileSync(collPath, JSON.stringify(content, null, "\t")); } + + // Run the repo formatter so the output is consistent with `just fmt`. + const repoRoot = path.resolve(__dirname, "../.."); + const biome = path.join(repoRoot, "node_modules", ".bin", "biome"); + const result = spawnSync(biome, ["format", "--write", collectionsDir], { + cwd: repoRoot, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`biome format exited ${result.status}`); + } } function SortObjectKeys(object) { diff --git a/typescript/seeds-webui/.gitignore b/typescript/seeds-webui/.gitignore new file mode 100644 index 000000000..054ac6fcc --- /dev/null +++ b/typescript/seeds-webui/.gitignore @@ -0,0 +1,4 @@ +build/ +dist/ +public/seeds-bundle/ +node_modules/ diff --git a/typescript/seeds-webui/README.md b/typescript/seeds-webui/README.md new file mode 100644 index 000000000..dc137f6ed --- /dev/null +++ b/typescript/seeds-webui/README.md @@ -0,0 +1,40 @@ +# tachi-seeds-webui + +The seeds workstation. Browse, query, diff over git history — and, in +localdev, edit `db/seeds/*.json` directly. + +## Running + +```sh +# Local dev (editing enabled, spawns a Vite dev server on :3100) +just seeds-webui + +# Build a static, read-only bundle (no editing) +cd typescript/seeds-webui && bun run build +``` + +## Shape + +- **Static SPA in prod.** Hosted at `seeds.tachi.ac`. No backend; reads + commit history from the GitHub REST API and the current seeds from a + pre-built `/seeds-bundle/` snapshot shipped with the site. +- **Dev mode adds a Vite plugin** (`dev/vite-plugin-seeds-dev.ts`) that + mounts `/__seeds/*` against the local repo's `db/seeds/`. That plugin is + _only_ loaded during `vite dev`; `vite build` never includes it. +- **In-browser SQLite** (`@sqlite.org/sqlite-wasm` + OPFS) for queries. + The worker at `src/lib/sqlite/worker.ts` ingests collections on first + load and caches them in OPFS by content hash. +- **Drafts** are queued in IndexedDB. The Drafts drawer composes them into + JSON Patches and calls `transport.writeCollection`, which in dev mode + rewrites the file and re-runs `sort-seeds.js`. + +## Edit-mode safety + +Two locks: + +1. `VITE_SEEDS_EDIT_MODE` is set to the literal `false` during `vite + build`, so every `EDIT_MODE`-guarded branch is tree-shaken out of the + prod bundle. +2. At runtime, the transport additionally probes `/__seeds/ping` before + unlocking edit paths. A static host where somebody deploys a dev build + by mistake will still act read-only. diff --git a/typescript/seeds-webui/dev/util/git.ts b/typescript/seeds-webui/dev/util/git.ts new file mode 100644 index 000000000..72f59a453 --- /dev/null +++ b/typescript/seeds-webui/dev/util/git.ts @@ -0,0 +1,124 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const pExecFile = promisify(execFile); + +export interface DevBranch { + name: string; + sha: string; +} + +export interface DevCommit { + sha: string; + message: string; + author: { date: string; email: string; name: string }; + committer: { date: string; email: string; name: string }; + parents: Array<{ sha: string }>; +} + +export async function gitExec( + repoRoot: string, + args: string[], +): Promise<{ stderr: string; stdout: string }> { + return pExecFile("git", args, { + cwd: repoRoot, + env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat" }, + maxBuffer: 256 * 1024 * 1024, + }); +} + +export async function listBranches( + repoRoot: string, +): Promise<{ branches: DevBranch[]; current: DevBranch | null }> { + const { stdout } = await gitExec(repoRoot, [ + "for-each-ref", + "--format=%(refname:short)\t%(objectname:short)", + "refs/heads", + ]); + const branches: DevBranch[] = []; + for (const line of stdout.split("\n")) { + const [name, sha] = line.split("\t"); + if (name && sha) {branches.push({ name, sha });} + } + + let current: DevBranch | null = null; + try { + const { stdout: head } = await gitExec(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]); + const name = head.trim(); + if (name && name !== "HEAD") { + const found = branches.find((b) => b.name === name); + if (found) {current = found;} + } + } catch { + // detached head — leave current null. + } + + return { branches, current }; +} + +// git log format: each commit is 7 records terminated by NUL, separated +// from the next commit by a literal "\x1e" (record separator). +const LOG_FORMAT = ["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%s"].join("%x00"); + +export async function listCommits( + repoRoot: string, + opts: { branch?: string; file?: string; limit?: number; skip?: number }, +): Promise { + const args = [ + "log", + `--format=${LOG_FORMAT}%x1e`, + `--max-count=${opts.limit ?? 30}`, + `--skip=${opts.skip ?? 0}`, + ]; + if (opts.branch) {args.push(opts.branch);} + if (opts.file) {args.push("--", opts.file);} + + const { stdout } = await gitExec(repoRoot, args); + const commits: DevCommit[] = []; + for (const record of stdout.split("\x1e")) { + const parts = record.trim().split("\x00"); + if (parts.length < 9) {continue;} + const [sha, parents, an, ae, ad, cn, ce, cd, message] = parts; + commits.push({ + sha: sha!, + parents: (parents ?? "") + .split(" ") + .filter(Boolean) + .map((p) => ({ sha: p })), + author: { name: an!, email: ae!, date: ad! }, + committer: { name: cn!, email: ce!, date: cd! }, + message: message!, + }); + } + return commits; +} + +export async function getCommit(repoRoot: string, sha: string): Promise { + const [c] = await listCommits(repoRoot, { branch: sha, limit: 1 }); + if (!c) {throw new Error(`No commit found for ${sha}`);} + return c; +} + +export async function showFileAt(repoRoot: string, rev: string, path: string): Promise { + const { stdout } = await gitExec(repoRoot, ["show", `${rev}:${path}`]); + return stdout; +} + +export async function statusPorcelain( + repoRoot: string, +): Promise<{ branch: string | null; changed: string[]; hasUncommitted: boolean }> { + const { stdout } = await gitExec(repoRoot, ["status", "--porcelain=v1", "--branch"]); + const lines = stdout.split("\n").filter(Boolean); + let branch: string | null = null; + const changed: string[] = []; + for (const line of lines) { + if (line.startsWith("##")) { + const m = /^## ([^.\s]+)(?:\.\.\.|$| )/u.exec(line); + branch = m?.[1] ?? null; + continue; + } + // Porcelain format: " XY path" — strip the two-char status + space. + changed.push(line.slice(3)); + } + return { branch, changed, hasUncommitted: changed.length > 0 }; +} diff --git a/typescript/seeds-webui/dev/util/json-patch.ts b/typescript/seeds-webui/dev/util/json-patch.ts new file mode 100644 index 000000000..13b3137ee --- /dev/null +++ b/typescript/seeds-webui/dev/util/json-patch.ts @@ -0,0 +1,111 @@ +// Minimal JSON Patch (RFC 6902) applier limited to the three ops the UI emits: +// add, remove, replace. Paths use '/' separators; '-' as last segment on an +// array means "append". +// +// We deliberately do NOT depend on fast-json-patch here because this runs in +// the dev plugin (Node side) and we want zero Node-side dependencies beyond +// Vite itself. + +export type JsonPatchOp = + | { op: "add"; path: string; value: unknown } + | { op: "remove"; path: string } + | { op: "replace"; path: string; value: unknown }; + +export type JsonPatch = JsonPatchOp[]; + +type Json = unknown; + +function decode(segment: string): string { + return segment.replace(/~1/gu, "/").replace(/~0/gu, "~"); +} + +function split(path: string): string[] { + if (path === "") {return [];} + if (path[0] !== "/") { + throw new Error(`Invalid JSON Pointer: ${path}`); + } + return path.slice(1).split("/").map(decode); +} + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function walk( + root: Json, + segments: string[], +): { exists: boolean; key: number | string; parent: Json } { + if (segments.length === 0) { + throw new Error( + "Cannot address the root via a patch op here; edit the whole file instead.", + ); + } + let cur: Json = root; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]!; + if (Array.isArray(cur)) { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) { + throw new Error( + `Array index out of bounds at ${segments.slice(0, i + 1).join("/")}`, + ); + } + cur = cur[idx]; + } else if (isObj(cur)) { + if (!(seg in cur)) { + throw new Error(`Missing key "${seg}" at ${segments.slice(0, i + 1).join("/")}`); + } + cur = cur[seg]; + } else { + throw new Error( + `Cannot descend into non-container at ${segments.slice(0, i + 1).join("/")}`, + ); + } + } + const last = segments[segments.length - 1]!; + if (Array.isArray(cur)) { + const key = last === "-" ? cur.length : Number(last); + if (last !== "-" && (!Number.isInteger(key) || key < 0 || key > cur.length)) { + throw new Error(`Array index out of bounds at ${segments.join("/")}`); + } + return { parent: cur, key, exists: last !== "-" && key < cur.length }; + } + if (isObj(cur)) { + return { parent: cur, key: last, exists: last in cur }; + } + throw new Error(`Cannot address ${segments.join("/")}: parent is not a container`); +} + +export function applyPatch(doc: T, patch: JsonPatch): T { + const clone = JSON.parse(JSON.stringify(doc)) as T; + for (const op of patch) { + const segs = split(op.path); + const { parent, key } = walk(clone, segs); + if (Array.isArray(parent)) { + const idx = key as number; + switch (op.op) { + case "add": + parent.splice(idx, 0, op.value); + break; + case "remove": + parent.splice(idx, 1); + break; + case "replace": + parent[idx] = op.value; + break; + } + } else if (isObj(parent)) { + const k = key as string; + switch (op.op) { + case "add": + case "replace": + parent[k] = op.value; + break; + case "remove": + delete parent[k]; + break; + } + } + } + return clone; +} diff --git a/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts b/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts new file mode 100644 index 000000000..b18f4c6f4 --- /dev/null +++ b/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts @@ -0,0 +1,293 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin, ViteDevServer } from "vite"; + +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + getCommit, + gitExec, + listBranches, + listCommits, + showFileAt, + statusPorcelain, +} from "./util/git"; +import { applyPatch, type JsonPatch } from "./util/json-patch"; + +// vite-plugin-seeds-dev — mounts /__seeds/* dev-only endpoints against the +// local repo's db/seeds directory so the seeds-webui can: +// +// - GET /__seeds/ping feature probe +// - GET /__seeds/collections list *.json in db/seeds +// - GET /__seeds/branches local git branches +// - GET /__seeds/commits log entries affecting db/seeds (optionally ?file=) +// - GET /__seeds/commit?sha=... details of a specific commit +// - GET /__seeds/collection?name=&rev= full JSON of a collection at a rev (or disk) +// - POST /__seeds/collection apply JSON patch, re-sort, write +// - POST /__seeds/sort run sort-seeds.js +// - GET /__seeds/status working-tree status +// - POST /__seeds/tests/run run seeds-scripts tests, stream (SSE) +// +// All endpoints are ONLY registered during `vite dev`. They never make it into +// the production bundle. + +export interface SeedsDevPluginOptions { + repoRoot: string; +} + +export function seedsDevPlugin(opts: SeedsDevPluginOptions): Plugin { + const seedsDir = path.join(opts.repoRoot, "db", "seeds"); + const seedsRepoRelPath = "db/seeds"; + const seedsScriptsDir = path.join(opts.repoRoot, "typescript", "seeds-scripts"); + + return { + name: "seeds-webui:dev", + apply: "serve", + configureServer(server: ViteDevServer) { + server.middlewares.use("/__seeds", async (req, res) => { + try { + await handle(req, res, { + repoRoot: opts.repoRoot, + seedsDir, + seedsRepoRelPath, + seedsScriptsDir, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + server.config.logger.error(`[seeds-webui] ${req.url ?? ""}: ${message}`); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: message })); + } + } + }); + }, + }; +} + +interface Ctx { + repoRoot: string; + seedsDir: string; + seedsRepoRelPath: string; + seedsScriptsDir: string; +} + +// ---------- Dispatcher ---------- + +async function handle(req: IncomingMessage, res: ServerResponse, ctx: Ctx) { + const url = new URL(req.url ?? "/", "http://localhost"); + const method = (req.method ?? "GET").toUpperCase(); + // `req.url` on middleware is already stripped of the /__seeds prefix. + const route = `${method} ${url.pathname.replace(/\/+$/u, "")}`; + + switch (route) { + case "GET /ping": + case "GET /": + return respond(res, 200, { ok: true }); + + case "GET /collections": + return respond(res, 200, await listCollectionFiles(ctx)); + + case "GET /branches": + return respond(res, 200, await listBranches(ctx.repoRoot)); + + case "GET /commits": + return respond( + res, + 200, + await commitsHandler(ctx, { + branch: url.searchParams.get("branch") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + file: url.searchParams.get("file") ?? undefined, + }), + ); + + case "GET /commit": { + const sha = url.searchParams.get("sha"); + if (!sha) {return respond(res, 400, { error: "missing sha" });} + return respond(res, 200, await getCommit(ctx.repoRoot, sha)); + } + + case "GET /collection": { + const name = url.searchParams.get("name"); + const rev = url.searchParams.get("rev") ?? undefined; + if (!name) {return respond(res, 400, { error: "missing name" });} + return respond(res, 200, await readCollection(ctx, name, rev)); + } + + case "POST /collection": { + const body = await readJson<{ name: string; patch: JsonPatch }>(req); + await writeCollection(ctx, body.name, body.patch); + return respond(res, 200, { ok: true }); + } + + case "POST /sort": { + await runSort(ctx); + return respond(res, 200, { ok: true }); + } + + case "GET /status": + return respond(res, 200, await statusHandler(ctx)); + + case "POST /tests/run": + return streamProcess(res, ctx.seedsScriptsDir, "bash", ["./run-tests.sh"]); + } + + return respond(res, 404, { error: `no seeds-webui dev route for ${route}` }); +} + +// ---------- Helpers ---------- + +function respond(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(body)); +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const c of req) { + chunks.push(c as Buffer); + } + const raw = Buffer.concat(chunks).toString("utf-8"); + if (raw.length === 0) {return {} as T;} + return JSON.parse(raw) as T; +} + +// ---------- Endpoint handlers ---------- + +async function listCollectionFiles(ctx: Ctx): Promise { + const names = await fs.readdir(ctx.seedsDir); + return names.filter((n) => n.endsWith(".json")).sort(); +} + +async function commitsHandler( + ctx: Ctx, + opts: { branch?: string; cursor?: string; file?: string }, +): Promise<{ + commits: Awaited>; + nextCursor?: string; +}> { + const skip = opts.cursor ? Number.parseInt(opts.cursor, 10) : 0; + const limit = 30; + const targetFile = opts.file ? `${ctx.seedsRepoRelPath}/${opts.file}` : ctx.seedsRepoRelPath; + + const commits = await listCommits(ctx.repoRoot, { + branch: opts.branch, + file: targetFile, + limit, + skip, + }); + return { + commits, + nextCursor: commits.length === limit ? String(skip + limit) : undefined, + }; +} + +async function readCollection(ctx: Ctx, name: string, rev?: string): Promise { + validateCollectionName(name); + if (rev === undefined) { + const raw = await fs.readFile(path.join(ctx.seedsDir, name), "utf-8"); + return JSON.parse(raw) as unknown[]; + } + validateRev(rev); + const raw = await showFileAt(ctx.repoRoot, rev, `${ctx.seedsRepoRelPath}/${name}`); + return JSON.parse(raw) as unknown[]; +} + +async function writeCollection(ctx: Ctx, name: string, patch: JsonPatch): Promise { + validateCollectionName(name); + const file = path.join(ctx.seedsDir, name); + const raw = await fs.readFile(file, "utf-8"); + const doc = JSON.parse(raw) as unknown[]; + const next = applyPatch(doc, patch); + + // Write atomically (same dir so it stays on the same fs). + const tmp = `${file}.tmp-${process.pid}`; + await fs.writeFile(tmp, JSON.stringify(next, null, "\t")); + await fs.rename(tmp, file); + + // Re-run sort-seeds so diffs stay canonical. + await runSort(ctx); +} + +async function runSort(ctx: Ctx): Promise { + await new Promise((resolve, reject) => { + const proc = spawn("node", ["sort-seeds.js"], { + cwd: ctx.seedsScriptsDir, + stdio: "inherit", + }); + proc.on("error", reject); + proc.on("exit", (code) => + code === 0 ? resolve() : reject(new Error(`sort-seeds exited ${code}`)), + ); + }); +} + +async function statusHandler(ctx: Ctx) { + const s = await statusPorcelain(ctx.repoRoot); + return { + branch: s.branch, + hasUncommittedChanges: s.changed.some((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)), + changedFiles: s.changed.filter((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)), + }; +} + +function streamProcess( + res: ServerResponse, + cwd: string, + cmd: string, + argv: string[], +): Promise { + return new Promise((resolve) => { + res.statusCode = 200; + res.setHeader("content-type", "text/event-stream"); + res.setHeader("cache-control", "no-cache"); + res.setHeader("x-accel-buffering", "no"); + + const write = (kind: "stderr" | "stdout", data: string) => { + res.write(`data: ${JSON.stringify({ kind, data })}\n\n`); + }; + + const proc = spawn(cmd, argv, { cwd }); + proc.stdout.on("data", (b: Buffer) => write("stdout", b.toString("utf-8"))); + proc.stderr.on("data", (b: Buffer) => write("stderr", b.toString("utf-8"))); + proc.on("error", (err) => write("stderr", `spawn error: ${err.message}\n`)); + proc.on("exit", (code) => { + res.write(`data: ${JSON.stringify({ kind: "exit", code: code ?? -1 })}\n\n`); + res.end(); + resolve(); + }); + }); +} + +// ---------- Validation ---------- + +function validateCollectionName(name: string): void { + if (!/^[a-z0-9][a-z0-9-]*\.json$/u.test(name)) { + throw new Error(`invalid collection name ${JSON.stringify(name)}`); + } +} + +// Allow branches, tags, shas, and short shas. Disallow shell metacharacters. +function validateRev(rev: string): void { + if (!/^[A-Za-z0-9._/-]+$/u.test(rev)) { + throw new Error(`invalid git revision ${JSON.stringify(rev)}`); + } +} + +// Re-export for the Vite config typecheck. +export type { JsonPatch }; + +// Used only in tests/dev to warm up git; exported so that callers can verify +// the repo is actually a git repo before mounting. +export async function gitSanityCheck(repoRoot: string): Promise { + try { + await gitExec(repoRoot, ["rev-parse", "--git-dir"]); + return true; + } catch { + return false; + } +} diff --git a/typescript/seeds-webui/eslint.config.mjs b/typescript/seeds-webui/eslint.config.mjs new file mode 100644 index 000000000..a2234ccdd --- /dev/null +++ b/typescript/seeds-webui/eslint.config.mjs @@ -0,0 +1,19 @@ +import configTachi from "eslint-config-tachi"; + +export default [ + ...configTachi.base, + { + ...configTachi.node, + files: ["vite.config.ts", "dev/**/*.ts", "scripts/**/*.ts"], + languageOptions: { + ...configTachi.node.languageOptions, + parserOptions: { + project: ["./tsconfig.node.json"], + }, + }, + }, + configTachi.react, + { + ignores: ["build/**", "public/**", "node_modules/**", "dist/**"], + }, +]; diff --git a/typescript/seeds-webui/index.html b/typescript/seeds-webui/index.html new file mode 100644 index 000000000..5eca68189 --- /dev/null +++ b/typescript/seeds-webui/index.html @@ -0,0 +1,29 @@ + + + + + Tachi Seeds + + + + + + + + + + + +
+ + diff --git a/typescript/seeds-webui/package.json b/typescript/seeds-webui/package.json new file mode 100644 index 000000000..68daa9a41 --- /dev/null +++ b/typescript/seeds-webui/package.json @@ -0,0 +1,61 @@ +{ + "name": "tachi-seeds-webui", + "version": "0.1.0-see-project-root", + "private": true, + "type": "module", + "license": "AGPL3", + "description": "Workstation UI for Tachi's db/seeds: browse, query, diff over git history, and (in localdev) edit.", + "scripts": { + "dev": "vite", + "build": "bun scripts/bundle-current-seeds.ts && vite build", + "preview": "vite preview", + "lint": "eslint .", + "lint-fix": "eslint . --fix", + "typecheck": "tsgo --noEmit", + "bench": "node -e \"process.exit(0)\"" + }, + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@lezer/highlight": "^1.2.3", + "@sqlite.org/sqlite-wasm": "^3.51.2-build9", + "codemirror": "^6.0.2", + "comlink": "^4.4.2", + "fast-json-patch": "^3.1.1", + "nanoid": "catalog:", + "natural-compare": "catalog:", + "react": "catalog:", + "react-bootstrap": "catalog:", + "react-dom": "catalog:", + "react-query": "catalog:", + "react-router-dom": "catalog:", + "tachi-common": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@types/natural-compare": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@types/react-router-dom": "catalog:", + "@typescript/native-preview": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bootstrap-scss": "catalog:", + "eslint-config-tachi": "workspace:*", + "sass": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-html": "catalog:" + }, + "engines": { + "bun": ">=1.2.0" + }, + "imports": { + "#*": [ + "./src/*", + "./src/*.ts", + "./src/*.tsx", + "./src/*/index.ts", + "./src/*/index.tsx" + ] + } +} diff --git a/typescript/seeds-webui/public/favicon.ico b/typescript/seeds-webui/public/favicon.ico new file mode 100644 index 000000000..b85080336 Binary files /dev/null and b/typescript/seeds-webui/public/favicon.ico differ diff --git a/typescript/seeds-webui/public/logos/logo-mark.png b/typescript/seeds-webui/public/logos/logo-mark.png new file mode 100644 index 000000000..ecec36928 Binary files /dev/null and b/typescript/seeds-webui/public/logos/logo-mark.png differ diff --git a/typescript/seeds-webui/public/logos/logo-with-bg.png b/typescript/seeds-webui/public/logos/logo-with-bg.png new file mode 100644 index 000000000..55817be94 Binary files /dev/null and b/typescript/seeds-webui/public/logos/logo-with-bg.png differ diff --git a/typescript/seeds-webui/public/logos/logo-wordmark.png b/typescript/seeds-webui/public/logos/logo-wordmark.png new file mode 100644 index 000000000..d6c370e81 Binary files /dev/null and b/typescript/seeds-webui/public/logos/logo-wordmark.png differ diff --git a/typescript/seeds-webui/public/logos/logo.png b/typescript/seeds-webui/public/logos/logo.png new file mode 100644 index 000000000..c7eb6ac0a Binary files /dev/null and b/typescript/seeds-webui/public/logos/logo.png differ diff --git a/typescript/seeds-webui/public/logos/logo.svg b/typescript/seeds-webui/public/logos/logo.svg new file mode 100644 index 000000000..2a50f5583 --- /dev/null +++ b/typescript/seeds-webui/public/logos/logo.svg @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/typescript/seeds-webui/scripts/bundle-current-seeds.ts b/typescript/seeds-webui/scripts/bundle-current-seeds.ts new file mode 100644 index 000000000..b65b8fd5a --- /dev/null +++ b/typescript/seeds-webui/scripts/bundle-current-seeds.ts @@ -0,0 +1,63 @@ +// Pre-build step: copy db/seeds/*.json into public/seeds-bundle/ so the prod +// SPA can render the "current state" without touching the GitHub API. +// +// Produces: +// public/seeds-bundle/.json (1:1 copy of each collection) +// public/seeds-bundle/manifest.json { sha, files: [{name, bytes, hash}] } +// +// Run with `bun scripts/bundle-current-seeds.ts`. + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../../.."); +const SRC = path.join(REPO_ROOT, "db", "seeds"); +const OUT = path.join(__dirname, "..", "public", "seeds-bundle"); + +async function main() { + await fs.mkdir(OUT, { recursive: true }); + + const files = (await fs.readdir(SRC)).filter((n) => n.endsWith(".json")).sort(); + + const manifest: { + files: Array<{ bytes: number; hash: string; name: string }>; + sha: string | null; + } = { + sha: detectSha(), + files: [], + }; + + for (const name of files) { + const srcPath = path.join(SRC, name); + const buf = await fs.readFile(srcPath); + const hash = createHash("sha256").update(buf).digest("hex").slice(0, 16); + await fs.writeFile(path.join(OUT, name), buf); + manifest.files.push({ bytes: buf.byteLength, hash, name }); + } + + await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2)); + + const totalBytes = manifest.files.reduce((s, f) => s + f.bytes, 0); + console.log( + `[seeds-webui] bundled ${files.length} files (${(totalBytes / 1024 / 1024).toFixed(1)} MiB) -> ${path.relative(REPO_ROOT, OUT)}`, + ); +} + +function detectSha(): string | null { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT }) + .toString("utf-8") + .trim(); + } catch { + return null; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/typescript/seeds-webui/src/App.tsx b/typescript/seeds-webui/src/App.tsx new file mode 100644 index 000000000..f7f285481 --- /dev/null +++ b/typescript/seeds-webui/src/App.tsx @@ -0,0 +1,76 @@ +import { AppShell } from "#components/AppShell"; +import { IngestToast } from "#components/IngestToast"; +import { EDIT_MODE } from "#lib/config"; +import { IngestProvider } from "#lib/ingest/IngestProvider"; +import { Collection } from "#pages/Collection"; +import { Diff } from "#pages/Diff"; +import { Overview } from "#pages/Overview"; +import { Query } from "#pages/Query"; +import React, { useEffect, useState } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 60_000, + }, + }, +}); + +// Load the editing routes lazily via a single dynamic import. The import is +// only reached when EDIT_MODE is true; since EDIT_MODE is replaced with the +// literal `false` during `vite build`, rollup drops the entire expression — +// which means `editRoutes.ts` and every page it references is never emitted +// into the prod bundle. +type EditRoute = { component: React.ComponentType; path: string }; + +function useEditRoutes(): EditRoute[] { + const [routes, setRoutes] = useState([]); + useEffect(() => { + if (!EDIT_MODE) {return;} + let alive = true; + import("./app/editRoutes") + .then((m) => { + if (alive) {setRoutes(m.editRoutes);} + }) + .catch((err) => { + console.error("[seeds-webui] failed to load edit routes:", err); + }); + return () => { + alive = false; + }; + }, []); + return routes; +} + +export default function App() { + const editRoutes = useEditRoutes(); + return ( + + + + + + + + } + /> + + + {editRoutes.map((r) => ( + + ))} + + + + + + + ); +} diff --git a/typescript/seeds-webui/src/app/editRoutes.ts b/typescript/seeds-webui/src/app/editRoutes.ts new file mode 100644 index 000000000..abbe5fc34 --- /dev/null +++ b/typescript/seeds-webui/src/app/editRoutes.ts @@ -0,0 +1,15 @@ +import type React from "react"; + +import { Bulk } from "#pages/Bulk"; +import { Drafts } from "#pages/Drafts"; +import { Validate } from "#pages/Validate"; + +// This module is only imported from a `EDIT_MODE ? import("...")` call site, +// which in a prod build (with EDIT_MODE === false literal) is dead code. As a +// result rollup never traces through this file and none of the edit-only +// pages end up in the output bundle. +export const editRoutes: Array<{ component: React.ComponentType; path: string }> = [ + { component: Bulk, path: "/bulk" }, + { component: Drafts, path: "/drafts" }, + { component: Validate, path: "/validate" }, +]; diff --git a/typescript/seeds-webui/src/components/AppShell.tsx b/typescript/seeds-webui/src/components/AppShell.tsx new file mode 100644 index 000000000..4936fb04f --- /dev/null +++ b/typescript/seeds-webui/src/components/AppShell.tsx @@ -0,0 +1,136 @@ +import { DevStrip } from "#components/DevStrip"; +import { EDIT_MODE } from "#lib/config"; +import { listDrafts } from "#lib/edits/draft-store"; +import React, { useEffect, useState } from "react"; +import { useQuery } from "react-query"; +import { Link, NavLink, useLocation } from "react-router-dom"; + +function useDraftCount(): number { + const { data = 0 } = useQuery( + "draft-count", + async () => { + if (!EDIT_MODE) {return 0;} + return (await listDrafts()).length; + }, + { + refetchInterval: EDIT_MODE ? 2000 : false, + staleTime: 0, + }, + ); + return data; +} + +export function AppShell({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useTheme(); + const draftCount = useDraftCount(); + const location = useLocation(); + const onDraftsPage = location.pathname === "/drafts"; + + return ( +
+
+ + Tachi +
+ Tachi + Seeds +
+
+ +
+ + {EDIT_MODE ? "Dev" : "Read-only"} + + +
+
+ +
+
{children}
+
+ {EDIT_MODE && draftCount > 0 && !onDraftsPage ? ( + + ) : null} +
+ ); +} + +function DraftsFloatingBar({ count }: { count: number }) { + return ( +
+ ✎ + + {count} staged {count === 1 ? "edit" : "edits"} + + + Review & Apply → + +
+ ); +} + +type Theme = "dark" | "light"; + +function useTheme(): [Theme, (t: Theme) => void] { + const [theme, setThemeState] = useState(() => { + if (typeof document === "undefined") {return "dark";} + return (document.documentElement.getAttribute("data-bs-theme") as Theme) ?? "dark"; + }); + + useEffect(() => { + document.documentElement.setAttribute("data-bs-theme", theme); + document.documentElement.style.setProperty( + "color-scheme", + theme === "light" ? "light" : "dark", + ); + }, [theme]); + + const setTheme = (t: Theme) => { + localStorage.setItem("seeds-webui-theme", t); + setThemeState(t); + }; + return [theme, setTheme]; +} diff --git a/typescript/seeds-webui/src/components/CollectionDiffRows.tsx b/typescript/seeds-webui/src/components/CollectionDiffRows.tsx new file mode 100644 index 000000000..b70d392e2 --- /dev/null +++ b/typescript/seeds-webui/src/components/CollectionDiffRows.tsx @@ -0,0 +1,149 @@ +import type { Row } from "#lib/diff/row-primary-key"; + +import { + type DiffRow, + type FieldDelta, + formatValue, + singleDocumentDiff, +} from "#lib/diff/collection-diff"; +import { useSongByIdLookup } from "#lib/format/use-song-by-id-lookup"; +import { useMemo, useState } from "react"; + +export function SingleRowDocumentDiff({ + before, + after, + collectionName, + songById: songByIdProp, +}: { + after: Record | null; + before: Record | null; + /** When set, used for pretty titles (e.g. FormatChart). If omitted but `songById` is not passed, songs are loaded for `charts-*`. */ + collectionName?: string; + songById?: Map | null; +}) { + const shouldFetchSongs = Boolean(collectionName && songByIdProp === undefined); + const songQuery = useSongByIdLookup( + collectionName && shouldFetchSongs ? collectionName : undefined, + ); + const songById = + songByIdProp !== undefined ? songByIdProp : (songQuery.data ?? undefined); + + const row = useMemo( + () => singleDocumentDiff(before, after, { collectionName, songById }), + [before, after, collectionName, songById], + ); + if (before === null && after === null) { + return
No diff data.
; + } + if (!row) { + return
No changes.
; + } + return ; +} + +export function DiffRows({ rows, limit = 200 }: { limit?: number; rows: DiffRow[] }) { + const visible = rows.slice(0, limit); + const truncated = rows.length - visible.length; + return ( +
+ {visible.map((r) => ( + + ))} + {truncated > 0 ? ( +

+ … {truncated.toLocaleString()} more rows hidden. Use the filter to narrow down. +

+ ) : null} +
+ ); +} + +export function DiffRowCard({ row }: { row: DiffRow }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ + {row.kind === "added" ? "+" : row.kind === "removed" ? "−" : "~"} + + {row.id} + {row.pretty !== row.id ? ( + {row.pretty} + ) : null} + + {row.kind === "changed" ? ( + + {row.fields.length} field + {row.fields.length === 1 ? "" : "s"} changed + + ) : null} + +
+ + {row.kind === "changed" ? : null} + + {row.kind === "added" ? ( +
{formatValue(row.after)}
+ ) : null} + + {row.kind === "removed" ? ( +
{formatValue(row.before)}
+ ) : null} + + {expanded && row.kind === "changed" ? ( +
+
+
before
+
{formatValue(row.before)}
+
+
+
after
+
{formatValue(row.after)}
+
+
+ ) : null} +
+ ); +} + +function FieldTable({ fields }: { fields: FieldDelta[] }) { + return ( + + + + + + + + + + {fields.map((f) => ( + + + + + + ))} + +
FieldBeforeAfter
{f.path} + {f.kind === "added" ? ( + — + ) : ( +
{formatValue(f.before)}
+ )} +
+ {f.kind === "removed" ? ( + — + ) : ( +
{formatValue(f.after)}
+ )} +
+ ); +} diff --git a/typescript/seeds-webui/src/components/CommitSparkline.tsx b/typescript/seeds-webui/src/components/CommitSparkline.tsx new file mode 100644 index 000000000..b086adcc8 --- /dev/null +++ b/typescript/seeds-webui/src/components/CommitSparkline.tsx @@ -0,0 +1,100 @@ +import type { Commit } from "#lib/transport/transport"; + +import { useMemo } from "react"; + +interface CommitSparklineProps { + commits: Commit[]; + /** Number of trailing days to bucket. Default 90. */ + days?: number; +} + +// Tiny inline SVG sparkline of commits per day, no chart lib. Good enough for +// the overview "pulse" view. Bars scale to the busiest day in the range. +export function CommitSparkline({ commits, days = 90 }: CommitSparklineProps) { + const { bins, maxCount, start, end, total } = useMemo(() => { + const now = Date.now(); + const bucket = new Map(); + const startDate = new Date(now - days * 86_400_000); + startDate.setHours(0, 0, 0, 0); + + // Seed every day in range with 0 so gaps show as empty bars. + for (let i = 0; i < days; i++) { + const d = new Date(startDate); + d.setDate(startDate.getDate() + i); + bucket.set(d.toISOString().slice(0, 10), 0); + } + + let total = 0; + for (const c of commits) { + const d = new Date(c.author.date); + if (Number.isNaN(d.getTime())) {continue;} + if (d.getTime() < startDate.getTime()) {continue;} + const key = d.toISOString().slice(0, 10); + if (!bucket.has(key)) {continue;} + bucket.set(key, (bucket.get(key) ?? 0) + 1); + total++; + } + + const bins = Array.from(bucket.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([day, count]) => ({ count, day })); + const maxCount = bins.reduce((m, b) => Math.max(m, b.count), 0); + + return { + bins, + end: new Date(), + maxCount, + start: startDate, + total, + }; + }, [commits, days]); + + const width = 600; + const height = 70; + const pad = 2; + const barW = (width - pad * (bins.length + 1)) / bins.length; + + return ( +
+
+ Commit activity + + {total} commit{total === 1 ? "" : "s"} in the last {days} days + +
+ + {bins.map((b, i) => { + const h = maxCount === 0 ? 0 : (b.count / maxCount) * (height - 4); + const x = pad + i * (barW + pad); + const y = height - h - 2; + const active = b.count > 0; + return ( + + + {b.day} — {b.count} commit{b.count === 1 ? "" : "s"} + + + ); + })} + +
+ {start.toLocaleDateString()} + {end.toLocaleDateString()} +
+
+ ); +} diff --git a/typescript/seeds-webui/src/components/DevStrip.tsx b/typescript/seeds-webui/src/components/DevStrip.tsx new file mode 100644 index 000000000..4df0d8861 --- /dev/null +++ b/typescript/seeds-webui/src/components/DevStrip.tsx @@ -0,0 +1,45 @@ +import { getTransport } from "#lib/transport/transport"; +import { useQuery } from "react-query"; + +// Thin horizontal strip under the header in dev mode that shows the current +// branch + working-tree dirtiness. Rendered only when the transport is the +// dev one (i.e. /__seeds/ping succeeded). +export function DevStrip() { + const { data: mode } = useQuery("transport-mode", async () => (await getTransport()).mode, { + staleTime: Infinity, + }); + const { data: status } = useQuery( + "git-status", + async () => { + const t = await getTransport(); + return t.gitStatus ? t.gitStatus() : null; + }, + { + enabled: mode === "dev", + refetchInterval: 15_000, + staleTime: 10_000, + }, + ); + + if (mode !== "dev") { + return null; + } + + return ( +
+ You are on branch + {status?.branch ?? "…"} + {status?.hasUncommittedChanges ? ( + + {status.changedFiles.length} changed file + {status.changedFiles.length === 1 ? "" : "s"} under db/seeds/ + + ) : ( + No seeds changes made! + )} + + You can edit files, and they will change the seed files on your system. + +
+ ); +} diff --git a/typescript/seeds-webui/src/components/IngestToast.tsx b/typescript/seeds-webui/src/components/IngestToast.tsx new file mode 100644 index 000000000..134c207f2 --- /dev/null +++ b/typescript/seeds-webui/src/components/IngestToast.tsx @@ -0,0 +1,78 @@ +import { useIngest } from "#lib/ingest/IngestProvider"; +import { useEffect, useState } from "react"; + +// Bottom-right toast that surfaces sqlite ingest progress from anywhere in +// the app. Stays pinned while ingesting; auto-hides ~1.5s after completion. +export function IngestToast() { + const { error, progress, ready } = useIngest(); + const [visible, setVisible] = useState(false); + const [hiding, setHiding] = useState(false); + + useEffect(() => { + if (error || (!ready && progress)) { + setVisible(true); + setHiding(false); + return; + } + + if (ready && visible) { + setHiding(true); + const t = setTimeout(() => { + setVisible(false); + setHiding(false); + }, 1500); + return () => clearTimeout(t); + } + }, [error, progress, ready, visible]); + + if (!visible) {return null;} + + const pct = + progress && progress.total > 0 + ? Math.round(((progress.idx + 1) / progress.total) * 100) + : 0; + + return ( +
+
+ {error ? ( + <> + + Ingest failed + + ) : ready ? ( + <> + + Database ready + + ) : ( + <> + + Loading seeds + + {progress ? `${progress.idx + 1}/${progress.total}` : ""} + + + )} +
+ {!ready && !error && progress ? ( + <> +
+ {progress.name} + + {progress.cached ? "cached" : `${progress.rows.toLocaleString()} rows`} + +
+
+
+
+ + ) : null} + {error ?
{error}
: null} +
+ ); +} diff --git a/typescript/seeds-webui/src/components/ResultTable.tsx b/typescript/seeds-webui/src/components/ResultTable.tsx new file mode 100644 index 000000000..bea6bb20e --- /dev/null +++ b/typescript/seeds-webui/src/components/ResultTable.tsx @@ -0,0 +1,249 @@ +import type { QueryResult } from "#lib/sqlite/types"; + +import { useMemo, useState } from "react"; + +// Threshold (in chars) above which a cell value is treated as a "blob" and +// gets its own scrollable monospace code box instead of an inline cell. +const LONG_VALUE_THRESHOLD = 80; + +interface ResultTableProps { + result: QueryResult; + // Soft cap on rows rendered to keep the DOM cheap. Extra rows are counted + // in the footer. Defaults to 500. + maxRows?: number; + // Optional footnote shown under the table (e.g. "first 500 of 1234 rows"). + footerNote?: React.ReactNode; + // Optional renderer for trailing action buttons per row. Wrap interactive + // elements in onClick={e => e.stopPropagation()} so they don't toggle row + // expansion. + rowActions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + // If provided, replaces the default pretty-printed JSON expansion with + // whatever this renders. In `toggle` mode, clicking the row shows/hides it. + // In `inline` mode, the detail is always shown under each data row (no + // expand column or click). Omit when you only have data rows. + expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + // `toggle` (default): chevron, click row to show detail. `inline`: always + // show detail under each row when `expandedContent` is set; data rows are + // not clickable. Used e.g. by Bulk to keep diffs visible without expanding. + rowDetailMode?: "inline" | "toggle"; +} + +// Shared, readable result table. Used by Query and Bulk pages. +// +// Design notes: +// - Cells wrap (`white-space: pre-wrap`) instead of ellipsising, so JSON +// and long text are actually visible. +// - Very long values get a scroll-capped code block, so one fat `raw` +// column doesn't blow up row height. +// - By default, clicking a row toggles a detail panel (see `rowDetailMode`). +// Pass `expandedContent` for custom content (e.g. a diff); otherwise the +// detail is the whole row as pretty-printed JSON. +// - `rowDetailMode="inline"` (with `expandedContent`) shows that detail +// under every row with no chevron or click — e.g. Bulk edit previews. +export function ResultTable({ + result, + maxRows = 500, + footerNote, + rowActions, + expandedContent, + rowDetailMode = "toggle", +}: ResultTableProps) { + const visible = result.rows.slice(0, maxRows); + const hidden = result.rows.length - visible.length; + const inline = rowDetailMode === "inline" && Boolean(expandedContent); + + // Heuristic: mark columns where any sampled value is long-ish as "wide", + // so we render them as blobs instead of trying to squeeze them into a cell. + const wideCols = useMemo(() => { + const set = new Set(); + const sample = visible.slice(0, 50); + for (let col = 0; col < result.columns.length; col++) { + for (const row of sample) { + const v = row[col]; + if (typeof v === "string" && v.length > LONG_VALUE_THRESHOLD) { + set.add(col); + break; + } + } + } + return set; + }, [visible, result.columns.length]); + + return ( +
+
+ + + + {inline ? null : ( + + ))} + {rowActions ? ( + + + + {visible.map((row, i) => ( + + ))} + +
+ )} + {result.columns.map((c, i) => ( + + {c} + + ) : null} +
+
+ {hidden > 0 ? ( +
+ Showing first {visible.length.toLocaleString()} of{" "} + {result.rows.length.toLocaleString()} rows +
+ ) : null} + {footerNote ?
{footerNote}
: null} +
+ ); +} + +function ResultRow({ + row, + columns, + wideCols, + actions, + expandedContent, + inlineDetail, +}: { + actions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + columns: string[]; + expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + inlineDetail: boolean; + row: unknown[]; + wideCols: Set; +}) { + const [open, setOpen] = useState(false); + + function toggle() { + setOpen((x) => !x); + } + + const extraCols = actions ? 1 : 0; + const showPanel = inlineDetail || open; + const isInline = inlineDetail; + + return ( + <> + { + if (e.key === "Enter" || e.key === " ") {toggle();} + } + } + role={isInline ? undefined : "button"} + tabIndex={isInline ? undefined : 0} + > + {isInline ? null : ( + + + ▸ + + + )} + {row.map((v, j) => ( + + ))} + {actions ? ( + e.stopPropagation()} + > + {actions({ columns, row })} + + ) : null} + + {showPanel ? ( + + {isInline ? null : } + + {expandedContent ? ( + expandedContent({ columns, row }) + ) : ( +
{formatRowAsJson(row, columns)}
+ )} + + + ) : null} + + ); +} + +function ResultCell({ value, wide }: { value: unknown; wide: boolean }) { + if (value === null) { + return ( + + NULL + + ); + } + + const str = typeof value === "string" ? value : String(value); + const looksJson = + typeof value === "string" && str.length > 2 && (str.startsWith("{") || str.startsWith("[")); + + if (wide || str.length > LONG_VALUE_THRESHOLD || looksJson) { + return ( + +
{looksJson ? pretty(str) : str}
+ + ); + } + + return {str}; +} + +function pretty(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} + +function formatRowAsJson(row: unknown[], columns: string[]): string { + const obj: Record = {}; + for (let i = 0; i < columns.length; i++) { + const k = columns[i]!; + const v = row[i]; + if (typeof v === "string") { + // If the value itself is a JSON string (like the `raw` column), + // nest the parsed value so the row-json reads naturally. + if (v.length > 2 && (v.startsWith("{") || v.startsWith("["))) { + try { + obj[k] = JSON.parse(v); + continue; + } catch { + // fall through + } + } + obj[k] = v; + } else { + obj[k] = v; + } + } + return JSON.stringify(obj, null, 2); +} diff --git a/typescript/seeds-webui/src/components/RowEditor.tsx b/typescript/seeds-webui/src/components/RowEditor.tsx new file mode 100644 index 000000000..6e17fb9ff --- /dev/null +++ b/typescript/seeds-webui/src/components/RowEditor.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; +import { type z } from "zod"; + +// --------------------------------------------------------------------------- +// JSON textarea editor with live Zod validation. +// +// Replaces the previous schema-driven field-tree form. Seeds documents are +// complex enough (discriminated unions, per-game chart data, etc.) that a +// generated form was always going to be leaky — it's cleaner to just edit +// the raw JSON and let the schema tell you exactly what's wrong. +// +// Behaviour: +// - Textarea is pre-filled with the initial value as pretty-printed JSON. +// - On every keystroke we try JSON.parse; if it succeeds we run safeParse +// against the Zod schema and show any issues inline. +// - Save is disabled until the JSON is both valid JSON and passes the schema. +// --------------------------------------------------------------------------- + +interface RowEditorProps { + schema: z.ZodType; + initial: unknown; + onSave: (value: unknown) => void; + onCancel: () => void; + title?: string; + submitLabel?: string; +} + +export function RowEditor({ + schema, + initial, + onSave, + onCancel, + title, + submitLabel = "Save", +}: RowEditorProps) { + const [text, setText] = useState(() => safeStringify(initial)); + const [parseError, setParseError] = useState(null); + const [zodErrors, setZodErrors] = useState([]); + const [parsed, setParsed] = useState(initial); + + useEffect(() => { + setText(safeStringify(initial)); + setParseError(null); + setZodErrors([]); + setParsed(initial); + }, [initial]); + + function handleChange(s: string) { + setText(s); + try { + const v = JSON.parse(s); + setParseError(null); + setParsed(v); + const result = schema.safeParse(v); + if (result.success) { + setZodErrors([]); + } else { + setZodErrors( + result.error.issues.map( + (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`, + ), + ); + } + } catch (e) { + setParseError(e instanceof Error ? e.message : String(e)); + setZodErrors([]); + setParsed(undefined); + } + } + + function handleSave() { + const result = schema.safeParse(parsed); + if (!result.success) {return;} + onSave(result.data); + } + + const isValid = !parseError && zodErrors.length === 0 && parsed !== undefined; + + return ( +
+ {title ?

{title}

: null} +
+