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) -> Self { - Self { code: code.into(), message: msg.into() } + Self { + code: code.into(), + message: msg.into(), + } } } @@ -78,8 +81,16 @@ pub struct DirEntry { pub modified_ms: Option, } -/// List immediate children of a directory. Single level only — recursive walk -/// is handled in the frontend so it can stream + show progress for large vaults. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub parent: Option, +} + +/// List immediate children of a directory. Single level only. #[tauri::command] pub fn list_dir(path: String) -> Result, FsError> { let mut entries = Vec::new(); @@ -109,6 +120,134 @@ pub fn list_dir(path: String) -> Result, FsError> { Ok(entries) } +const MD_EXTENSIONS: [&str; 3] = ["md", "mdx", "markdown"]; +const ALWAYS_HIDE: [&str; 12] = [ + ".git", + ".svn", + ".hg", + ".DS_Store", + ".editor", + ".obsidian", + "node_modules", + "dist", + "build", + "target", + ".next", + ".turbo", +]; + +fn is_hidden(name: &str) -> bool { + ALWAYS_HIDE.contains(&name) || name.starts_with('.') +} + +fn is_markdown(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| MD_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) + .unwrap_or(false) +} + +fn sorted_workspace_children( + dir: &Path, + is_root: bool, +) -> Result, FsError> { + let read_dir = match fs::read_dir(dir) { + Ok(read_dir) => read_dir, + Err(err) if is_root => return Err(err.into()), + Err(_) => return Ok(Vec::new()), + }; + + let mut dirs = Vec::new(); + let mut files = Vec::new(); + + for ent in read_dir { + let ent = match ent { + Ok(ent) => ent, + Err(_) => continue, + }; + let name = ent.file_name().to_string_lossy().into_owned(); + if is_hidden(&name) { + continue; + } + + let path = ent.path(); + let file_type = match ent.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + + if file_type.is_dir() { + dirs.push((name, path, true)); + } else if file_type.is_file() && is_markdown(&path) { + files.push((name, path, false)); + } + } + + dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + dirs.extend(files); + Ok(dirs) +} + +fn walk_workspace_dir( + dir: &Path, + parent: usize, + entries: &mut Vec, + is_root: bool, +) -> Result { + let children = sorted_workspace_children(dir, is_root)?; + let mut has_markdown = false; + + for (name, path, is_dir) in children { + if is_dir { + let idx = entries.len(); + entries.push(WorkspaceEntry { + name, + path: path.to_string_lossy().into_owned(), + is_dir: true, + parent: Some(parent), + }); + + if walk_workspace_dir(&path, idx, entries, false)? { + has_markdown = true; + } else { + entries.truncate(idx); + } + } else { + entries.push(WorkspaceEntry { + name, + path: path.to_string_lossy().into_owned(), + is_dir: false, + parent: Some(parent), + }); + has_markdown = true; + } + } + + Ok(has_markdown) +} + +/// Walk a workspace once in Rust, returning a compact flat tree containing only +/// markdown files and directories that contain markdown descendants. +#[tauri::command] +pub fn walk_workspace(root: String) -> Result, FsError> { + let root_path = PathBuf::from(&root); + let root_name = root_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(root.as_str()) + .to_string(); + + let mut entries = vec![WorkspaceEntry { + name: root_name, + path: root_path.to_string_lossy().into_owned(), + is_dir: true, + parent: None, + }]; + walk_workspace_dir(&root_path, 0, &mut entries, true)?; + Ok(entries) +} + /// Read a file as a UTF-8 string. Bytes that aren't valid UTF-8 are lossy-replaced. /// Returns the canonical path alongside the content so the frontend can /// reconcile case-insensitive filesystems. @@ -133,7 +272,11 @@ pub fn read_text_file(path: String) -> Result { .and_then(|md| md.modified().ok()) .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_millis()); - Ok(ReadResult { content, canonical_path: canonical, modified_ms }) + Ok(ReadResult { + content, + canonical_path: canonical, + modified_ms, + }) } /// Resolve `~` and relative paths to an absolute path that the rest of the app diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7383836..ab20052 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,12 +22,14 @@ pub fn run() { .manage(PendingOpenFiles::default()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ take_pending_open_files, commands::fs::atomic_write_text, commands::fs::list_dir, commands::fs::read_text_file, commands::fs::resolve_path, + commands::fs::walk_workspace, commands::launch_services::set_default_markdown_handler, commands::launch_services::get_default_markdown_handler, commands::launch_services::is_default_markdown_handler, diff --git a/src/App.tsx b/src/App.tsx index 7410128..d5a9d02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,10 +50,15 @@ async function openExternalFiles(paths: string[]): Promise { } } +function isTauriRuntime(): boolean { + return "__TAURI_INTERNALS__" in window; +} + export default function App() { const root = useWorkspaceStore((s) => s.root); const refreshTree = useWorkspaceStore((s) => s.refreshTree); const activeTab = useTabsStore(selectActiveTab); + const restoreTabSession = useTabsStore((s) => s.restoreSession); const readerMode = useUIStore((s) => s.readerMode); const toggleReaderMode = useUIStore((s) => s.toggleReaderMode); const lightThemeFamily = useUIStore((s) => s.lightThemeFamily); @@ -74,6 +79,11 @@ export default function App() { } }, [root, refreshTree]); + // Reopen the previous tab session from disk after persisted stores hydrate. + useEffect(() => { + void restoreTabSession(); + }, [restoreTabSession]); + // Theme application — settings preview can temporarily force light/dark. useEffect(() => { const mq = window.matchMedia("(prefers-color-scheme: dark)"); @@ -122,19 +132,29 @@ export default function App() { // WebView's own handler. useEffect(() => { const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "r") { + if ( + (e.metaKey || e.ctrlKey) && + !e.shiftKey && + !e.altKey && + e.key.toLowerCase() === "r" + ) { e.preventDefault(); e.stopPropagation(); toggleReaderMode(); } // Also block Cmd+Shift+R (hard reload) so it doesn't reload either. - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "r") { + if ( + (e.metaKey || e.ctrlKey) && + e.shiftKey && + e.key.toLowerCase() === "r" + ) { e.preventDefault(); e.stopPropagation(); } }; window.addEventListener("keydown", onKey, { capture: true }); - return () => window.removeEventListener("keydown", onKey, { capture: true }); + return () => + window.removeEventListener("keydown", onKey, { capture: true }); }, [toggleReaderMode]); // Cmd+K opens the quick switcher (requires a workspace; ignored otherwise) @@ -182,6 +202,7 @@ export default function App() { // the app before this listener exists; this effect drains the queue once the // frontend is ready and again whenever Rust signals new paths. useEffect(() => { + if (!isTauriRuntime()) return; let cancelled = false; const drainPendingOpenFiles = async () => { try { @@ -206,7 +227,7 @@ export default function App() { }; }, []); - if (!root) { + if (!root && !activeTab) { return (
diff --git a/src/components/CommandPalette/QuickSwitcher.tsx b/src/components/CommandPalette/QuickSwitcher.tsx index 6515259..42aaa4d 100644 --- a/src/components/CommandPalette/QuickSwitcher.tsx +++ b/src/components/CommandPalette/QuickSwitcher.tsx @@ -11,42 +11,19 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useWorkspaceStore } from "@/stores/workspace"; import { useTabsStore } from "@/stores/tabs"; -import type { TreeNode } from "@/services/fs-bridge"; +import type { WorkspaceFile } from "@/stores/workspace"; interface Props { open: boolean; onClose: () => void; } -function flattenFiles(node: TreeNode | null, out: TreeNode[] = []): TreeNode[] { - if (!node) return out; - if (!node.isDir) out.push(node); - if (node.children) for (const c of node.children) flattenFiles(c, out); - return out; -} - -function stripExt(name: string): string { - const idx = name.lastIndexOf("."); - if (idx <= 0) return name; - const ext = name.slice(idx).toLowerCase(); - if (ext === ".md" || ext === ".mdx" || ext === ".markdown") - return name.slice(0, idx); - return name; -} - -function relativePath(fullPath: string, workspaceRoot: string | null): string { - if (workspaceRoot && fullPath.startsWith(workspaceRoot + "/")) { - return fullPath.slice(workspaceRoot.length + 1); - } - return fullPath; -} - /** * Subsequence-style fuzzy match with positional scoring. * Returns null if not all query chars are present in order; otherwise a score * (higher is better) reflecting how tightly the query matched. */ -function fuzzyScore(query: string, target: string): number | null { +export function fuzzyScore(query: string, target: string): number | null { if (!query) return 0; const q = query.toLowerCase(); const t = target.toLowerCase(); @@ -69,39 +46,44 @@ function fuzzyScore(query: string, target: string): number | null { return score - t.length * 0.05; } -type Ranked = { file: TreeNode; score: number }; +type Ranked = { file: WorkspaceFile; score: number }; + +export function rankWorkspaceFiles( + files: WorkspaceFile[], + query: string, + limit = 20, +): Ranked[] { + const q = query.trim(); + if (!q) { + return files.slice(0, limit).map((file) => ({ file, score: 0 })); + } + + const ranked: Ranked[] = []; + for (const file of files) { + const nameScore = fuzzyScore(q, file.searchName); + const pathScore = fuzzyScore(q, file.searchPath); + if (nameScore === null && pathScore === null) continue; + const score = Math.max( + nameScore ?? -Infinity, + (pathScore ?? -Infinity) * 0.6, + ); + ranked.push({ file, score }); + } + ranked.sort((a, b) => b.score - a.score); + return ranked.slice(0, limit); +} export function QuickSwitcher({ open, onClose }: Props) { - const tree = useWorkspaceStore((s) => s.tree); - const root = useWorkspaceStore((s) => s.root); + const files = useWorkspaceStore((s) => s.files); const openFile = useTabsStore((s) => s.openFile); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); - const files = useMemo(() => flattenFiles(tree), [tree]); - const filtered = useMemo(() => { - if (!query.trim()) { - return files.slice(0, 20).map((f) => ({ file: f, score: 0 })); - } - const q = query.trim(); - const ranked: Ranked[] = []; - for (const f of files) { - const rel = relativePath(f.path, root); - const nameScore = fuzzyScore(q, stripExt(f.name)); - const pathScore = fuzzyScore(q, rel); - if (nameScore === null && pathScore === null) continue; - const score = Math.max( - nameScore ?? -Infinity, - (pathScore ?? -Infinity) * 0.6, - ); - ranked.push({ file: f, score }); - } - ranked.sort((a, b) => b.score - a.score); - return ranked.slice(0, 20); - }, [files, query, root]); + return rankWorkspaceFiles(files, query); + }, [files, query]); // Reset selection / query / scroll when opening or filter changes useEffect(() => { @@ -170,10 +152,6 @@ export function QuickSwitcher({ open, onClose }: Props) {
No files match "{query}"
)} {filtered.map(({ file }, idx) => { - const rel = relativePath(file.path, root); - const folder = rel.includes("/") - ? rel.slice(0, rel.lastIndexOf("/")) - : ""; return ( ); })}
- ↑↓ navigate - open - esc close + + ↑↓ navigate + + + open + + + esc close +
diff --git a/src/components/Editor/BlockEditor.tsx b/src/components/Editor/BlockEditor.tsx index 190998f..2d7fc09 100644 --- a/src/components/Editor/BlockEditor.tsx +++ b/src/components/Editor/BlockEditor.tsx @@ -1,62 +1,102 @@ -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; -import { StarterKit } from "@tiptap/starter-kit"; -import { Markdown } from "@tiptap/markdown"; -import { Placeholder } from "@tiptap/extensions"; -import { Typography } from "@tiptap/extension-typography"; -import { TaskList } from "@tiptap/extension-task-list"; -import { TaskItem } from "@tiptap/extension-task-item"; -import { Wikilink } from "@/services/editor/extensions/wikilink"; -import { CodeHighlight } from "@/services/editor/extensions/code-highlight"; -import { SlashMenuExtension } from "@/services/editor/extensions/slash-menu"; +import { createEditorExtensions } from "@/services/editor/extensions"; +import { isOpenableExternalUrl } from "@/services/external-links"; + +export type EditorMarkdownSnapshot = { + markdown: string; + revision: number; +}; export interface BlockEditorProps { /** Initial markdown content. Only read on mount — pass a `key` to remount on file switch. */ initialMarkdown: string; - onChange?: (markdown: string) => void; + initialRevision: number; + onEdit?: (revision: number) => void; + onSnapshot?: (snapshot: EditorMarkdownSnapshot) => void; + onOpenLink?: (target: string) => void; + onOpenUrl?: (url: string) => void; + registerSnapshotProvider?: ( + provider: () => EditorMarkdownSnapshot, + ) => () => void; placeholder?: string; editable?: boolean; } export function BlockEditor({ initialMarkdown, - onChange, + initialRevision, + onEdit, + onSnapshot, + onOpenLink, + onOpenUrl, + registerSnapshotProvider, placeholder = "Type / for commands…", editable = true, }: BlockEditorProps) { + const revisionRef = useRef(initialRevision); + const extensions = useMemo( + () => createEditorExtensions({ placeholder }), + [placeholder], + ); + + useEffect(() => { + revisionRef.current = Math.max(revisionRef.current, initialRevision); + }, [initialRevision]); + const editor = useEditor({ - extensions: [ - // Disable starter-kit's plain code block so we can supply the syntax-highlighted variant. - StarterKit.configure({ - codeBlock: false, - }), - CodeHighlight, - Typography, - TaskList, - TaskItem.configure({ nested: true }), - Markdown.configure({ - indentation: { style: "space", size: 2 }, - }), - Placeholder.configure({ - placeholder, - emptyEditorClass: "is-editor-empty", - }), - Wikilink, - SlashMenuExtension, - ], + extensions, content: initialMarkdown, contentType: "markdown", editorProps: { attributes: { class: "tiptap" }, + handleDOMEvents: { + click: (_view, event) => { + if (!event.metaKey && !event.ctrlKey) return false; + const target = event.target; + if (!(target instanceof Element)) return false; + + const wikilink = target.closest("[data-wikilink]"); + if (wikilink) { + const linkTarget = wikilink.dataset.wikilink; + if (!linkTarget) return false; + + event.preventDefault(); + onOpenLink?.(linkTarget); + return true; + } + + const anchor = target.closest("a[href]"); + const href = anchor?.href; + if (!href || !isOpenableExternalUrl(href)) return false; + + event.preventDefault(); + onOpenUrl?.(href); + return true; + }, + }, }, - onUpdate: ({ editor }) => { - if (!onChange) return; - onChange(editor.getMarkdown()); + onUpdate: () => { + revisionRef.current += 1; + onEdit?.(revisionRef.current); }, editable, immediatelyRender: false, }); + useLayoutEffect(() => { + if (!editor) return; + const getSnapshot = () => ({ + markdown: editor.getMarkdown(), + revision: revisionRef.current, + }); + const unregister = registerSnapshotProvider?.(getSnapshot); + return () => { + onSnapshot?.(getSnapshot()); + unregister?.(); + }; + }, [editor, onSnapshot, registerSnapshotProvider]); + // Sync editable prop into the live editor (so reader-mode toggle takes effect // without remounting the editor). useEffect(() => { diff --git a/src/components/Editor/EditorPane.tsx b/src/components/Editor/EditorPane.tsx index 276c675..c0bd425 100644 --- a/src/components/Editor/EditorPane.tsx +++ b/src/components/Editor/EditorPane.tsx @@ -8,22 +8,83 @@ * - reader mode forces rendered + locks editing */ -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { BlockEditor } from "./BlockEditor"; import { SourceEditor } from "./SourceEditor"; import { useTabsStore, selectActiveTab } from "@/stores/tabs"; import { useUIStore } from "@/stores/ui"; +import { useWorkspaceStore } from "@/stores/workspace"; +import { resolveWorkspaceLinkTarget } from "@/services/link-targets"; const AUTO_SAVE_DEBOUNCE_MS = 1500; export function EditorPane() { const activeTab = useTabsStore(selectActiveTab); const updateContent = useTabsStore((s) => s.updateContent); + const markEditorChanged = useTabsStore((s) => s.markEditorChanged); + const syncEditorSnapshot = useTabsStore((s) => s.syncEditorSnapshot); + const registerSnapshotProvider = useTabsStore( + (s) => s.registerSnapshotProvider, + ); const saveActive = useTabsStore((s) => s.saveActive); + const openFile = useTabsStore((s) => s.openFile); const readerMode = useUIStore((s) => s.readerMode); const viewMode = useUIStore((s) => s.viewMode); + const workspaceFiles = useWorkspaceStore((s) => s.files); const effectiveMode = readerMode ? "rendered" : viewMode; + const activePath = activeTab?.path; + + const handleRenderedEdit = useCallback( + (revision: number) => { + if (activePath) markEditorChanged(activePath, revision); + }, + [activePath, markEditorChanged], + ); + + const handleRenderedSnapshot = useCallback( + (snapshot: { markdown: string; revision: number }) => { + if (activePath) { + syncEditorSnapshot(activePath, { + content: snapshot.markdown, + revision: snapshot.revision, + }); + } + }, + [activePath, syncEditorSnapshot], + ); + + const handleRegisterSnapshotProvider = useCallback( + (provider: () => { markdown: string; revision: number }) => { + if (!activePath) return () => {}; + return registerSnapshotProvider(activePath, () => { + const snapshot = provider(); + return { + content: snapshot.markdown, + revision: snapshot.revision, + }; + }); + }, + [activePath, registerSnapshotProvider], + ); + + const handleOpenLink = useCallback( + (target: string) => { + const file = resolveWorkspaceLinkTarget(workspaceFiles, target); + if (!file) return; + void openFile(file.path).catch((e: unknown) => { + console.error(`Failed to open link target ${target}:`, e); + }); + }, + [openFile, workspaceFiles], + ); + + const handleOpenUrl = useCallback((url: string) => { + void openUrl(url).catch((e: unknown) => { + console.error(`Failed to open URL ${url}:`, e); + }); + }, []); // Cmd+S to save (suppressed in reader mode — there's nothing to save) useEffect(() => { @@ -46,7 +107,7 @@ export function EditorPane() { void saveActive(); }, AUTO_SAVE_DEBOUNCE_MS); return () => clearTimeout(id); - }, [activeTab?.content, activeTab?.dirty, saveActive, readerMode]); + }, [activeTab?.revision, activeTab?.dirty, saveActive, readerMode]); if (!activeTab) { return ( @@ -69,8 +130,13 @@ export function EditorPane() {
updateContent(activeTab.path, md)} + initialMarkdown={activeTab.content} + initialRevision={activeTab.revision} + onEdit={handleRenderedEdit} + onSnapshot={handleRenderedSnapshot} + onOpenLink={handleOpenLink} + onOpenUrl={handleOpenUrl} + registerSnapshotProvider={handleRegisterSnapshotProvider} editable={!readerMode} />
diff --git a/src/components/Sidebar/FileTree.tsx b/src/components/Sidebar/FileTree.tsx index 39aac53..0668b28 100644 --- a/src/components/Sidebar/FileTree.tsx +++ b/src/components/Sidebar/FileTree.tsx @@ -6,17 +6,31 @@ * - Empty-folder pruning + dotfile/build-dir hiding happens in fs-bridge.walkWorkspace. */ -import { useState, useCallback } from "react"; +import { + useState, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; import type { TreeNode } from "@/services/fs-bridge"; import { basename, isPathInsideRoot, stripMarkdownExt } from "@/services/path-utils"; import { useTabsStore, type Tab } from "@/stores/tabs"; import { useWorkspaceStore } from "@/stores/workspace"; +const ROW_HEIGHT = 26; +const OVERSCAN_ROWS = 8; + interface RowProps { node: TreeNode; depth: number; } +type VisibleRow = RowProps & { + kind: "dir" | "file"; +}; + function FileRow({ node, depth }: RowProps) { const openFile = useTabsStore((s) => s.openFile); const activePath = useTabsStore((s) => s.activePath); @@ -44,31 +58,23 @@ function FileRow({ node, depth }: RowProps) { ); } -function DirRow({ node, depth }: RowProps) { - const [open, setOpen] = useState(depth < 1); // top-level open by default +interface DirRowProps extends RowProps { + open: boolean; + onToggle: (path: string) => void; +} + +function DirRow({ node, depth, open, onToggle }: DirRowProps) { return ( -
-
setOpen((v) => !v)} - className="tree-row tree-row-dir" - style={{ paddingLeft: depth * 14 + 4 }} - > - {open ? "▾" : "▸"} - {node.name} -
- {open && node.children && ( -
- {node.children.map((child) => - child.isDir ? ( - - ) : ( - - ), - )} -
- )} +
onToggle(node.path)} + className="tree-row tree-row-dir" + style={{ paddingLeft: depth * 14 + 4 }} + title={node.path} + > + {open ? "▾" : "▸"} + {node.name}
); } @@ -108,6 +114,32 @@ function LooseFileRow({ tab }: LooseFileRowProps) { ); } +function collectDefaultOpenDirs(tree: TreeNode | null): Set { + const openDirs = new Set(); + for (const child of tree?.children ?? []) { + if (child.isDir) openDirs.add(child.path); + } + return openDirs; +} + +function collectVisibleRows( + nodes: TreeNode[] | undefined, + openDirs: Set, + depth: number, + rows: VisibleRow[], +) { + for (const node of nodes ?? []) { + if (node.isDir) { + rows.push({ node, depth, kind: "dir" }); + if (openDirs.has(node.path)) { + collectVisibleRows(node.children, openDirs, depth + 1, rows); + } + } else { + rows.push({ node, depth, kind: "file" }); + } + } +} + export function FileTree() { const tree = useWorkspaceStore((s) => s.tree); const root = useWorkspaceStore((s) => s.root); @@ -115,6 +147,48 @@ export function FileTree() { const tabs = useTabsStore((s) => s.tabs); const looseTabs = tabs.filter((tab) => !isPathInsideRoot(tab.path, root)); const hasWorkspaceFiles = Boolean(tree?.children?.length); + const [openDirs, setOpenDirs] = useState>(() => + collectDefaultOpenDirs(tree), + ); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const treeRef = useRef(null); + + useEffect(() => { + setOpenDirs(collectDefaultOpenDirs(tree)); + setScrollTop(0); + treeRef.current?.scrollTo({ top: 0 }); + }, [tree?.path]); + + useLayoutEffect(() => { + const el = treeRef.current; + if (!el) return; + + const updateHeight = () => setViewportHeight(el.clientHeight); + updateHeight(); + + const resizeObserver = new ResizeObserver(updateHeight); + resizeObserver.observe(el); + return () => resizeObserver.disconnect(); + }, []); + + const rows = useMemo(() => { + const nextRows: VisibleRow[] = []; + collectVisibleRows(tree?.children, openDirs, 0, nextRows); + return nextRows; + }, [openDirs, tree]); + + const toggleDir = useCallback((path: string) => { + setOpenDirs((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }, []); if (loading && !tree) { return
Loading…
; @@ -127,21 +201,57 @@ export function FileTree() {
No markdown files in this folder.
- FullMark only shows .md files. + + FullMark only shows .md files. +
); } + const viewportRows = viewportHeight + ? Math.ceil(viewportHeight / ROW_HEIGHT) + : 40; + const start = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN_ROWS); + const end = Math.min(rows.length, start + viewportRows + OVERSCAN_ROWS * 2); + const visibleRows = rows.slice(start, end); + return ( -
+
setScrollTop(e.currentTarget.scrollTop)} + style={{ height: "100%", overflowY: "auto", position: "relative" }} + > {hasWorkspaceFiles ? ( - tree.children?.map((child) => - child.isDir ? ( - - ) : ( - - ), - ) +
+
+ {visibleRows.map((row) => + row.kind === "dir" ? ( + + ) : ( + + ), + )} +
+
) : (
No markdown files in this folder. diff --git a/src/services/editor/extensions.ts b/src/services/editor/extensions.ts new file mode 100644 index 0000000..95c0287 --- /dev/null +++ b/src/services/editor/extensions.ts @@ -0,0 +1,38 @@ +import type { Extensions } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { Markdown } from "@tiptap/markdown"; +import { Placeholder } from "@tiptap/extensions"; +import { Typography } from "@tiptap/extension-typography"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TaskItem } from "@tiptap/extension-task-item"; +import { Wikilink } from "@/services/editor/extensions/wikilink"; +import { CodeHighlight } from "@/services/editor/extensions/code-highlight"; +import { SlashMenuExtension } from "@/services/editor/extensions/slash-menu"; + +type EditorExtensionsOptions = { + placeholder: string; +}; + +export function createEditorExtensions({ + placeholder, +}: EditorExtensionsOptions): Extensions { + return [ + // Disable starter-kit's plain code block so we can supply the syntax-highlighted variant. + StarterKit.configure({ + codeBlock: false, + }), + CodeHighlight, + Typography, + TaskList, + TaskItem.configure({ nested: true }), + Markdown.configure({ + indentation: { style: "space", size: 2 }, + }), + Placeholder.configure({ + placeholder, + emptyEditorClass: "is-editor-empty", + }), + Wikilink, + SlashMenuExtension, + ]; +} diff --git a/src/services/external-links.ts b/src/services/external-links.ts new file mode 100644 index 0000000..2d14c5c --- /dev/null +++ b/src/services/external-links.ts @@ -0,0 +1,10 @@ +const OPENABLE_URL_SCHEMES = new Set(["http:", "https:", "mailto:", "tel:"]); + +export function isOpenableExternalUrl(value: string): boolean { + try { + const url = new URL(value); + return OPENABLE_URL_SCHEMES.has(url.protocol); + } catch { + return false; + } +} diff --git a/src/services/fs-bridge.ts b/src/services/fs-bridge.ts index 4e00872..2032262 100644 --- a/src/services/fs-bridge.ts +++ b/src/services/fs-bridge.ts @@ -3,8 +3,8 @@ * * Responsibilities: * - typed wrappers around our custom Rust commands (atomic_write_text, - * list_dir, read_text_file, resolve_path) - * - markdown filtering + recursive walk on the JS side + * list_dir, read_text_file, resolve_path, walk_workspace) + * - markdown-filtered workspace tree conversion * - write-token suppression: every write tags its target path with an * expiring token; watcher events with a non-expired matching token are * dropped so we don't self-trigger a reload after our own save @@ -29,22 +29,6 @@ export type ReadResult = { modifiedMs?: number; }; -const MD_EXTENSIONS = new Set([".md", ".mdx", ".markdown"]); -const ALWAYS_HIDE = new Set([ - ".git", - ".svn", - ".hg", - ".DS_Store", - ".editor", - ".obsidian", - "node_modules", - "dist", - "build", - "target", - ".next", - ".turbo", -]); - // -- Write-token suppression ------------------------------------------------- const pendingWrites = new Map(); @@ -112,7 +96,10 @@ export async function atomicWrite( ): Promise { return queueWrite(path, async () => { const token = makeToken(); - pendingWrites.set(path, { token, expiresAt: Date.now() + SUPPRESS_WINDOW_MS }); + pendingWrites.set(path, { + token, + expiresAt: Date.now() + SUPPRESS_WINDOW_MS, + }); try { return await invoke("atomic_write_text", { path, content }); } catch (e) { @@ -127,7 +114,7 @@ export async function listDir(path: string): Promise { return invoke("list_dir", { path }); } -// -- Recursive walk + .md filter -------------------------------------------- +// -- Workspace walk + .md filter -------------------------------------------- export type TreeNode = { name: string; @@ -138,17 +125,56 @@ export type TreeNode = { hasMarkdown?: boolean; }; -function isMarkdown(name: string): boolean { - const idx = name.lastIndexOf("."); - if (idx < 0) return false; - return MD_EXTENSIONS.has(name.slice(idx).toLowerCase()); +export type WorkspaceEntry = { + name: string; + path: string; + isDir: boolean; + parent: number | null; +}; + +function fallbackRoot(root: string): TreeNode { + return { + name: root.split("/").pop() || root, + path: root, + isDir: true, + children: [], + hasMarkdown: false, + }; } -function isHidden(name: string): boolean { - if (ALWAYS_HIDE.has(name)) return true; - // Other dot-files: hide except for our own future config - if (name.startsWith(".")) return true; - return false; +export function buildTreeFromWorkspaceEntries( + entries: WorkspaceEntry[], + root: string, +): TreeNode { + if (entries.length === 0) return fallbackRoot(root); + + const nodes = entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDir: entry.isDir, + children: entry.isDir ? [] : undefined, + })); + + for (let idx = 1; idx < entries.length; idx++) { + const parentIdx = entries[idx].parent ?? 0; + const parent = nodes[parentIdx]; + if (!parent?.isDir) continue; + parent.children ??= []; + parent.children.push(nodes[idx]); + } + + function markMarkdown(node: TreeNode): boolean { + if (!node.isDir) { + node.hasMarkdown = true; + return true; + } + const hasMarkdown = (node.children ?? []).some(markMarkdown); + node.hasMarkdown = hasMarkdown; + return hasMarkdown; + } + + markMarkdown(nodes[0]); + return nodes[0]; } /** @@ -160,23 +186,6 @@ function isHidden(name: string): boolean { * hatch by design. FullMark is a markdown editor; anything else is noise. */ export async function walkWorkspace(root: string): Promise { - async function walk(dir: string, name: string): Promise { - const entries = await listDir(dir).catch(() => [] as DirEntry[]); - const children: TreeNode[] = []; - for (const e of entries) { - if (isHidden(e.name)) continue; - if (e.isDir) { - const sub = await walk(e.path, e.name); - if (sub.hasMarkdown) children.push(sub); - } else if (isMarkdown(e.name)) { - children.push({ name: e.name, path: e.path, isDir: false }); - } - } - const hasMd = children.some( - (c) => (!c.isDir && isMarkdown(c.name)) || (c.isDir && c.hasMarkdown), - ); - return { name, path: dir, isDir: true, children, hasMarkdown: hasMd }; - } - - return walk(root, root.split("/").pop() || root); + const entries = await invoke("walk_workspace", { root }); + return buildTreeFromWorkspaceEntries(entries, root); } diff --git a/src/services/link-targets.ts b/src/services/link-targets.ts new file mode 100644 index 0000000..f37d4d9 --- /dev/null +++ b/src/services/link-targets.ts @@ -0,0 +1,40 @@ +import type { WorkspaceFile } from "@/stores/workspace"; +import { stripMarkdownExt } from "@/services/path-utils"; + +function normalizePath(value: string): string { + return value.replace(/\\/g, "/").replace(/^\/+/, "").toLowerCase(); +} + +function stripFragment(value: string): string { + const hashIdx = value.indexOf("#"); + return (hashIdx >= 0 ? value.slice(0, hashIdx) : value).trim(); +} + +function withoutMarkdownExt(value: string): string { + const parts = value.split("/"); + const last = parts.pop(); + if (!last) return value; + parts.push(stripMarkdownExt(last)); + return parts.join("/"); +} + +export function resolveWorkspaceLinkTarget( + files: WorkspaceFile[], + rawTarget: string, +): WorkspaceFile | null { + const target = stripFragment(rawTarget); + if (!target) return null; + + const normalized = normalizePath(target); + const normalizedNoExt = normalizePath(withoutMarkdownExt(target)); + + return ( + files.find((file) => normalizePath(file.path) === normalized) ?? + files.find((file) => normalizePath(file.relativePath) === normalized) ?? + files.find( + (file) => normalizePath(withoutMarkdownExt(file.relativePath)) === normalizedNoExt, + ) ?? + files.find((file) => file.searchName === normalizedNoExt) ?? + null + ); +} diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index fb36875..0dde38f 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -7,6 +7,7 @@ */ import { create } from "zustand"; +import { persist } from "zustand/middleware"; import { readTextFile, atomicWrite } from "@/services/fs-bridge"; export type Tab = { @@ -18,128 +19,346 @@ export type Tab = { baseContent: string; /** Current markdown in the editor (may have unsaved changes). */ content: string; - /** baseContent !== content */ + /** Current local revision. Increments on every edit. */ + revision: number; + /** Revision represented by `content`; rendered editor edits can be newer. */ + contentRevision: number; + /** Revision known to match `baseContent` on disk. */ + savedRevision: number; + /** True when current editor state does not match the saved revision. */ dirty: boolean; /** mtime when last read/written, for external-change detection. */ lastSyncedMs?: number; }; +export type EditorContentSnapshot = { + content: string; + revision: number; +}; + +type SnapshotProvider = () => EditorContentSnapshot; + type TabsState = { tabs: Tab[]; activePath: string | null; + restoredPaths: string[]; + restoringTabs: boolean; + hasRestoredSession: boolean; openFile: (path: string) => Promise; + restoreSession: () => Promise; closeTab: (path: string) => void; switchTo: (path: string) => void; updateContent: (path: string, content: string) => void; + markEditorChanged: (path: string, revision: number) => void; + syncEditorSnapshot: (path: string, snapshot: EditorContentSnapshot) => void; + registerSnapshotProvider: ( + path: string, + provider: SnapshotProvider, + ) => () => void; save: (path: string) => Promise; saveActive: () => Promise; isDirty: (path: string) => boolean; }; +const snapshotProviders = new Map(); + +type PersistedTabsState = { + tabs?: Array<{ path?: unknown }>; + restoredPaths?: unknown; + activePath?: unknown; +}; + function basename(p: string): string { const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); return idx < 0 ? p : p.slice(idx + 1); } -export const useTabsStore = create((set, get) => ({ - tabs: [], - activePath: null, - - openFile: async (path) => { - // Single-tab-per-path: focus existing if already open. - const existing = get().tabs.find((t) => t.path === path); - if (existing) { - set({ activePath: existing.path }); - return existing.path; - } - - const result = await readTextFile(path); - const canonical = result.canonicalPath; - - // Check canonical path too — case-insensitive filesystems may have aliased it. - const existingByCanon = get().tabs.find((t) => t.path === canonical); - if (existingByCanon) { - set({ activePath: existingByCanon.path }); - return existingByCanon.path; - } - - const tab: Tab = { - path: canonical, - name: basename(canonical), - baseContent: result.content, - content: result.content, - dirty: false, - lastSyncedMs: result.modifiedMs, - }; - set((s) => ({ - tabs: [...s.tabs, tab], - activePath: canonical, - })); - return canonical; - }, - - closeTab: (path) => { - set((s) => { - const idx = s.tabs.findIndex((t) => t.path === path); - if (idx < 0) return s; - const tabs = s.tabs.filter((t) => t.path !== path); - let activePath = s.activePath; - if (activePath === path) { - const next = tabs[idx] ?? tabs[idx - 1] ?? null; - activePath = next?.path ?? null; - } - return { tabs, activePath }; - }); - }, - - switchTo: (path) => { - if (get().tabs.some((t) => t.path === path)) { - set({ activePath: path }); - } - }, - - updateContent: (path, content) => { - set((s) => ({ - tabs: s.tabs.map((t) => - t.path === path - ? { ...t, content, dirty: content !== t.baseContent } - : t, - ), - })); - }, - - save: async (path) => { - const tab = get().tabs.find((t) => t.path === path); - if (!tab) return; - // Save-on-dirty only: skip writes when nothing changed. - // This is the mitigation for first-save normalization (see fidelity-gate-results.md). - if (!tab.dirty) return; - - await atomicWrite(tab.path, tab.content); - set((s) => ({ - tabs: s.tabs.map((t) => - t.path === path - ? { +function uniqueStringPaths(paths: unknown): string[] { + if (!Array.isArray(paths)) return []; + const seen = new Set(); + const result: string[] = []; + for (const path of paths) { + if (typeof path !== "string" || !path || seen.has(path)) continue; + seen.add(path); + result.push(path); + } + return result; +} + +function tabPaths(tabs: Tab[]): string[] { + return tabs.map((tab) => tab.path); +} + +function extractPersistedPaths(state: PersistedTabsState): string[] { + const restoredPaths = uniqueStringPaths(state.restoredPaths); + if (restoredPaths.length > 0) return restoredPaths; + return uniqueStringPaths(state.tabs?.map((tab) => tab.path)); +} + +export const useTabsStore = create()( + persist( + (set, get) => ({ + tabs: [], + activePath: null, + restoredPaths: [], + restoringTabs: false, + hasRestoredSession: false, + + openFile: async (path) => { + // Single-tab-per-path: focus existing if already open. + const existing = get().tabs.find((t) => t.path === path); + if (existing) { + set({ activePath: existing.path }); + return existing.path; + } + + const result = await readTextFile(path); + const canonical = result.canonicalPath; + + // Check canonical path too — case-insensitive filesystems may have aliased it. + const existingByCanon = get().tabs.find((t) => t.path === canonical); + if (existingByCanon) { + set({ activePath: existingByCanon.path }); + return existingByCanon.path; + } + + const tab: Tab = { + path: canonical, + name: basename(canonical), + baseContent: result.content, + content: result.content, + revision: 0, + contentRevision: 0, + savedRevision: 0, + dirty: false, + lastSyncedMs: result.modifiedMs, + }; + set((s) => ({ + tabs: [...s.tabs, tab], + activePath: canonical, + })); + return canonical; + }, + + restoreSession: async () => { + const state = get(); + if (state.hasRestoredSession || state.restoringTabs) return; + + const paths = uniqueStringPaths(state.restoredPaths); + set({ restoringTabs: true, hasRestoredSession: true }); + + if (paths.length === 0) { + set({ restoringTabs: false, activePath: null }); + return; + } + + const desiredActivePath = state.activePath; + let restoredActivePath: string | null = null; + + for (const path of paths) { + try { + const canonical = await get().openFile(path); + if (path === desiredActivePath || canonical === desiredActivePath) { + restoredActivePath = canonical; + } + } catch (e) { + console.error(`Failed to restore tab ${path}:`, e); + } + } + + set((current) => { + const activePath = + restoredActivePath ?? + (current.tabs.some((tab) => tab.path === desiredActivePath) + ? desiredActivePath + : current.activePath); + return { + activePath, + restoringTabs: false, + restoredPaths: tabPaths(current.tabs), + }; + }); + }, + + closeTab: (path) => { + set((s) => { + const idx = s.tabs.findIndex((t) => t.path === path); + if (idx < 0) return s; + const tabs = s.tabs.filter((t) => t.path !== path); + let activePath = s.activePath; + if (activePath === path) { + const next = tabs[idx] ?? tabs[idx - 1] ?? null; + activePath = next?.path ?? null; + } + return { tabs, activePath }; + }); + }, + + switchTo: (path) => { + if (get().tabs.some((t) => t.path === path)) { + set({ activePath: path }); + } + }, + + updateContent: (path, content) => { + set((s) => ({ + tabs: s.tabs.map((t) => { + if (t.path !== path) return t; + if (content === t.content && t.contentRevision === t.revision) { + return t; + } + + const revision = t.revision + 1; + const dirty = content !== t.baseContent; + return { + ...t, + content, + revision, + contentRevision: revision, + savedRevision: dirty ? t.savedRevision : revision, + dirty, + }; + }), + })); + }, + + markEditorChanged: (path, revision) => { + set((s) => ({ + tabs: s.tabs.map((t) => + t.path === path + ? { + ...t, + revision: Math.max(t.revision, revision), + dirty: true, + } + : t, + ), + })); + }, + + syncEditorSnapshot: (path, snapshot) => { + set((s) => ({ + tabs: s.tabs.map((t) => { + if (t.path !== path) return t; + if (snapshot.revision < t.contentRevision) return t; + + const revision = Math.max(t.revision, snapshot.revision); + const dirty = snapshot.content !== t.baseContent; + return { ...t, - baseContent: t.content, - dirty: false, + content: snapshot.content, + revision, + contentRevision: snapshot.revision, + savedRevision: dirty ? t.savedRevision : revision, + dirty: dirty || revision !== snapshot.revision, + }; + }), + })); + }, + + registerSnapshotProvider: (path, provider) => { + snapshotProviders.set(path, provider); + return () => { + if (snapshotProviders.get(path) === provider) { + snapshotProviders.delete(path); + } + }; + }, + + save: async (path) => { + const tab = get().tabs.find((t) => t.path === path); + if (!tab) return; + const provider = snapshotProviders.get(path); + const snapshot = provider?.() ?? { + content: tab.content, + revision: tab.revision, + }; + + if (snapshot.content === tab.baseContent) { + set((s) => ({ + tabs: s.tabs.map((t) => { + if (t.path !== path) return t; + if (t.revision > snapshot.revision) return t; + return { + ...t, + content: snapshot.content, + revision: snapshot.revision, + contentRevision: snapshot.revision, + savedRevision: snapshot.revision, + dirty: false, + }; + }), + })); + return; + } + + // Save-on-dirty only: skip writes when nothing changed. + // This is the mitigation for first-save normalization (see fidelity-gate-results.md). + if (!tab.dirty && snapshot.revision === tab.savedRevision) return; + + await atomicWrite(tab.path, snapshot.content); + set((s) => ({ + tabs: s.tabs.map((t) => { + if (t.path !== path) return t; + + const hasNewerEdits = t.revision > snapshot.revision; + const knownContentMatchesDisk = + hasNewerEdits && + t.contentRevision === t.revision && + t.content === snapshot.content; + + return { + ...t, + baseContent: snapshot.content, + content: hasNewerEdits ? t.content : snapshot.content, + contentRevision: hasNewerEdits + ? t.contentRevision + : snapshot.revision, + savedRevision: knownContentMatchesDisk + ? t.revision + : snapshot.revision, + dirty: hasNewerEdits && !knownContentMatchesDisk, lastSyncedMs: Date.now(), - } - : t, - ), - })); - }, - - saveActive: async () => { - const active = get().activePath; - if (active) await get().save(active); - }, - - isDirty: (path) => { - return get().tabs.find((t) => t.path === path)?.dirty ?? false; - }, -})); + }; + }), + })); + }, + + saveActive: async () => { + const active = get().activePath; + if (active) await get().save(active); + }, + + isDirty: (path) => { + return get().tabs.find((t) => t.path === path)?.dirty ?? false; + }, + }), + { + name: "fullmark.tabs", + version: 1, + merge: (persisted, current) => { + const persistedState = + typeof persisted === "object" && persisted !== null + ? (persisted as PersistedTabsState) + : {}; + const activePath = + typeof persistedState.activePath === "string" + ? persistedState.activePath + : null; + return { + ...current, + activePath, + restoredPaths: extractPersistedPaths(persistedState), + }; + }, + partialize: (state) => ({ + restoredPaths: tabPaths(state.tabs), + activePath: state.activePath, + }), + }, + ), +); // Selector helpers export const selectActiveTab = (s: TabsState): Tab | null => diff --git a/src/stores/workspace.ts b/src/stores/workspace.ts index 2efdac8..412346d 100644 --- a/src/stores/workspace.ts +++ b/src/stores/workspace.ts @@ -11,13 +11,27 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { walkWorkspace, type TreeNode } from "@/services/fs-bridge"; +export type WorkspaceFile = { + name: string; + path: string; + label: string; + relativePath: string; + folder: string; + searchName: string; + searchPath: string; +}; + type WorkspaceState = { /** Absolute path of the current workspace root, or null when no folder is open. */ root: string | null; /** File tree (markdown files only). */ tree: TreeNode | null; + /** Flat markdown file index derived once per successful workspace scan. */ + files: WorkspaceFile[]; /** Loading state for tree rebuild. */ loadingTree: boolean; + /** Monotonic scan id used to ignore stale async walk results. */ + scanVersion: number; /** Most-recently-opened workspace paths (most recent first, capped at 8). */ recent: string[]; @@ -27,21 +41,79 @@ type WorkspaceState = { }; const RECENT_LIMIT = 8; +let nextScanVersion = 0; + +function stripExt(name: string): string { + const idx = name.lastIndexOf("."); + if (idx <= 0) return name; + const ext = name.slice(idx).toLowerCase(); + if (ext === ".md" || ext === ".mdx" || ext === ".markdown") { + return name.slice(0, idx); + } + return name; +} + +function relativePath(fullPath: string, workspaceRoot: string | null): string { + if (workspaceRoot && fullPath.startsWith(workspaceRoot + "/")) { + return fullPath.slice(workspaceRoot.length + 1); + } + return fullPath; +} + +export function buildWorkspaceFileIndex( + tree: TreeNode | null, + root: string | null, +): WorkspaceFile[] { + const files: WorkspaceFile[] = []; + + function visit(node: TreeNode) { + if (!node.isDir) { + const rel = relativePath(node.path, root); + const folder = rel.includes("/") + ? rel.slice(0, rel.lastIndexOf("/")) + : ""; + const label = stripExt(node.name); + files.push({ + name: node.name, + path: node.path, + label, + relativePath: rel, + folder, + searchName: label.toLowerCase(), + searchPath: rel.toLowerCase(), + }); + return; + } + + for (const child of node.children ?? []) { + visit(child); + } + } + + if (tree) visit(tree); + return files; +} export const useWorkspaceStore = create()( persist( (set, get) => ({ root: null, tree: null, + files: [], loadingTree: false, + scanVersion: 0, recent: [], openWorkspace: async (root) => { - set({ root, tree: null, loadingTree: true }); + const scanVersion = ++nextScanVersion; + set({ root, tree: null, files: [], loadingTree: true, scanVersion }); try { const tree = await walkWorkspace(root); + if (get().scanVersion !== scanVersion || get().root !== root) return; + const files = buildWorkspaceFileIndex(tree, root); set((s) => ({ tree, + files, loadingTree: false, recent: [root, ...s.recent.filter((r) => r !== root)].slice( 0, @@ -49,6 +121,7 @@ export const useWorkspaceStore = create()( ), })); } catch (e) { + if (get().scanVersion !== scanVersion || get().root !== root) return; console.error("Failed to open workspace:", e); set({ loadingTree: false }); throw e; @@ -56,17 +129,28 @@ export const useWorkspaceStore = create()( }, closeWorkspace: () => { - set({ root: null, tree: null }); + const scanVersion = ++nextScanVersion; + set({ + root: null, + tree: null, + files: [], + loadingTree: false, + scanVersion, + }); }, refreshTree: async () => { const root = get().root; if (!root) return; - set({ loadingTree: true }); + const scanVersion = ++nextScanVersion; + set({ loadingTree: true, scanVersion }); try { const tree = await walkWorkspace(root); - set({ tree, loadingTree: false }); + if (get().scanVersion !== scanVersion || get().root !== root) return; + const files = buildWorkspaceFileIndex(tree, root); + set({ tree, files, loadingTree: false }); } catch (e) { + if (get().scanVersion !== scanVersion || get().root !== root) return; console.error("Failed to refresh tree:", e); set({ loadingTree: false }); } diff --git a/src/styles/editor.css b/src/styles/editor.css index 0c3518f..e0f2882 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -86,6 +86,7 @@ /* Links: accent color, 1px underline, 3px offset. Hover saturates. */ .tiptap a { color: var(--accent); + cursor: pointer; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; @@ -361,5 +362,8 @@ background: var(--hl-deletion-bg, color-mix(in oklab, red 12%, transparent)); } .tiptap .hljs-addition { - background: var(--hl-addition-bg, color-mix(in oklab, green 12%, transparent)); + background: var( + --hl-addition-bg, + color-mix(in oklab, green 12%, transparent) + ); } diff --git a/tests/e2e/web/fullmark.spec.ts b/tests/e2e/web/fullmark.spec.ts new file mode 100644 index 0000000..c7b58ca --- /dev/null +++ b/tests/e2e/web/fullmark.spec.ts @@ -0,0 +1,351 @@ +import { expect, test, type Page } from "@playwright/test"; + +type WorkspaceEntry = { + name: string; + path: string; + isDir: boolean; + parent: number | null; +}; + +const ROOT = "/e2e-vault"; + +async function pressAppShortcut(page: Page, key: string, shiftKey = false) { + await page.locator("body").dispatchEvent("keydown", { + key, + ctrlKey: true, + metaKey: false, + shiftKey, + altKey: false, + bubbles: true, + cancelable: true, + }); +} + +function makeEntries(extraFiles = 0): WorkspaceEntry[] { + const entries: WorkspaceEntry[] = [ + { name: "e2e-vault", path: ROOT, isDir: true, parent: null }, + { name: "Inbox", path: `${ROOT}/Inbox`, isDir: true, parent: 0 }, + { name: "Projects", path: `${ROOT}/Projects`, isDir: true, parent: 0 }, + { + name: "Daily Note.md", + path: `${ROOT}/Inbox/Daily Note.md`, + isDir: false, + parent: 1, + }, + { + name: "Project Plan.mdx", + path: `${ROOT}/Projects/Project Plan.mdx`, + isDir: false, + parent: 2, + }, + { + name: "README.markdown", + path: `${ROOT}/README.markdown`, + isDir: false, + parent: 0, + }, + ]; + + for (let idx = 0; idx < extraFiles; idx++) { + entries.push({ + name: `Generated ${String(idx).padStart(4, "0")}.md`, + path: `${ROOT}/Projects/Generated ${String(idx).padStart(4, "0")}.md`, + isDir: false, + parent: 2, + }); + } + + return entries; +} + +function makeFiles(extraFiles = 0): Record { + const files: Record = { + [`${ROOT}/Inbox/Daily Note.md`]: [ + "# Daily Note", + "", + "Today links to [[Project Plan]].", + "", + "External link: [Example](https://example.com).", + "", + "- [ ] Keep editing fast", + "", + ].join("\n"), + [`${ROOT}/Projects/Project Plan.mdx`]: [ + "# Project Plan", + "", + "A plan with `inline code` and a task.", + "", + "- [x] Build test harness", + "", + ].join("\n"), + [`${ROOT}/README.markdown`]: "# Readme\n\nGenerated workspace root note.\n", + }; + + for (let idx = 0; idx < extraFiles; idx++) { + files[`${ROOT}/Projects/Generated ${String(idx).padStart(4, "0")}.md`] = + `# Generated ${idx}\n\nSynthetic file for quick switcher coverage.\n`; + } + + return files; +} + +async function installTauriMock(page: Page, extraFiles = 0) { + await page.addInitScript( + ({ entries, files, root }) => { + type Callback = (payload: unknown) => void; + const callbackMap = new Map(); + const listenerMap = new Map< + number, + { event: string; callbackId: number } + >(); + let nextCallbackId = 1; + let nextEventId = 1; + const mutableFiles = { ...files }; + const writes: Array<{ path: string; content: string }> = []; + const openedUrls: string[] = []; + + window.__FULLMARK_E2E__ = { + root, + writes, + openedUrls, + files: mutableFiles, + emit(event: string, payload: unknown) { + for (const [id, listener] of listenerMap) { + if (listener.event !== event) continue; + callbackMap.get(listener.callbackId)?.({ event, id, payload }); + } + }, + }; + + window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { + unregisterListener(_event: string, id: number) { + listenerMap.delete(id); + }, + }; + + window.__TAURI_INTERNALS__ = { + transformCallback(callback: Callback, once = false) { + const id = nextCallbackId++; + callbackMap.set(id, (payload: unknown) => { + callback(payload); + if (once) callbackMap.delete(id); + }); + return id; + }, + unregisterCallback(id: number) { + callbackMap.delete(id); + }, + convertFileSrc(path: string) { + return path; + }, + async invoke(command: string, args?: Record) { + if (command === "plugin:dialog|open") return root; + if (command === "plugin:event|listen") { + const eventId = nextEventId++; + listenerMap.set(eventId, { + event: String(args?.event), + callbackId: Number(args?.handler), + }); + return eventId; + } + if (command === "plugin:event|unlisten") { + listenerMap.delete(Number(args?.eventId)); + return null; + } + if (command === "plugin:opener|open_url") { + openedUrls.push(String(args?.url)); + return null; + } + if (command === "walk_workspace") return entries; + if (command === "read_text_file") { + const path = String(args?.path); + return { + content: mutableFiles[path] ?? "", + canonicalPath: path, + modifiedMs: 1, + }; + } + if (command === "atomic_write_text") { + const path = String(args?.path); + const content = String(args?.content); + mutableFiles[path] = content; + writes.push({ path, content }); + return path; + } + if (command === "is_default_markdown_handler") return false; + if (command === "get_default_markdown_handler") return null; + if (command === "set_default_markdown_handler") return null; + throw new Error(`Unhandled Tauri command in E2E mock: ${command}`); + }, + }; + }, + { + entries: makeEntries(extraFiles), + files: makeFiles(extraFiles), + root: ROOT, + }, + ); +} + +async function openWorkspace(page: Page, extraFiles = 0) { + await installTauriMock(page, extraFiles); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "FullMark" })).toBeVisible(); + await page.getByRole("button", { name: "Open folder…" }).click(); + await expect(page.getByText("e2e-vault")).toBeVisible(); +} + +async function bootWithPersistedSession(page: Page) { + await installTauriMock(page); + await page.addInitScript( + ({ root }) => { + localStorage.setItem( + "fullmark.workspace", + JSON.stringify({ + state: { root, recent: [root] }, + version: 0, + }), + ); + localStorage.setItem( + "fullmark.tabs", + JSON.stringify({ + state: { + restoredPaths: [ + `${root}/Inbox/Daily Note.md`, + `${root}/Projects/Project Plan.mdx`, + ], + activePath: `${root}/Projects/Project Plan.mdx`, + }, + version: 1, + }), + ); + }, + { root: ROOT }, + ); + await page.goto("/"); +} + +test("opens a mocked workspace and filters the markdown tree", async ({ + page, +}) => { + await openWorkspace(page); + + await expect(page.getByRole("tree")).toBeVisible(); + await expect( + page.getByRole("treeitem", { name: "Daily Note" }), + ).toBeVisible(); + await expect( + page.getByRole("treeitem", { name: "Project Plan" }), + ).toBeVisible(); + await expect(page.getByText("asset")).toHaveCount(0); +}); + +test("opens, edits, and saves latest source content", async ({ page }) => { + await openWorkspace(page); + + await page.getByRole("treeitem", { name: "Daily Note" }).click(); + await expect(page.getByRole("tab", { name: "Daily Note" })).toBeVisible(); + + await page.locator('button[title^="Source view"]').click(); + const source = page.getByLabel("Markdown source"); + await expect(source).toBeVisible(); + await source.fill("# Daily Note\n\nChanged in headless E2E.\n"); + await expect(page.getByText("Unsaved")).toBeVisible(); + + await pressAppShortcut(page, "s"); + await expect + .poll(() => page.evaluate(() => window.__FULLMARK_E2E__.writes.length)) + .toBe(1); + await expect(page.getByText("Saved")).toBeVisible(); + + const writes = await page.evaluate(() => window.__FULLMARK_E2E__.writes); + expect(writes).toEqual([ + { + path: `${ROOT}/Inbox/Daily Note.md`, + content: "# Daily Note\n\nChanged in headless E2E.\n", + }, + ]); +}); + +test("quick switcher navigates a large indexed workspace and reader mode toggles", async ({ + page, +}) => { + await openWorkspace(page, 250); + + await pressAppShortcut(page, "k"); + await expect( + page.getByRole("dialog", { name: "Search files" }), + ).toBeVisible(); + await page.getByPlaceholder("Search files…").fill("generated 0249"); + await expect( + page.getByRole("option", { name: /Generated 0249/ }), + ).toBeVisible(); + await page.keyboard.press("Enter"); + + await expect(page.getByRole("tab", { name: "Generated 0249" })).toBeVisible(); + await pressAppShortcut(page, "r"); + await expect(page.locator(".app-title-mode")).toHaveText(/Reader/); +}); + +test("cmd-click opens wikilink targets from the current workspace", async ({ + page, +}) => { + await openWorkspace(page); + + await page.getByRole("treeitem", { name: "Daily Note" }).click(); + await expect(page.getByRole("tab", { name: "Daily Note" })).toBeVisible(); + + const wikilink = page.locator("[data-wikilink='Project Plan']"); + await expect(wikilink).toBeVisible(); + await expect(wikilink).toHaveCSS("cursor", "pointer"); + await wikilink.dispatchEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + metaKey: true, + }); + + await expect(page.getByRole("tab", { name: "Project Plan" })).toBeVisible(); +}); + +test("cmd-click opens markdown URL links in the system browser", async ({ + page, +}) => { + await openWorkspace(page); + + await page.getByRole("treeitem", { name: "Daily Note" }).click(); + await expect(page.getByRole("tab", { name: "Daily Note" })).toBeVisible(); + + const link = page.getByRole("link", { name: "Example" }); + await expect(link).toBeVisible(); + await expect(link).toHaveCSS("cursor", "pointer"); + await link.dispatchEvent("click", { + bubbles: true, + cancelable: true, + button: 0, + metaKey: true, + }); + + await expect + .poll(() => page.evaluate(() => window.__FULLMARK_E2E__.openedUrls)) + .toEqual(["https://example.com/"]); +}); + +test("restores open tabs and active file after app restart", async ({ + page, +}) => { + await bootWithPersistedSession(page); + + await expect( + page.getByRole("treeitem", { name: "Daily Note" }), + ).toBeVisible(); + await expect(page.getByRole("tab", { name: "Daily Note" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Project Plan" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Project Plan" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect( + page.getByRole("heading", { name: "Project Plan" }), + ).toBeVisible(); +}); diff --git a/tests/editor-save.test.ts b/tests/editor-save.test.ts new file mode 100644 index 0000000..4d65270 --- /dev/null +++ b/tests/editor-save.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fsBridge = vi.hoisted(() => ({ + readTextFile: vi.fn(), + atomicWrite: vi.fn(), +})); + +vi.mock("@/services/fs-bridge", () => fsBridge); + +import { useTabsStore } from "../src/stores/tabs"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("editor save snapshots", () => { + beforeEach(() => { + vi.clearAllMocks(); + useTabsStore.setState({ tabs: [], activePath: null }); + fsBridge.readTextFile.mockResolvedValue({ + canonicalPath: "/tmp/note.md", + content: "saved", + modifiedMs: 1, + }); + }); + + it("writes the registered editor snapshot instead of stale tab content", async () => { + const path = await useTabsStore.getState().openFile("/tmp/note.md"); + const unregister = useTabsStore + .getState() + .registerSnapshotProvider(path, () => ({ + content: "rendered edit", + revision: 1, + })); + + useTabsStore.getState().markEditorChanged(path, 1); + await useTabsStore.getState().save(path); + + expect(fsBridge.atomicWrite).toHaveBeenCalledWith(path, "rendered edit"); + expect(useTabsStore.getState().tabs[0]).toMatchObject({ + baseContent: "rendered edit", + content: "rendered edit", + revision: 1, + savedRevision: 1, + dirty: false, + }); + + unregister(); + }); + + it("keeps a tab dirty when edits happen during an in-flight save", async () => { + const path = await useTabsStore.getState().openFile("/tmp/note.md"); + const write = deferred(); + fsBridge.atomicWrite.mockReturnValue(write.promise); + + let snapshot = { content: "first edit", revision: 1 }; + const unregister = useTabsStore + .getState() + .registerSnapshotProvider(path, () => snapshot); + + useTabsStore.getState().markEditorChanged(path, 1); + const savePromise = useTabsStore.getState().save(path); + expect(fsBridge.atomicWrite).toHaveBeenCalledWith(path, "first edit"); + + snapshot = { content: "second edit", revision: 2 }; + useTabsStore.getState().markEditorChanged(path, 2); + + write.resolve(path); + await savePromise; + + expect(useTabsStore.getState().tabs[0]).toMatchObject({ + baseContent: "first edit", + revision: 2, + savedRevision: 1, + dirty: true, + }); + + unregister(); + }); + + it("clears dirty without writing when the save snapshot matches disk", async () => { + const path = await useTabsStore.getState().openFile("/tmp/note.md"); + useTabsStore.getState().markEditorChanged(path, 1); + useTabsStore.getState().syncEditorSnapshot(path, { + content: "saved", + revision: 1, + }); + + await useTabsStore.getState().save(path); + + expect(fsBridge.atomicWrite).not.toHaveBeenCalled(); + expect(useTabsStore.getState().tabs[0]).toMatchObject({ + content: "saved", + revision: 1, + savedRevision: 1, + dirty: false, + }); + }); +}); diff --git a/tests/fidelity-production.test.ts b/tests/fidelity-production.test.ts new file mode 100644 index 0000000..50d9904 --- /dev/null +++ b/tests/fidelity-production.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment jsdom + */ + +import { afterEach, describe, expect, it } from "vitest"; +import { Editor } from "@tiptap/core"; +import { StarterKit } from "@tiptap/starter-kit"; +import { Markdown } from "@tiptap/markdown"; +import { Placeholder } from "@tiptap/extensions"; +import { Typography } from "@tiptap/extension-typography"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TaskItem } from "@tiptap/extension-task-item"; +import { Wikilink } from "@/services/editor/extensions/wikilink"; +import { CodeHighlight } from "@/services/editor/extensions/code-highlight"; +import { SlashMenuExtension } from "@/services/editor/extensions/slash-menu"; + +const editors: Editor[] = []; + +function makeProductionEditor() { + const element = document.createElement("div"); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: [ + StarterKit.configure({ + codeBlock: false, + }), + CodeHighlight, + Typography, + TaskList, + TaskItem.configure({ nested: true }), + Markdown.configure({ + indentation: { style: "space", size: 2 }, + }), + Placeholder.configure({ + placeholder: "Type / for commands...", + emptyEditorClass: "is-editor-empty", + }), + Wikilink, + SlashMenuExtension, + ], + content: "", + editable: true, + }); + editors.push(editor); + return editor; +} + +function roundTrip(markdown: string) { + const editor = makeProductionEditor(); + editor.commands.setContent(markdown, { contentType: "markdown" }); + return editor.getMarkdown(); +} + +function jsonFor(markdown: string) { + const editor = makeProductionEditor(); + editor.commands.setContent(markdown, { contentType: "markdown" }); + return editor.getJSON(); +} + +afterEach(() => { + for (const editor of editors.splice(0)) { + editor.destroy(); + editor.options.element.remove(); + } +}); + +describe("production editor markdown fidelity", () => { + it("keeps FullMark custom markdown idempotent after the canonical pass", () => { + const markdown = [ + "# Daily note", + "", + "Intro with [[Project Atlas|Atlas]] and `inline code`.", + "", + "- [ ] Ship stability tests", + " - [x] Preserve nested task state", + "", + "```ts", + "const status = \"stable\";", + "```", + "", + "> Quote with **strong** and _emphasis_.", + "", + ].join("\n"); + + const firstPass = roundTrip(markdown); + const secondPass = roundTrip(firstPass); + + expect(firstPass).toContain("[[Project Atlas|Atlas]]"); + expect(firstPass).toContain("- [ ] Ship stability tests"); + expect(firstPass).toContain(" - [x] Preserve nested task state"); + expect(firstPass).toContain("```ts"); + expect(secondPass).toBe(firstPass); + expect(jsonFor(secondPass)).toEqual(jsonFor(firstPass)); + }); + + it("does not serialize wikilinks as plain bracket text", () => { + const markdown = "See [[Roadmap]] and [[Project Atlas|Atlas]]."; + const output = roundTrip(markdown); + + expect(output).toBe(markdown); + expect(output).not.toContain("\\[\\["); + }); +}); diff --git a/tests/perf-harness.test.ts b/tests/perf-harness.test.ts new file mode 100644 index 0000000..f75d856 --- /dev/null +++ b/tests/perf-harness.test.ts @@ -0,0 +1,82 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const harness = await import("../scripts/generate-vault.mjs"); + +describe("generated vault harness", () => { + it("exposes every required benchmark profile", () => { + expect(harness.PROFILE_NAMES).toEqual([ + "small", + "medium", + "large", + "chaos", + "flat-50k", + "deep-5k", + "wide-dirs", + "mixed-large", + "unicode-paths", + ]); + }); + + it("generates deterministic small vault manifests outside the repo by default", async () => { + const first = await harness.createGeneratedVault({ + profile: "small", + seed: "unit-seed", + }); + const second = await harness.createGeneratedVault({ + profile: "small", + seed: "unit-seed", + }); + + try { + expect(first.root.startsWith(os.tmpdir())).toBe(true); + expect(second.root.startsWith(os.tmpdir())).toBe(true); + expect(first.manifest.markdownFileCount).toBe(32); + expect(first.manifest.markdownFiles).toEqual(second.manifest.markdownFiles); + expect(first.manifest.totalMarkdownBytes).toBe(second.manifest.totalMarkdownBytes); + + const samplePath = path.join(first.root, first.manifest.markdownFiles[0]); + await expect(fs.readFile(samplePath, "utf8")).resolves.toContain("# "); + } finally { + await first.cleanup(); + await second.cleanup(); + } + }); + + it("covers unicode paths without losing manifest readability", async () => { + const vault = await harness.createGeneratedVault({ + profile: "unicode-paths", + seed: "unicode-unit-seed", + }); + + try { + expect(vault.manifest.markdownFileCount).toBe(420); + expect(vault.manifest.markdownFiles.some((file: string) => /[^\x00-\x7F]/.test(file))).toBe( + true, + ); + await expect( + fs.readFile(path.join(vault.root, "fullmark-generated-manifest.json"), "utf8"), + ).resolves.toContain("unicode-paths"); + } finally { + await vault.cleanup(); + } + }); + + it("keeps generated vaults under .tmp when artifact retention is requested", async () => { + process.env.FULLMARK_KEEP_ARTIFACTS = "1"; + const vault = await harness.createGeneratedVault({ + profile: "small", + seed: "keep-artifacts-seed", + }); + + try { + expect(vault.root).toContain(`${path.sep}.tmp${path.sep}generated-vaults${path.sep}small`); + const stats = await fs.stat(vault.root); + expect(stats.isDirectory()).toBe(true); + } finally { + await fs.rm(vault.root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/save-race.test.ts b/tests/save-race.test.ts new file mode 100644 index 0000000..63ee4db --- /dev/null +++ b/tests/save-race.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { atomicWrite } from "@/services/fs-bridge"; +import { useTabsStore } from "@/stores/tabs"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +type PendingWrite = { + path: string; + content: string; + resolve: (value: string) => void; + reject: (reason?: unknown) => void; +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("save race stability", () => { + const invokeMock = vi.mocked(invoke); + const writes: PendingWrite[] = []; + + beforeEach(() => { + writes.length = 0; + invokeMock.mockReset(); + invokeMock.mockImplementation((command, args) => { + if (command === "read_text_file") { + const path = String((args as { path: string }).path); + return Promise.resolve({ + content: "base", + canonicalPath: path, + modifiedMs: 100, + }); + } + + if (command === "atomic_write_text") { + const pending = deferred(); + const writeArgs = args as { path: string; content: string }; + writes.push({ + path: writeArgs.path, + content: writeArgs.content, + resolve: pending.resolve, + reject: pending.reject, + }); + return pending.promise; + } + + return Promise.reject(new Error(`unexpected invoke command: ${command}`)); + }); + + useTabsStore.setState(useTabsStore.getInitialState(), true); + }); + + it("serializes atomic writes to the same path", async () => { + const first = atomicWrite("/notes/a.md", "one"); + const second = atomicWrite("/notes/a.md", "two"); + + await flushMicrotasks(); + expect(writes).toHaveLength(1); + expect(writes[0]).toMatchObject({ path: "/notes/a.md", content: "one" }); + + writes[0].resolve("/notes/a.md"); + await first; + await flushMicrotasks(); + + expect(writes).toHaveLength(2); + expect(writes[1]).toMatchObject({ path: "/notes/a.md", content: "two" }); + + writes[1].resolve("/notes/a.md"); + await second; + }); + + it("keeps newer edits dirty when an older save finishes later", async () => { + const path = await useTabsStore.getState().openFile("/notes/race.md"); + + useTabsStore.getState().updateContent(path, "draft one"); + const save = useTabsStore.getState().save(path); + + await flushMicrotasks(); + expect(writes).toHaveLength(1); + expect(writes[0].content).toBe("draft one"); + + useTabsStore.getState().updateContent(path, "draft two"); + writes[0].resolve(path); + await save; + + const tab = useTabsStore.getState().tabs.find((candidate) => { + return candidate.path === path; + }); + + expect(tab).toMatchObject({ + content: "draft two", + baseContent: "draft one", + dirty: true, + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..9e1b7b4 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,6 @@ +import { afterEach } from "vitest"; + +afterEach(() => { + delete process.env.FULLMARK_KEEP_ARTIFACTS; + delete process.env.FULLMARK_ARTIFACT_DIR; +}); diff --git a/tests/tabs-restore.test.ts b/tests/tabs-restore.test.ts new file mode 100644 index 0000000..c02fca3 --- /dev/null +++ b/tests/tabs-restore.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fsBridge = vi.hoisted(() => ({ + readTextFile: vi.fn(), + atomicWrite: vi.fn(), +})); + +vi.mock("@/services/fs-bridge", () => fsBridge); + +import { useTabsStore } from "../src/stores/tabs"; + +describe("tab session restore", () => { + beforeEach(() => { + vi.clearAllMocks(); + useTabsStore.setState(useTabsStore.getInitialState(), true); + fsBridge.readTextFile.mockImplementation((path: string) => + Promise.resolve({ + canonicalPath: path, + content: `# ${path}`, + modifiedMs: 1, + }), + ); + }); + + it("reopens persisted tab paths from disk and restores the active tab", async () => { + useTabsStore.setState({ + restoredPaths: ["/vault/a.md", "/vault/b.md"], + activePath: "/vault/a.md", + hasRestoredSession: false, + restoringTabs: false, + }); + + await useTabsStore.getState().restoreSession(); + + expect(fsBridge.readTextFile).toHaveBeenCalledTimes(2); + expect(useTabsStore.getState().tabs.map((tab) => tab.path)).toEqual([ + "/vault/a.md", + "/vault/b.md", + ]); + expect(useTabsStore.getState().activePath).toBe("/vault/a.md"); + expect(useTabsStore.getState().tabs.every((tab) => !tab.dirty)).toBe(true); + }); +}); diff --git a/tests/workspace-race.test.ts b/tests/workspace-race.test.ts new file mode 100644 index 0000000..5e5c03d --- /dev/null +++ b/tests/workspace-race.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { walkWorkspace, type TreeNode } from "@/services/fs-bridge"; +import { useWorkspaceStore } from "@/stores/workspace"; + +vi.mock("@/services/fs-bridge", () => ({ + walkWorkspace: vi.fn(), +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function tree(root: string, child: string): TreeNode { + return { + name: root.split("/").pop() || root, + path: root, + isDir: true, + hasMarkdown: true, + children: [{ name: child, path: `${root}/${child}`, isDir: false }], + }; +} + +describe("workspace stale scan race", () => { + const walkWorkspaceMock = vi.mocked(walkWorkspace); + + beforeEach(() => { + localStorage.clear(); + walkWorkspaceMock.mockReset(); + useWorkspaceStore.setState(useWorkspaceStore.getInitialState(), true); + }); + + it("does not let an older workspace scan replace the current workspace tree", async () => { + const first = deferred(); + const second = deferred(); + walkWorkspaceMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const openFirst = useWorkspaceStore.getState().openWorkspace("/vault/old"); + const openSecond = useWorkspaceStore.getState().openWorkspace("/vault/new"); + + second.resolve(tree("/vault/new", "current.md")); + await openSecond; + + expect(useWorkspaceStore.getState()).toMatchObject({ + root: "/vault/new", + tree: { + path: "/vault/new", + children: [{ name: "current.md" }], + }, + loadingTree: false, + }); + + first.resolve(tree("/vault/old", "stale.md")); + await openFirst; + + expect(useWorkspaceStore.getState()).toMatchObject({ + root: "/vault/new", + tree: { + path: "/vault/new", + children: [{ name: "current.md" }], + }, + loadingTree: false, + }); + }); +}); diff --git a/tests/workspace-search.test.ts b/tests/workspace-search.test.ts new file mode 100644 index 0000000..2cdec18 --- /dev/null +++ b/tests/workspace-search.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { buildTreeFromWorkspaceEntries } from "../src/services/fs-bridge"; +import { buildWorkspaceFileIndex } from "../src/stores/workspace"; +import { rankWorkspaceFiles } from "../src/components/CommandPalette/QuickSwitcher"; +import { resolveWorkspaceLinkTarget } from "../src/services/link-targets"; + +describe("workspace tree and search indexing", () => { + it("rebuilds nested tree data from flat workspace entries", () => { + const tree = buildTreeFromWorkspaceEntries( + [ + { name: "vault", path: "/vault", isDir: true, parent: null }, + { name: "Notes", path: "/vault/Notes", isDir: true, parent: 0 }, + { + name: "Meeting.md", + path: "/vault/Notes/Meeting.md", + isDir: false, + parent: 1, + }, + { + name: "README.md", + path: "/vault/README.md", + isDir: false, + parent: 0, + }, + ], + "/vault", + ); + + expect(tree.children?.map((child) => child.name)).toEqual([ + "Notes", + "README.md", + ]); + expect(tree.hasMarkdown).toBe(true); + expect(tree.children?.[0].hasMarkdown).toBe(true); + }); + + it("builds a reusable flat file index for quick switching", () => { + const tree = buildTreeFromWorkspaceEntries( + [ + { name: "vault", path: "/vault", isDir: true, parent: null }, + { name: "Notes", path: "/vault/Notes", isDir: true, parent: 0 }, + { + name: "Meeting.md", + path: "/vault/Notes/Meeting.md", + isDir: false, + parent: 1, + }, + { + name: "Plan.mdx", + path: "/vault/Projects/Plan.mdx", + isDir: false, + parent: 0, + }, + ], + "/vault", + ); + + const index = buildWorkspaceFileIndex(tree, "/vault"); + + expect(index).toMatchObject([ + { + label: "Meeting", + relativePath: "Notes/Meeting.md", + folder: "Notes", + searchName: "meeting", + }, + { + label: "Plan", + relativePath: "Projects/Plan.mdx", + folder: "Projects", + searchPath: "projects/plan.mdx", + }, + ]); + expect(rankWorkspaceFiles(index, "meet")[0].file.path).toBe( + "/vault/Notes/Meeting.md", + ); + expect(rankWorkspaceFiles(index, "proj")[0].file.path).toBe( + "/vault/Projects/Plan.mdx", + ); + }); + + it("resolves wikilink targets from the workspace index", () => { + const tree = buildTreeFromWorkspaceEntries( + [ + { name: "vault", path: "/vault", isDir: true, parent: null }, + { name: "Notes", path: "/vault/Notes", isDir: true, parent: 0 }, + { + name: "Meeting.md", + path: "/vault/Notes/Meeting.md", + isDir: false, + parent: 1, + }, + { + name: "Project Plan.mdx", + path: "/vault/Projects/Project Plan.mdx", + isDir: false, + parent: 0, + }, + ], + "/vault", + ); + + const index = buildWorkspaceFileIndex(tree, "/vault"); + + expect(resolveWorkspaceLinkTarget(index, "Project Plan")?.path).toBe( + "/vault/Projects/Project Plan.mdx", + ); + expect(resolveWorkspaceLinkTarget(index, "Notes/Meeting#agenda")?.path).toBe( + "/vault/Notes/Meeting.md", + ); + expect(resolveWorkspaceLinkTarget(index, "Missing")).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..692781b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + globals: true, + setupFiles: ["tests/setup.ts"], + include: ["tests/**/*.test.ts"], + testTimeout: 30_000, + }, +});