diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml new file mode 100644 index 0000000..c132548 --- /dev/null +++ b/.github/workflows/performance.yml @@ -0,0 +1,127 @@ +name: Performance Validation + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: performance-validation-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI: true + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec tsc --noEmit + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + + unit: + name: Unit Stability + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec vitest run tests/save-race.test.ts tests/workspace-race.test.ts + + fidelity: + name: Markdown Fidelity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec vitest run tests/fidelity-production.test.ts + - run: node scripts/fidelity-gate.mjs docs 10 + + perf-smoke: + name: Performance Smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Check web asset budget + run: | + node <<'NODE' + const fs = require("node:fs"); + const path = require("node:path"); + const assetsDir = path.join(process.cwd(), "dist", "assets"); + const maxAssetBytes = 1_500_000; + const assets = fs.existsSync(assetsDir) + ? fs.readdirSync(assetsDir).map((name) => path.join(assetsDir, name)) + : []; + const oversized = assets + .map((file) => ({ file, size: fs.statSync(file).size })) + .filter(({ size }) => size > maxAssetBytes); + if (oversized.length > 0) { + console.error("Assets above 1.5MB smoke budget:"); + for (const { file, size } of oversized) { + console.error(`${path.relative(process.cwd(), file)}: ${size} bytes`); + } + process.exit(1); + } + NODE + + web-e2e: + name: Web E2E + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:e2e:web + + # Linux Tauri WebDriver notes: + # - Run this only on Linux images with webkit2gtk, xvfb, tauri-driver, and the + # app bundle installed. The web smoke above intentionally does not exercise + # native dialogs, filesystem permissions, or WebView focus behavior. + # - A future native lane should start `tauri-driver`, run the packaged app + # under xvfb, then execute WebDriver specs against 127.0.0.1:4444. diff --git a/docs/performance-validation.md b/docs/performance-validation.md new file mode 100644 index 0000000..3520aee --- /dev/null +++ b/docs/performance-validation.md @@ -0,0 +1,38 @@ +# Performance Validation + +This repo validates stability and fidelity in separate CI lanes so failures stay easy to triage. + +## CI lanes + +- `Typecheck`: `pnpm exec tsc --noEmit` +- `Build`: `pnpm build` +- `Unit Stability`: save-race and workspace stale-scan regression tests +- `Markdown Fidelity`: production editor markdown idempotency tests plus the corpus fidelity gate over `docs` +- `Performance Smoke`: production build plus a 1.5 MB per-asset smoke budget +- `Web E2E`: runs headless Playwright against Vite with mocked Tauri APIs + +## Local commands + +```sh +pnpm install --frozen-lockfile +pnpm exec tsc --noEmit +pnpm build +pnpm exec vitest run tests/save-race.test.ts tests/workspace-race.test.ts +pnpm exec vitest run tests/fidelity-production.test.ts +pnpm exec playwright install chromium +pnpm test:e2e:web +pnpm test:ci +``` + +## Native Tauri WebDriver + +The workflow includes comments for the native Linux WebDriver lane, but does not enable it yet. That lane needs a runner image with `webkit2gtk`, `xvfb`, `tauri-driver`, and the packaged FullMark app available. Once those dependencies are pinned, the lane should: + +1. Build the Tauri bundle. +2. Start `tauri-driver` on `127.0.0.1:4444`. +3. Launch the packaged app under `xvfb`. +4. Run WebDriver specs against native filesystem dialogs, focus behavior, and WebView keyboard handling. + +The current `web-e2e` lane is intentionally narrower: it proves the frontend +workflows against mocked Tauri APIs, but it does not validate native filesystem +dialogs, platform focus behavior, or packaged-app startup. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..12d8c1a --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,71 @@ +# Performance Harness + +FullMark's benchmark and fidelity tests use generated vaults only. The harness +never reads a real user corpus, so CI and local runs are deterministic and safe +to share. + +## Profiles + +The generator supports these profiles: + +- `small`: 32 markdown notes across a shallow tree. +- `medium`: 750 notes with frontmatter, wikilinks, and mixed `.md`/`.mdx`. +- `large`: 5,000 notes across a deeper tree. +- `chaos`: 1,200 notes plus hidden folders, non-markdown files, long names, and empty directories. +- `flat-50k`: 50,000 markdown notes in one directory. +- `deep-5k`: 5,000 notes distributed through a deep tree. +- `wide-dirs`: 3,000 notes spread across many sibling directories. +- `mixed-large`: 10,000 notes plus non-markdown and hidden-path noise. +- `unicode-paths`: 420 notes with Unicode directory and file names. + +## Commands + +```sh +npm run test:unit +npm run test:fidelity +npm run test:perf +npm run test:e2e:web +npm run test:ci +``` + +Run every performance profile when you need a full local sweep: + +```sh +node scripts/perf-bench.mjs --profiles all --sample-size 256 +``` + +The benchmark records these critical paths: + +- generated vault creation +- markdown file discovery +- sampled `stat` calls +- sampled file read and frontmatter parse +- MiniSearch index construction +- representative query execution +- sampled TipTap markdown round-trip + +## Artifacts + +`scripts/perf-bench.mjs` writes JSON and Markdown reports. By default, reports +and generated vaults are written under the OS temp directory. Set +`FULLMARK_KEEP_ARTIFACTS=1` to keep generated vaults and reports under `.tmp`: + +```sh +FULLMARK_KEEP_ARTIFACTS=1 npm run test:perf +``` + +Set `FULLMARK_ARTIFACT_DIR=/path/to/reports` to choose an explicit report +directory. Generated vault content is deterministic for a profile and seed. + +## Headless web E2E + +`npm run test:e2e:web` runs Playwright headlessly against Vite. The tests mock +Tauri's dialog, event, filesystem, and command APIs before the app loads, then +exercise the real React UI for workspace open, markdown-only tree filtering, +source editing and save, quick switching, and reader mode. + +Install the browser once on a fresh machine: + +```sh +pnpm exec playwright install chromium +``` diff --git a/package.json b/package.json index 008e62a..e69e6a6 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,20 @@ "name": "fullmark", "private": true, "version": "0.1.5", + "packageManager": "pnpm@10.18.2", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "build:install": "scripts/build-install.sh", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test:unit": "vitest run", + "test:fidelity": "node scripts/fidelity-gate.mjs --profiles small,unicode-paths --limit 120", + "test:perf": "node scripts/perf-bench.mjs --profiles small,medium,chaos --sample-size 128", + "test:e2e:web": "playwright test --config playwright.config.ts", + "test:e2e:tauri": "vitest run --passWithNoTests tests/e2e/tauri/**/*.test.ts", + "test:ci": "vitest run && node scripts/fidelity-gate.mjs --profiles small,unicode-paths --limit 120 && node scripts/perf-bench.mjs --profiles small --sample-size 32 && pnpm test:e2e:web" }, "dependencies": { "@floating-ui/dom": "^1.7.6", @@ -16,6 +23,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", + "@tauri-apps/plugin-opener": "^2.5.4", "@tiptap/core": "^3.23.1", "@tiptap/extension-code-block": "^3.23.1", "@tiptap/extension-code-block-lowlight": "^3.23.1", @@ -40,6 +48,7 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.3.0", "@tauri-apps/cli": "^2", "@types/node": "^25.6.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..42eb967 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/e2e/web", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + fullyParallel: true, + reporter: process.env.CI ? [["github"], ["list"]] : "list", + use: { + baseURL: "http://127.0.0.1:1420", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: "pnpm dev --host 127.0.0.1", + url: "http://127.0.0.1:1420", + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ee744..e4f9165 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@tauri-apps/plugin-fs': specifier: ^2.5.1 version: 2.5.1 + '@tauri-apps/plugin-opener': + specifier: ^2.5.4 + version: 2.5.4 '@tiptap/core': specifier: ^3.23.1 version: 3.23.1(@tiptap/pm@3.23.1) @@ -90,6 +93,9 @@ importers: specifier: ^5.0.13 version: 5.0.13(@types/react@19.2.14)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) devDependencies: + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)) @@ -558,6 +564,11 @@ packages: '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -893,6 +904,9 @@ packages: '@tauri-apps/plugin-fs@2.5.1': resolution: {integrity: sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==} + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + '@tiptap/core@3.23.1': resolution: {integrity: sha512-8YvSGiJTeU5wPuGiYIIYgyiyaaT1CAx+kJL0bju0w871OvbJJj0T/ywhcmxGXW6pOal2T8X2xt9ZqE+vib0VJw==} peerDependencies: @@ -1757,6 +1771,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2107,6 +2126,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -2900,6 +2929,10 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -3146,6 +3179,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tiptap/core@3.23.1(@tiptap/pm@3.23.1)': dependencies: '@tiptap/pm': 3.23.1 @@ -4152,6 +4189,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4489,6 +4529,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/scripts/fidelity-gate.mjs b/scripts/fidelity-gate.mjs index 51d30aa..b7107c9 100644 --- a/scripts/fidelity-gate.mjs +++ b/scripts/fidelity-gate.mjs @@ -1,30 +1,49 @@ +#!/usr/bin/env node + /** - * Fidelity gate — runs markdown round-trip through TipTap + @tiptap/markdown - * against the user's actual brain/ corpus and reports divergences. + * Generated-only markdown fidelity gate. * * Usage: - * node scripts/fidelity-gate.mjs [path/to/corpus] [limit] - * - * Defaults: /Users/vamsi/Coding/vamsios/brain, limit=25 + * node scripts/fidelity-gate.mjs --profiles small,unicode-paths --limit 120 * - * Output: - * - Summary: identical / with-diff / errors counts - * - Per-file: pass marker, byte deltas, diff line counts - * - First N divergences shown as unified diff fragments + * Generated vaults live in the OS temp directory by default. Set + * FULLMARK_KEEP_ARTIFACTS=1 to keep generated vaults and report artifacts under .tmp. */ import { JSDOM } from "jsdom"; import * as Diff from "diff"; import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; +import { + PROFILE_NAMES, + createGeneratedVault, + removeGeneratedVault, +} from "./generate-vault.mjs"; -// --- DOM polyfill (must run BEFORE importing TipTap) --- +const SHOW_DIFFS = 5; +const MD_EXTENSIONS = new Set([".md", ".mdx", ".markdown"]); +const HIDDEN = new Set([ + ".git", + ".svn", + ".hg", + ".DS_Store", + ".editor", + ".obsidian", + "node_modules", + "dist", + "build", + "target", + ".next", + ".turbo", +]); + +// DOM polyfill must run before importing TipTap. const dom = new JSDOM("
", { url: "http://localhost/", pretendToBeVisual: true, }); -// Node 24 makes some globals (navigator) getter-only — copy carefully. globalThis.window = dom.window; globalThis.document = dom.window.document; globalThis.HTMLElement = dom.window.HTMLElement; @@ -34,7 +53,6 @@ globalThis.DocumentFragment = dom.window.DocumentFragment; globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 16); globalThis.cancelAnimationFrame = (id) => clearTimeout(id); -// Only override navigator if jsdom's version is missing fields TipTap probes try { Object.defineProperty(globalThis, "navigator", { value: dom.window.navigator, @@ -42,31 +60,98 @@ try { configurable: true, }); } catch { - /* Node 24+ has navigator pre-defined; jsdom can fall back to its own */ + // Node 24+ may expose navigator as a getter-only global. } -// --- TipTap imports (after polyfill so module init sees globals) --- const { Editor } = await import("@tiptap/core"); const { StarterKit } = await import("@tiptap/starter-kit"); const { Markdown } = await import("@tiptap/markdown"); -const BRAIN = process.argv[2] || "/Users/vamsi/Coding/vamsios/brain"; -const LIMIT = Number(process.argv[3]) || 25; -const SHOW_DIFFS = 5; +function keepArtifacts() { + return process.env.FULLMARK_KEEP_ARTIFACTS === "1"; +} -async function findMdFiles(dir, files = []) { - let entries; - try { - entries = await fs.readdir(dir, { withFileTypes: true }); - } catch { - return files; +function defaultArtifactDir() { + if (process.env.FULLMARK_ARTIFACT_DIR) { + return path.resolve(process.env.FULLMARK_ARTIFACT_DIR); + } + if (keepArtifacts()) { + return path.resolve(process.cwd(), ".tmp", "fidelity-artifacts"); + } + return path.join(os.tmpdir(), "fullmark-fidelity-artifacts"); +} + +function parseProfiles(value) { + if (!value || value === "all") return PROFILE_NAMES; + return value + .split(",") + .map((profile) => profile.trim()) + .filter(Boolean); +} + +function parseArgs(argv) { + const args = { + profiles: ["small"], + limit: 100, + artifactDir: defaultArtifactDir(), + seed: undefined, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--profiles" || arg === "--profile") { + args.profiles = parseProfiles(argv[++i]); + } else if (arg.startsWith("--profiles=")) { + args.profiles = parseProfiles(arg.slice("--profiles=".length)); + } else if (arg.startsWith("--profile=")) { + args.profiles = parseProfiles(arg.slice("--profile=".length)); + } else if (arg === "--limit") { + args.limit = Number(argv[++i]); + } else if (arg.startsWith("--limit=")) { + args.limit = Number(arg.slice("--limit=".length)); + } else if (arg === "--out") { + args.artifactDir = path.resolve(argv[++i]); + } else if (arg.startsWith("--out=")) { + args.artifactDir = path.resolve(arg.slice("--out=".length)); + } else if (arg === "--seed") { + args.seed = argv[++i]; + } else if (arg.startsWith("--seed=")) { + args.seed = arg.slice("--seed=".length); + } + } + + if (!Number.isFinite(args.limit) || args.limit < 1) { + throw new Error("--limit must be a positive number"); } - for (const e of entries) { - if (e.name.startsWith(".")) continue; - if (["node_modules", "dist", "target"].includes(e.name)) continue; - const p = path.join(dir, e.name); - if (e.isDirectory()) await findMdFiles(p, files); - else if (e.name.endsWith(".md")) files.push(p); + + for (const profile of args.profiles) { + if (!PROFILE_NAMES.includes(profile)) { + throw new Error(`Unknown profile "${profile}". Expected: ${PROFILE_NAMES.join(", ")}`); + } + } + + return args; +} + +function isMarkdown(name) { + return MD_EXTENSIONS.has(path.extname(name).toLowerCase()); +} + +function isHidden(name) { + return HIDDEN.has(name) || name.startsWith("."); +} + +async function findMdFiles(dir, files = []) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (isHidden(entry.name)) continue; + const absolutePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await findMdFiles(absolutePath, files); + } else if (entry.isFile() && isMarkdown(entry.name)) { + files.push(absolutePath); + } } return files; } @@ -88,6 +173,7 @@ function roundTrip(md) { return editor.getMarkdown(); } finally { editor.destroy(); + editor.options.element?.remove?.(); } } @@ -98,6 +184,7 @@ function getJSON(md) { return JSON.stringify(editor.getJSON()); } finally { editor.destroy(); + editor.options.element?.remove?.(); } } @@ -119,17 +206,6 @@ function summarize(original, output, idempotent, semanticAstMatch) { }; } -/** - * Normalize a markdown string for "semantic equivalence" comparison. - * - Decode common HTML entities (& > → & > <) - * - Collapse multiple blank lines into one - * - Trim trailing whitespace on each line - * - Strip leading/trailing whitespace on the whole document - * - * If two strings are equivalent after normalization, the divergence is - * cosmetic (rendering-equivalent) and the save layer can safely treat them - * as "no change" to avoid spurious diffs on disk. - */ function normalize(s) { return s .replace(/&/g, "&") @@ -138,13 +214,13 @@ function normalize(s) { .replace(/'/g, "'") .replace(/"/g, '"') .split("\n") - .map((l) => l.replace(/\s+$/, "")) + .map((line) => line.replace(/\s+$/, "")) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } -function printUnifiedFragment(original, output, contextLines = 2, maxHunks = 6) { +function unifiedFragment(original, output, contextLines = 2, maxHunks = 6) { const hunks = Diff.structuredPatch( "original", "round-tripped", @@ -154,97 +230,202 @@ function printUnifiedFragment(original, output, contextLines = 2, maxHunks = 6) "", { context: contextLines }, ).hunks; - for (const h of hunks.slice(0, maxHunks)) { - console.log( - ` @@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, - ); - for (const line of h.lines) console.log(` ${line}`); + const lines = []; + for (const hunk of hunks.slice(0, maxHunks)) { + lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`); + lines.push(...hunk.lines); } if (hunks.length > maxHunks) { - console.log(` ... (${hunks.length - maxHunks} more hunks omitted)`); + lines.push(`... (${hunks.length - maxHunks} more hunks omitted)`); } + return lines.join("\n"); } -async function main() { - const allFiles = await findMdFiles(BRAIN); - const files = allFiles.slice(0, LIMIT); - console.log( - `Corpus: ${BRAIN}\nFound ${allFiles.length} .md files; testing first ${files.length}.\n`, - ); +async function runProfile(profile, args) { + let vault; + try { + vault = await createGeneratedVault({ + profile, + seed: args.seed ? `${args.seed}:${profile}` : undefined, + contentMode: "roundtrip-safe", + }); + const allFiles = await findMdFiles(vault.root); + const files = allFiles.slice(0, args.limit); - const results = []; - for (const f of files) { - const md = await fs.readFile(f, "utf-8"); - try { - const out1 = roundTrip(md); - const out2 = roundTrip(out1); - const idempotent = out1 === out2; - const semanticAstMatch = getJSON(md) === getJSON(out1); - const s = summarize(md, out1, idempotent, semanticAstMatch); - results.push({ - file: path.relative(BRAIN, f), - original: md, - output: out1, - ...s, - }); - } catch (e) { - results.push({ - file: path.relative(BRAIN, f), - error: e?.message || String(e), - }); + console.log( + `Corpus: ${profile} (${vault.root})\nFound ${allFiles.length} markdown files; testing ${files.length}.\n`, + ); + + const results = []; + for (const file of files) { + const md = await fs.readFile(file, "utf-8"); + const relativeFile = path.relative(vault.root, file); + try { + const out1 = roundTrip(md); + const out2 = roundTrip(out1); + const idempotent = out1 === out2; + const semanticAstMatch = getJSON(md) === getJSON(out1); + const summary = summarize(md, out1, idempotent, semanticAstMatch); + results.push({ + file: relativeFile, + originalBytes: Buffer.byteLength(md), + outputBytes: Buffer.byteLength(out1), + ...summary, + diff: summary.isIdentical ? undefined : unifiedFragment(md, out1), + }); + } catch (error) { + results.push({ + file: relativeFile, + error: error?.message || String(error), + }); + } } + + return summarizeProfile(profile, vault, allFiles, files, results); + } finally { + await removeGeneratedVault(vault); } +} - // Summary table - const ok = results.filter((r) => r.isIdentical).length; - const idempotent = results.filter( - (r) => !r.error && r.idempotent, - ).length; - const astMatch = results.filter( - (r) => !r.error && r.semanticAstMatch, - ).length; +function summarizeProfile(profile, vault, allFiles, files, results) { + const byteIdentical = results.filter((r) => r.isIdentical).length; + const semanticEquivalent = results.filter((r) => r.isSemanticEquivalent).length; + const idempotent = results.filter((r) => !r.error && r.idempotent).length; + const astMatch = results.filter((r) => !r.error && r.semanticAstMatch).length; const errors = results.filter((r) => r.error).length; - console.log(`=== SUMMARY ===`); + console.log("=== SUMMARY ==="); + console.log(`Profile: ${profile}`); console.log(`Total: ${results.length}`); - console.log(`✓ Byte-identical: ${ok}`); - console.log(`↻ Idempotent (2nd save no-op): ${idempotent}`); - console.log(`≡ Same parsed AST: ${astMatch} (truly lossless)`); - console.log(`✗ Errors: ${errors}`); + console.log(`Byte-identical: ${byteIdentical}`); + console.log(`Semantic equivalent: ${semanticEquivalent}`); + console.log(`Idempotent: ${idempotent}`); + console.log(`Same parsed AST: ${astMatch}`); + console.log(`Errors: ${errors}`); console.log(); - // Per-file outcomes - for (const r of results) { - if (r.error) { - console.log(`✗ ${r.file} — ERROR: ${r.error}`); - } else if (r.isIdentical) { - console.log(`✓ ${r.file}`); + for (const result of results) { + if (result.error) { + console.log(`ERROR ${result.file}: ${result.error}`); + } else if (result.isIdentical) { + console.log(`OK ${result.file}`); + } else if ( + result.isSemanticEquivalent && + result.idempotent && + result.semanticAstMatch + ) { + console.log(`OK ${result.file} [canonical]`); } else { - const origLines = r.original.split("\n").length; - const outLines = r.output.split("\n").length; const flags = [ - r.idempotent ? "↻idempotent" : "✗not-idempotent", - r.semanticAstMatch ? "≡ast-match" : "✗ast-mismatch", + result.idempotent ? "idempotent" : "not-idempotent", + result.semanticAstMatch ? "ast-match" : "ast-mismatch", ].join(" "); console.log( - `≠ ${r.file} lines: ${origLines}→${outLines} diff: +${r.added} -${r.removed} [${flags}]`, + `DIFF ${result.file} bytes: ${result.originalBytes}->${result.outputBytes} diff: +${result.added} -${result.removed} [${flags}]`, ); } } - // First N divergences in detail - const diverged = results.filter((r) => !r.isIdentical && !r.error); + const diverged = results.filter((r) => { + return ( + !r.error && + !r.isIdentical && + (!r.isSemanticEquivalent || !r.idempotent || !r.semanticAstMatch) + ); + }); if (diverged.length > 0) { console.log(`\n=== DIVERGENCE DETAILS (first ${SHOW_DIFFS}) ===\n`); - for (const r of diverged.slice(0, SHOW_DIFFS)) { - console.log(`--- ${r.file} ---`); - printUnifiedFragment(r.original, r.output, 2, 6); + for (const result of diverged.slice(0, SHOW_DIFFS)) { + console.log(`--- ${result.file} ---`); + console.log(result.diff); console.log(); } } + + return { + profile, + seed: vault.seed, + root: vault.root, + markdownFileCount: allFiles.length, + testedFileCount: files.length, + summary: { + byteIdentical, + semanticEquivalent, + idempotent, + astMatch, + errors, + }, + results, + }; +} + +function toMarkdown(report, jsonFile) { + const lines = [ + "# FullMark Generated Fidelity Gate", + "", + `JSON artifact: \`${path.basename(jsonFile)}\``, + "", + "| Profile | Tested | Byte-identical | Semantic equivalent | Idempotent | AST match | Errors |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", + ]; + + for (const profile of report.results) { + lines.push( + [ + `| ${profile.profile}`, + profile.testedFileCount, + profile.summary.byteIdentical, + profile.summary.semanticEquivalent, + profile.summary.idempotent, + profile.summary.astMatch, + profile.summary.errors, + ].join(" | ") + " |", + ); + } + + lines.push(""); + lines.push("Generated vaults are built at runtime; no real user corpus is read."); + return `${lines.join("\n")}\n`; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + await fs.mkdir(args.artifactDir, { recursive: true }); + + const results = []; + for (const profile of args.profiles) { + results.push(await runProfile(profile, args)); + } + + const report = { + startedAt: new Date().toISOString(), + node: process.version, + platform: `${process.platform} ${process.arch}`, + profiles: args.profiles, + limit: args.limit, + results, + }; + + const suffix = args.profiles.join("_").replace(/[^a-z0-9_-]+/gi, "-"); + const jsonFile = path.join(args.artifactDir, `fidelity-gate-${suffix}.json`); + const markdownFile = path.join(args.artifactDir, `fidelity-gate-${suffix}.md`); + await fs.writeFile(jsonFile, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + await fs.writeFile(markdownFile, toMarkdown(report, jsonFile), "utf8"); + + console.log(`Wrote ${jsonFile}`); + console.log(`Wrote ${markdownFile}`); + + const totalErrors = results.reduce((sum, item) => sum + item.summary.errors, 0); + const nonIdempotent = results.reduce( + (sum, item) => sum + (item.testedFileCount - item.summary.idempotent), + 0, + ); + if (totalErrors > 0 || nonIdempotent > 0) { + process.exitCode = 1; + } } -main().catch((e) => { - console.error("Fatal:", e); +main().catch((error) => { + console.error("Fatal:", error?.stack || error); process.exit(1); }); diff --git a/scripts/generate-vault.mjs b/scripts/generate-vault.mjs new file mode 100644 index 0000000..c8b5b51 --- /dev/null +++ b/scripts/generate-vault.mjs @@ -0,0 +1,533 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const MARKDOWN_EXTENSIONS = [".md", ".mdx", ".markdown"]; +const HIDDEN_DIRS = [".git", ".obsidian", "node_modules", "dist"]; +const NON_MARKDOWN_EXTENSIONS = [".txt", ".json", ".png", ".pdf", ".csv"]; + +export const PROFILE_NAMES = [ + "small", + "medium", + "large", + "chaos", + "flat-50k", + "deep-5k", + "wide-dirs", + "mixed-large", + "unicode-paths", +]; + +export const PROFILES = { + small: { + fileCount: 32, + dirCount: 8, + maxDepth: 3, + paragraphs: 3, + linksPerFile: 3, + tagsPerFile: 2, + frontmatter: true, + extensions: [".md"], + }, + medium: { + fileCount: 750, + dirCount: 80, + maxDepth: 5, + paragraphs: 4, + linksPerFile: 5, + tagsPerFile: 4, + frontmatter: true, + extensions: [".md", ".mdx"], + }, + large: { + fileCount: 5_000, + dirCount: 320, + maxDepth: 8, + paragraphs: 5, + linksPerFile: 7, + tagsPerFile: 5, + frontmatter: true, + extensions: MARKDOWN_EXTENSIONS, + }, + chaos: { + fileCount: 1_200, + dirCount: 160, + maxDepth: 7, + paragraphs: 5, + linksPerFile: 9, + tagsPerFile: 6, + frontmatter: true, + extensions: MARKDOWN_EXTENSIONS, + hiddenDirCount: 8, + nonMarkdownCount: 240, + longNames: true, + sparseEmptyDirs: 36, + }, + "flat-50k": { + fileCount: 50_000, + dirCount: 1, + maxDepth: 1, + paragraphs: 1, + linksPerFile: 2, + tagsPerFile: 2, + frontmatter: false, + extensions: [".md"], + flat: true, + }, + "deep-5k": { + fileCount: 5_000, + dirCount: 120, + maxDepth: 64, + paragraphs: 2, + linksPerFile: 3, + tagsPerFile: 3, + frontmatter: true, + extensions: [".md"], + deep: true, + }, + "wide-dirs": { + fileCount: 3_000, + dirCount: 1_200, + maxDepth: 2, + paragraphs: 2, + linksPerFile: 2, + tagsPerFile: 2, + frontmatter: false, + extensions: [".md"], + wide: true, + sparseEmptyDirs: 300, + }, + "mixed-large": { + fileCount: 10_000, + dirCount: 600, + maxDepth: 7, + paragraphs: 4, + linksPerFile: 8, + tagsPerFile: 5, + frontmatter: true, + extensions: MARKDOWN_EXTENSIONS, + hiddenDirCount: 20, + nonMarkdownCount: 2_500, + sparseEmptyDirs: 200, + }, + "unicode-paths": { + fileCount: 420, + dirCount: 64, + maxDepth: 4, + paragraphs: 3, + linksPerFile: 4, + tagsPerFile: 3, + frontmatter: true, + extensions: [".md", ".markdown"], + unicode: true, + nonMarkdownCount: 40, + }, +}; + +const WORDS = [ + "atlas", + "beacon", + "canvas", + "delta", + "ember", + "field", + "graph", + "harbor", + "index", + "journal", + "kernel", + "ledger", + "matrix", + "node", + "orbit", + "packet", + "quartz", + "relay", + "signal", + "thread", + "vector", + "window", + "syntax", + "cursor", + "buffer", + "vault", + "outline", + "branch", + "anchor", + "filter", +]; + +const UNICODE_SEGMENTS = [ + "cafe", + "東京", + "naive-notes", + "résumé", + "mañana", + "данные", + "δοκιμή", + "jalapeño", + "中文", + "crème", + "niño", + "über", + "façade", + "서울", + "हिन्दी", +]; + +function createRng(seedText) { + let h = 2166136261; + for (let i = 0; i < seedText.length; i++) { + h ^= seedText.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return () => { + h += 0x6d2b79f5; + let t = h; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function sanitizeSegment(value) { + return value + .normalize("NFC") + .replace(/[/:]/g, "-") + .replace(/\s+/g, "-") + .toLowerCase(); +} + +function pad(number, width) { + return String(number).padStart(width, "0"); +} + +function pick(array, rng) { + return array[Math.floor(rng() * array.length)]; +} + +function makeTitle(index, rng, unicode) { + const parts = unicode + ? [pick(UNICODE_SEGMENTS, rng), pick(WORDS, rng), pad(index, 5)] + : [pick(WORDS, rng), pick(WORDS, rng), pad(index, 5)]; + return parts.join(" "); +} + +function makeSlug(index, rng, unicode, longNames = false) { + const base = makeTitle(index, rng, unicode); + const extra = longNames + ? `-${pick(WORDS, rng)}-${pick(WORDS, rng)}-${pick(WORDS, rng)}` + : ""; + return sanitizeSegment(`${base}${extra}`); +} + +function makeDirectories(profile, rng) { + if (profile.flat) return ["."]; + + if (profile.deep) { + const dirs = ["."]; + let current = "."; + for (let i = 0; i < profile.dirCount - 1; i++) { + current = path.posix.join(current, `level-${pad(i, 3)}`); + dirs.push(current); + } + return dirs; + } + + if (profile.wide) { + return [ + ".", + ...Array.from({ length: profile.dirCount - 1 }, (_, i) => { + return `section-${pad(i, 4)}`; + }), + ]; + } + + const dirs = ["."]; + for (let i = 1; i < profile.dirCount; i++) { + const depth = 1 + Math.floor(rng() * profile.maxDepth); + const segments = []; + for (let d = 0; d < depth; d++) { + const word = profile.unicode ? pick(UNICODE_SEGMENTS, rng) : pick(WORDS, rng); + segments.push(`${sanitizeSegment(word)}-${pad((i + d) % 997, 3)}`); + } + dirs.push(path.posix.join(...segments)); + } + return Array.from(new Set(dirs)); +} + +function makeMarkdown(index, profile, rng, allSlugs, contentMode) { + const roundTripSafe = contentMode === "roundtrip-safe"; + const title = makeTitle(index, rng, profile.unicode); + const tags = Array.from({ length: profile.tagsPerFile }, () => pick(WORDS, rng)); + const links = Array.from({ length: profile.linksPerFile }, () => { + const targetIndex = Math.floor(rng() * allSlugs.length); + const label = rng() > 0.65 ? `|${pick(WORDS, rng)} ${pick(WORDS, rng)}` : ""; + return `[[${allSlugs[targetIndex]}${label}]]`; + }); + + const blocks = []; + if (profile.frontmatter && !roundTripSafe) { + blocks.push( + [ + "---", + `title: "${title.replace(/"/g, '\\"')}"`, + `id: note-${pad(index, 6)}`, + `rank: ${index}`, + `tags: [${tags.map((tag) => `"${tag}"`).join(", ")}]`, + "---", + "", + ].join("\n"), + ); + } + + blocks.push(`# ${title}`); + blocks.push(""); + blocks.push( + `Links: ${links.join(", ")}. Tags: ${tags.map((tag) => `#${tag}`).join(" ")}.`, + ); + + for (let i = 0; i < profile.paragraphs; i++) { + const sentence = Array.from({ length: 18 }, () => pick(WORDS, rng)).join(" "); + blocks.push(""); + blocks.push(`${sentence}. This generated paragraph is ${pad(index, 6)}:${i}.`); + } + + if (!roundTripSafe && index % 5 === 0) { + blocks.push(""); + blocks.push("- [ ] capture follow-up"); + blocks.push("- [x] preserve deterministic content"); + } + + if (index % 9 === 0) { + blocks.push(""); + blocks.push("```ts"); + blocks.push(`export const generatedNote = "${pad(index, 6)}";`); + blocks.push("```"); + } + + if (!roundTripSafe && index % 13 === 0) { + blocks.push(""); + blocks.push("| Key | Value |"); + blocks.push("| --- | --- |"); + blocks.push(`| profile | ${profile.name} |`); + blocks.push(`| index | ${index} |`); + } + + return `${blocks.join("\n")}\n`; +} + +async function ensureDir(dir, cache) { + if (cache.has(dir)) return; + await fs.mkdir(dir, { recursive: true }); + cache.add(dir); +} + +async function runBatched(items, concurrency, worker) { + let next = 0; + const workers = Array.from( + { length: Math.min(concurrency, items.length || 1) }, + async () => { + while (next < items.length) { + const item = items[next++]; + await worker(item); + } + }, + ); + await Promise.all(workers); +} + +async function createTempRoot(profileName) { + const prefix = path.join(os.tmpdir(), `fullmark-${profileName}-`); + return fs.mkdtemp(prefix); +} + +function keepArtifacts() { + return process.env.FULLMARK_KEEP_ARTIFACTS === "1"; +} + +export async function createGeneratedVault(options = {}) { + const profileName = options.profile ?? "small"; + if (!PROFILE_NAMES.includes(profileName)) { + throw new Error( + `Unknown profile "${profileName}". Expected one of: ${PROFILE_NAMES.join(", ")}`, + ); + } + + const profile = { ...PROFILES[profileName], name: profileName }; + const contentMode = options.contentMode ?? "default"; + const seed = String(options.seed ?? `fullmark-${profileName}-v1`); + const rng = createRng(seed); + const root = + options.root ?? + (keepArtifacts() + ? path.resolve(process.cwd(), ".tmp", "generated-vaults", profileName) + : await createTempRoot(profileName)); + + await fs.rm(root, { recursive: true, force: true }); + await fs.mkdir(root, { recursive: true }); + + const dirs = makeDirectories(profile, rng); + const dirCache = new Set([root]); + await runBatched(dirs, 64, async (dir) => { + await ensureDir(path.join(root, dir), dirCache); + }); + + const slugs = Array.from({ length: profile.fileCount }, (_, i) => + makeSlug(i, createRng(`${seed}:slug:${i}`), profile.unicode, profile.longNames), + ); + + const markdownFiles = []; + let totalBytes = 0; + const writeJobs = Array.from({ length: profile.fileCount }, (_, i) => i); + await runBatched(writeJobs, 96, async (index) => { + const fileRng = createRng(`${seed}:file:${index}`); + const dir = dirs[index % dirs.length]; + const ext = profile.extensions[index % profile.extensions.length]; + const slug = slugs[index]; + const relPath = path.posix.join(dir, `${slug}${ext}`); + const absolutePath = path.join(root, relPath); + await ensureDir(path.dirname(absolutePath), dirCache); + const markdown = makeMarkdown(index, profile, fileRng, slugs, contentMode); + await fs.writeFile(absolutePath, markdown, "utf8"); + markdownFiles[index] = relPath; + totalBytes += Buffer.byteLength(markdown); + }); + + if (profile.nonMarkdownCount) { + const nonMarkdownJobs = Array.from({ length: profile.nonMarkdownCount }, (_, i) => i); + await runBatched(nonMarkdownJobs, 64, async (index) => { + const dir = dirs[(index * 17) % dirs.length]; + const ext = NON_MARKDOWN_EXTENSIONS[index % NON_MARKDOWN_EXTENSIONS.length]; + const relPath = path.posix.join(dir, `asset-${pad(index, 5)}${ext}`); + const absolutePath = path.join(root, relPath); + await ensureDir(path.dirname(absolutePath), dirCache); + await fs.writeFile(absolutePath, `generated non-markdown ${index}\n`, "utf8"); + }); + } + + if (profile.hiddenDirCount) { + const hiddenJobs = Array.from({ length: profile.hiddenDirCount }, (_, i) => i); + await runBatched(hiddenJobs, 16, async (index) => { + const hiddenName = HIDDEN_DIRS[index % HIDDEN_DIRS.length]; + const hiddenRoot = path.join(root, hiddenName, `cache-${pad(index, 3)}`); + await fs.mkdir(hiddenRoot, { recursive: true }); + await fs.writeFile( + path.join(hiddenRoot, `hidden-${pad(index, 3)}.md`), + "# Hidden generated note\n\nThis should be ignored by app tree walkers.\n", + "utf8", + ); + }); + } + + if (profile.sparseEmptyDirs) { + const emptyJobs = Array.from({ length: profile.sparseEmptyDirs }, (_, i) => i); + await runBatched(emptyJobs, 32, async (index) => { + await fs.mkdir(path.join(root, "empty", `branch-${pad(index, 4)}`), { + recursive: true, + }); + }); + } + + const manifest = { + profile: profileName, + seed, + root, + markdownFileCount: profile.fileCount, + directoryCount: dirs.length, + nonMarkdownFileCount: profile.nonMarkdownCount ?? 0, + hiddenDirectoryCount: profile.hiddenDirCount ?? 0, + totalMarkdownBytes: totalBytes, + contentMode, + markdownFiles, + }; + + await fs.writeFile( + path.join(root, "fullmark-generated-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); + + return { + root, + profile: profileName, + seed, + manifest, + cleanup: async () => { + if (!options.root && !keepArtifacts()) { + await fs.rm(root, { recursive: true, force: true }); + } + }, + }; +} + +export async function removeGeneratedVault(vault) { + if (vault?.cleanup) { + await vault.cleanup(); + return; + } + if (vault?.root && !keepArtifacts()) { + await fs.rm(vault.root, { recursive: true, force: true }); + } +} + +function parseArgs(argv) { + const args = { + profile: "small", + seed: undefined, + root: undefined, + json: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--profile") args.profile = argv[++i]; + else if (arg.startsWith("--profile=")) args.profile = arg.slice("--profile=".length); + else if (arg === "--seed") args.seed = argv[++i]; + else if (arg.startsWith("--seed=")) args.seed = arg.slice("--seed=".length); + else if (arg === "--out" || arg === "--root") args.root = path.resolve(argv[++i]); + else if (arg.startsWith("--out=")) args.root = path.resolve(arg.slice("--out=".length)); + else if (arg === "--json") args.json = true; + else if (arg === "--list") args.list = true; + else if (!arg.startsWith("-")) args.profile = arg; + } + return args; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.list) { + console.log(PROFILE_NAMES.join("\n")); + return; + } + + const vault = await createGeneratedVault(args); + const output = { + profile: vault.profile, + seed: vault.seed, + root: vault.root, + markdownFileCount: vault.manifest.markdownFileCount, + directoryCount: vault.manifest.directoryCount, + nonMarkdownFileCount: vault.manifest.nonMarkdownFileCount, + totalMarkdownBytes: vault.manifest.totalMarkdownBytes, + }; + + if (args.json) { + console.log(JSON.stringify(output, null, 2)); + } else { + console.log(`Generated ${output.profile} vault at ${output.root}`); + console.log( + `${output.markdownFileCount} markdown files, ${output.directoryCount} directories, ${output.totalMarkdownBytes} markdown bytes`, + ); + } +} + +const executedPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; +if (executedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error?.stack || error); + process.exit(1); + }); +} diff --git a/scripts/perf-bench.mjs b/scripts/perf-bench.mjs new file mode 100644 index 0000000..dd51d4f --- /dev/null +++ b/scripts/perf-bench.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import matter from "gray-matter"; +import MiniSearch from "minisearch"; +import { JSDOM } from "jsdom"; +import { + PROFILE_NAMES, + createGeneratedVault, + removeGeneratedVault, +} from "./generate-vault.mjs"; + +const MD_EXTENSIONS = new Set([".md", ".mdx", ".markdown"]); +const HIDDEN = new Set([ + ".git", + ".svn", + ".hg", + ".DS_Store", + ".editor", + ".obsidian", + "node_modules", + "dist", + "build", + "target", + ".next", + ".turbo", +]); + +let tiptapModules; + +function keepArtifacts() { + return process.env.FULLMARK_KEEP_ARTIFACTS === "1"; +} + +function defaultArtifactDir() { + if (process.env.FULLMARK_ARTIFACT_DIR) { + return path.resolve(process.env.FULLMARK_ARTIFACT_DIR); + } + if (keepArtifacts()) { + return path.resolve(process.cwd(), ".tmp", "perf-artifacts"); + } + return path.join(os.tmpdir(), "fullmark-perf-artifacts"); +} + +function parseList(value) { + if (!value || value === "all") return PROFILE_NAMES; + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseArgs(argv) { + const args = { + profiles: ["small", "medium", "chaos"], + sampleSize: 128, + artifactDir: defaultArtifactDir(), + seed: undefined, + markdownRoundTrip: true, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--profiles") args.profiles = parseList(argv[++i]); + else if (arg.startsWith("--profiles=")) { + args.profiles = parseList(arg.slice("--profiles=".length)); + } else if (arg === "--profile") args.profiles = parseList(argv[++i]); + else if (arg.startsWith("--profile=")) { + args.profiles = parseList(arg.slice("--profile=".length)); + } else if (arg === "--sample-size") { + args.sampleSize = Number(argv[++i]); + } else if (arg.startsWith("--sample-size=")) { + args.sampleSize = Number(arg.slice("--sample-size=".length)); + } else if (arg === "--out") { + args.artifactDir = path.resolve(argv[++i]); + } else if (arg.startsWith("--out=")) { + args.artifactDir = path.resolve(arg.slice("--out=".length)); + } else if (arg === "--seed") { + args.seed = argv[++i]; + } else if (arg.startsWith("--seed=")) { + args.seed = arg.slice("--seed=".length); + } else if (arg === "--no-markdown-roundtrip") { + args.markdownRoundTrip = false; + } + } + + if (!Number.isFinite(args.sampleSize) || args.sampleSize < 1) { + throw new Error("--sample-size must be a positive number"); + } + + for (const profile of args.profiles) { + if (!PROFILE_NAMES.includes(profile)) { + throw new Error(`Unknown profile "${profile}". Expected: ${PROFILE_NAMES.join(", ")}`); + } + } + + return args; +} + +function isMarkdown(name) { + const ext = path.extname(name).toLowerCase(); + return MD_EXTENSIONS.has(ext); +} + +function isHidden(name) { + return HIDDEN.has(name) || name.startsWith("."); +} + +async function discoverMarkdown(root) { + const files = []; + async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (isHidden(entry.name)) continue; + const absolutePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(absolutePath); + } else if (entry.isFile() && isMarkdown(entry.name)) { + files.push(absolutePath); + } + } + } + await walk(root); + return files; +} + +function sampleEvenly(files, size) { + if (files.length <= size) return files; + const sample = []; + const step = (files.length - 1) / (size - 1); + for (let i = 0; i < size; i++) { + sample.push(files[Math.round(i * step)]); + } + return sample; +} + +async function timed(name, fn) { + const start = performance.now(); + const value = await fn(); + const durationMs = performance.now() - start; + return { name, durationMs, value }; +} + +async function readDocuments(files, root) { + return Promise.all( + files.map(async (file, index) => { + const content = await fs.readFile(file, "utf8"); + const parsed = matter(content); + return { + id: index, + path: path.relative(root, file), + title: parsed.data.title || path.basename(file), + tags: Array.isArray(parsed.data.tags) ? parsed.data.tags : [], + text: parsed.content, + bytes: Buffer.byteLength(content), + }; + }), + ); +} + +function buildSearchIndex(documents) { + const search = new MiniSearch({ + fields: ["title", "text", "tags"], + storeFields: ["path", "title"], + searchOptions: { prefix: true, fuzzy: 0.2 }, + }); + search.addAll(documents); + return search; +} + +async function loadTiptap() { + if (tiptapModules) return tiptapModules; + + const dom = new JSDOM("", { + url: "http://localhost/", + pretendToBeVisual: true, + }); + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.Element = dom.window.Element; + globalThis.Node = dom.window.Node; + globalThis.DocumentFragment = dom.window.DocumentFragment; + globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); + globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 16); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + try { + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + writable: true, + configurable: true, + }); + } catch { + // Node may expose navigator as an accessor. TipTap works with jsdom globals above. + } + + const [{ Editor }, { StarterKit }, { Markdown }] = await Promise.all([ + import("@tiptap/core"), + import("@tiptap/starter-kit"), + import("@tiptap/markdown"), + ]); + tiptapModules = { Editor, StarterKit, Markdown }; + return tiptapModules; +} + +async function roundTripMarkdown(documents) { + const { Editor, StarterKit, Markdown } = await loadTiptap(); + let changed = 0; + let outputBytes = 0; + + for (const doc of documents) { + const element = document.createElement("div"); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: [StarterKit, Markdown], + content: "", + }); + try { + editor.commands.setContent(doc.text, { contentType: "markdown" }); + const markdown = editor.getMarkdown(); + outputBytes += Buffer.byteLength(markdown); + if (markdown.trim() !== doc.text.trim()) changed++; + } finally { + editor.destroy(); + element.remove(); + } + } + + return { changed, outputBytes }; +} + +async function runProfile(profile, args) { + const timings = []; + let vault; + const generation = await timed("generate-vault", async () => { + vault = await createGeneratedVault({ + profile, + seed: args.seed ? `${args.seed}:${profile}` : undefined, + }); + return vault.manifest; + }); + timings.push(withoutValue(generation)); + + try { + const discovery = await timed("discover-markdown", () => discoverMarkdown(vault.root)); + timings.push(withoutValue(discovery)); + const files = discovery.value; + const sampleFiles = sampleEvenly(files, args.sampleSize); + + const stat = await timed("stat-sample", async () => { + const stats = await Promise.all(sampleFiles.map((file) => fs.stat(file))); + return stats.reduce((sum, item) => sum + item.size, 0); + }); + timings.push(withoutValue(stat)); + + const readParse = await timed("read-parse-frontmatter-sample", () => + readDocuments(sampleFiles, vault.root), + ); + timings.push(withoutValue(readParse)); + const documents = readParse.value; + + const searchIndex = await timed("build-search-index-sample", () => + Promise.resolve(buildSearchIndex(documents)), + ); + timings.push(withoutValue(searchIndex)); + + const search = await timed("search-index-query", () => + Promise.resolve(searchIndex.value.search("generated deterministic vault")), + ); + timings.push(withoutValue(search)); + + let roundTrip = null; + if (args.markdownRoundTrip) { + const roundTripSample = documents.slice(0, Math.min(32, documents.length)); + const result = await timed("markdown-roundtrip-sample", () => + roundTripMarkdown(roundTripSample), + ); + timings.push(withoutValue(result)); + roundTrip = result.value; + } + + return { + profile, + root: vault.root, + seed: vault.seed, + markdownFileCount: files.length, + manifestMarkdownFileCount: vault.manifest.markdownFileCount, + sampleSize: sampleFiles.length, + sampledBytes: stat.value, + searchResultCount: search.value.length, + roundTrip, + timings, + }; + } finally { + await removeGeneratedVault(vault); + } +} + +function withoutValue(result) { + return { + name: result.name, + durationMs: Number(result.durationMs.toFixed(3)), + }; +} + +function toMarkdown(results, jsonFile) { + const lines = [ + "# FullMark Generated Performance Bench", + "", + `JSON artifact: \`${path.basename(jsonFile)}\``, + "", + "| Profile | Markdown files | Sample | Generate ms | Discover ms | Read/parse ms | Search build ms | Round-trip ms |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", + ]; + + for (const result of results) { + const byName = new Map(result.timings.map((timing) => [timing.name, timing.durationMs])); + lines.push( + [ + `| ${result.profile}`, + result.markdownFileCount, + result.sampleSize, + byName.get("generate-vault") ?? "", + byName.get("discover-markdown") ?? "", + byName.get("read-parse-frontmatter-sample") ?? "", + byName.get("build-search-index-sample") ?? "", + byName.get("markdown-roundtrip-sample") ?? "", + ].join(" | ") + " |", + ); + } + + lines.push(""); + lines.push("Generated vaults are created from deterministic profiles at runtime."); + return `${lines.join("\n")}\n`; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + await fs.mkdir(args.artifactDir, { recursive: true }); + + const startedAt = new Date().toISOString(); + const results = []; + for (const profile of args.profiles) { + console.log(`Benchmarking ${profile}...`); + results.push(await runProfile(profile, args)); + } + + const artifact = { + startedAt, + finishedAt: new Date().toISOString(), + node: process.version, + platform: `${process.platform} ${process.arch}`, + profiles: args.profiles, + sampleSize: args.sampleSize, + results, + }; + + const suffix = args.profiles.join("_").replace(/[^a-z0-9_-]+/gi, "-"); + const jsonFile = path.join(args.artifactDir, `perf-bench-${suffix}.json`); + const markdownFile = path.join(args.artifactDir, `perf-bench-${suffix}.md`); + await fs.writeFile(jsonFile, `${JSON.stringify(artifact, null, 2)}\n`, "utf8"); + await fs.writeFile(markdownFile, toMarkdown(results, jsonFile), "utf8"); + + console.log(`Wrote ${jsonFile}`); + console.log(`Wrote ${markdownFile}`); +} + +main().catch((error) => { + console.error(error?.stack || error); + process.exit(1); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 70c9a1f..a668400 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,137 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -142,6 +273,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.2" @@ -331,6 +475,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -702,6 +855,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -719,6 +899,37 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -843,6 +1054,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-opener", ] [[package]] @@ -877,6 +1089,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1242,6 +1467,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1547,6 +1778,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1749,6 +1999,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2155,12 +2411,34 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2186,6 +2464,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2209,6 +2493,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2274,6 +2564,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2319,6 +2620,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2590,6 +2905,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2866,6 +3194,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3279,6 +3617,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.1" @@ -3379,6 +3739,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -3684,9 +4057,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3736,6 +4121,17 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4770,6 +5166,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.2", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.2", + "zvariant", +] + [[package]] name = "zerofrom" version = "0.1.7" @@ -4829,3 +5286,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.2", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.2", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 07f2c0b..52bdc5a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-plugin-fs = { version = "2", features = ["watch"] } tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tauri-plugin-opener = "2.5.4" [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1512cc9..df1d67b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "core:window:allow-internal-toggle-maximize", "fs:default", "dialog:default", + "opener:default", { "identifier": "fs:scope", "allow": [ diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 4a9418d..9e0f184 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -13,7 +13,10 @@ pub struct FsError { impl FsError { fn new(code: &str, msg: impl Into.md files.
+
+ FullMark only shows .md files.
+