chore: project scaffold, build config, scripts and README

Bun + SvelteKit (static adapter) + Electron project files, icon resources,
dev/e2e/bench scripts and the README documenting the app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 437887207c
commit 0b5f8bfa8b
27 files changed
+2050

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
// Timing of the vault-side work on a big synthetic vault: node scripts/bench.cjs [notes=10000]
// Run with the Electron binary as node so better-sqlite3 loads: ELECTRON_RUN_AS_NODE=1 electron scripts/bench.cjs
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { Vault } = require('../electron/vault.cjs');
const { openDb } = require('../electron/db.cjs');
const links = require('../electron/links.cjs');
const tasks = require('../electron/tasks.cjs');
const N = Number(process.argv[2]) || 10000;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-bench-'));
const words = 'alpha beta gamma delta project roadmap meeting notes idea review plan budget design draft summary weekly sync'.split(' ');
const rnd = (n) => Math.floor(Math.random() * n);
const sentence = () => Array.from({ length: 8 + rnd(10) }, () => words[rnd(words.length)]).join(' ');
const t0 = Date.now();
for (let i = 0; i < N; i++) {
const folder = path.join(dir, `Area ${i % 40}`, `Topic ${i % 400}`);
fs.mkdirSync(folder, { recursive: true });
let body = `# Note ${i}\n\n`;
for (let p = 0; p < 6; p++) body += sentence() + (rnd(3) === 0 ? ` [[Note ${rnd(N)}]]` : '') + '.\n\n';
body += `- [ ] task ${i} #work\n`;
fs.writeFileSync(path.join(folder, `Note ${i}.md`), `---\ntags: [t${i % 50}]\n---\n\n${body}`);
}
console.log(`generated ${N} notes in ${Date.now() - t0} ms`);
const time = async (label, fn) => { const s = process.hrtime.bigint(); const r = await fn(); console.log(label.padEnd(34), String(Number(process.hrtime.bigint() - s) / 1e6 | 0).padStart(7), 'ms'); return r; };
(async () => {
const v = new Vault(dir, { trash: async () => {} });
const pages = await time('vault.list (cold)', () => v.list());
await time('vault.list (warm cache)', () => v.list());
const db = openDb(path.join(dir, 'index.sqlite'));
await time('db.rebuild', async () => db.rebuild(pages));
await time('db.search "roadmap budget"', async () => db.search('roadmap budget'));
await time('links.graph', async () => links.graph(pages));
const target = pages.find((p) => p.title === 'Note 5').id;
await time('links.backlinks (one page)', async () => links.backlinks(target, pages));
await time('links.unlinked (one page)', async () => links.unlinked(target, pages));
await time('tasks.findTasks', async () => tasks.findTasks(pages));
await time('JSON size of page list (no body)', async () => JSON.stringify(pages.map(({ body, ...p }) => p)).length);
db.close();
fs.rmSync(dir, { recursive: true, force: true });
})();
+33
View File
@@ -0,0 +1,33 @@
// `bun run dev` runs this: Electron with --dev, restarted whenever a file in electron/ changes.
// (Vite hot-reloads the page by itself, but the main process only loads its code once.)
import { spawn } from 'node:child_process';
import { watch } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const electron = path.join(root, 'node_modules', '.bin', 'electron');
let child = null;
let restarting = false;
let timer = null;
function start() {
child = spawn(electron, ['.', '--dev', ...process.argv.slice(2)], { cwd: root, stdio: 'inherit' });
child.on('exit', (code) => {
if (restarting) { restarting = false; start(); return; }
process.exit(code ?? 0); // the window was closed: stop dev mode, as before
});
}
watch(path.join(root, 'electron'), { recursive: true }, (_event, file) => {
if (!file || !/\.(c?js|json)$/.test(file)) return;
clearTimeout(timer);
timer = setTimeout(() => {
console.log(`[app] ${file} changed, restarting Electron`);
restarting = true;
child.kill('SIGTERM');
}, 250);
});
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { restarting = false; child?.kill(sig); });
start();
+40
View File
@@ -0,0 +1,40 @@
// Runs the end-to-end suites. No arguments: all of them, in parallel. Names: only those, e.g. `bun run e2e canvas tags`.
import { spawn } from 'node:child_process';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const electron = path.join(root, 'node_modules/.bin/electron');
const want = process.argv.slice(2);
const all = readdirSync(path.join(root, 'test')).filter((f) => /^e2e.*\.test\.cjs$/.test(f)).sort();
const files = want.length ? all.filter((f) => want.some((w) => f === `e2e-${w}.test.cjs` || f === `e2e.test.cjs` && w === 'e2e')) : all;
if (!files.length) { console.error(`No suite matches: ${want.join(', ')}`); process.exit(2); }
// Timing-sensitive suites (they measure frame rate) run alone, after the rest, so the others can't slow them down.
const SERIAL = ['e2e-board-drag.test.cjs'];
const started = Date.now();
const run = (file) => new Promise((resolve) => {
const t0 = Date.now();
const child = spawn(electron, [path.join('test', file), '--ozone-platform=headless'], { cwd: root });
let out = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (out += d));
child.on('close', (code) => {
const lines = out.split('\n').filter((l) => /^(PASS|FAIL)|passed!|failed|harness/.test(l) && !/Fontconfig/.test(l));
const fails = lines.filter((l) => l.startsWith('FAIL') || /failed|harness/.test(l));
resolve({ file, code, secs: ((Date.now() - t0) / 1000).toFixed(0), fails, checks: lines.filter((l) => l.startsWith('PASS')).length });
});
});
const parallel = files.filter((f) => !SERIAL.includes(f));
const results = await Promise.all(parallel.map(run));
for (const f of files.filter((f) => SERIAL.includes(f))) results.push(await run(f));
for (const r of results) {
const ok = r.code === 0;
console.log(`${ok ? 'ok ' : 'FAIL'} ${r.file.padEnd(28)} ${String(r.checks).padStart(3)} checks ${r.secs}s`);
if (!ok) for (const l of r.fails) console.log(' ' + l);
}
console.log(`\n${results.filter((r) => r.code === 0).length}/${results.length} suites passed in ${((Date.now() - started) / 1000).toFixed(0)}s`);
process.exit(results.every((r) => r.code === 0) ? 0 : 1);
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Render resources/icon.svg into every icon file the app and its installers use.
# Needs rsvg-convert (librsvg) and ImageMagick. The results are committed, so building doesn't need these.
set -e
cd "$(dirname "$0")/../resources"
mkdir -p icons
for s in 16 24 32 48 64 128 256 512 1024; do
rsvg-convert -w $s -h $s icon.svg -o icons/${s}x${s}.png
done
cp icons/1024x1024.png icon.png # electron-builder: macOS .icns is made from this
magick icons/16x16.png icons/24x24.png icons/32x32.png icons/48x48.png \
icons/64x64.png icons/128x128.png icons/256x256.png icon.ico # Windows
rm icons/1024x1024.png # Linux sizes stop at 512
cp icon.svg ../static/icon.svg # the welcome screen shows it
cp icons/64x64.png ../static/favicon.png
echo "icons written to resources/ and static/"
+79
View File
@@ -0,0 +1,79 @@
// How the app feels on a big vault. Not part of `bun run e2e` (it is slow): electron scripts/perf-large.cjs [notes=10000]
const { app, BrowserWindow } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const N = Number(process.argv.find((a) => /^\d+$/.test(a))) || 10000;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-perf-'));
const vault = path.join(tmp, 'Vault');
const words = 'alpha beta gamma delta project roadmap meeting notes idea review plan budget design draft summary weekly sync'.split(' ');
const rnd = (n) => Math.floor(Math.random() * n);
const sentence = () => Array.from({ length: 8 + rnd(10) }, () => words[rnd(words.length)]).join(' ');
for (let i = 0; i < N; i++) {
const folder = path.join(vault, `Area ${i % 40}`);
fs.mkdirSync(folder, { recursive: true });
let body = `# Note ${i}\n\n`;
for (let p = 0; p < 6; p++) body += sentence() + (rnd(3) === 0 ? ` [[Note ${rnd(N)}]]` : '') + '.\n\n';
fs.writeFileSync(path.join(folder, `Note ${i}.md`), `---\ntags: [t${i % 50}]\n---\n\n${body}`);
}
for (let a = 0; a < 40; a++) fs.writeFileSync(path.join(vault, `Area ${a}.md`), `Area ${a}\n`);
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Area 1', theme: 'dark', bounds: { width: 1300, height: 850 } }));
const started = Date.now();
require('../electron/main.cjs');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
app.whenReady().then(async () => {
let win;
for (let i = 0; i < 200 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(50);
const wc = win.webContents;
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
const report = (label, ms, extra = '') => console.log(`${label.padEnd(40)} ${String(Math.round(ms)).padStart(6)} ms ${extra}`);
// frames the page could not draw: longest gap between animation frames over a period
const jank = (ms) => js(`let last = performance.now(), worst = 0; const end = last + ${ms}; await new Promise((res) => { const f = (t) => { worst = Math.max(worst, t - last); last = t; t < end ? requestAnimationFrame(f) : res(); }; requestAnimationFrame(f); }); return worst`);
const timeIn = (code) => js(`const t = performance.now(); ${code}; return performance.now() - t`);
try {
for (let i = 0; i < 300; i++) { if (await js(`return !!document.querySelector('.tree .row')`)) break; await sleep(100); }
report('launch to sidebar shown', Date.now() - started);
report('sidebar rows (folded)', 0, String(await js(`return document.querySelectorAll('.tree .row').length`)));
// open every top-level folder
const t0 = Date.now();
await js(`for (const r of document.querySelectorAll('.tree > .node > .row')) { r.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); }`);
await sleep(200);
report('open all 40 folders', Date.now() - t0, 'rows now: ' + (await js(`return document.querySelectorAll('.tree .row').length`)));
report('worst frame gap after that (1s)', await jank(1000));
// open a page that has many backlinks / mentions
const o = Date.now();
await js(`document.querySelector('.tree .row[data-id="Area 2/Note 2"]')?.click()`);
for (let i = 0; i < 100; i++) { if ((await js(`return document.querySelector('.title')?.textContent`)) === 'Note 2') break; await sleep(20); }
report('open a page', Date.now() - o);
// search
const s = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Search"]').click()`);
await sleep(100);
await js(`const i = document.querySelector('.pal-top input'); i.value = 'roadmap budget'; i.dispatchEvent(new Event('input', { bubbles: true }))`);
for (let i = 0; i < 100; i++) { if (await js(`return document.querySelectorAll('.pal-list .pi').length > 3`)) break; await sleep(20); }
report('search shows results', Date.now() - s);
await js(`document.querySelector('.overlay')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))`);
await sleep(400);
// graph
const g = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Graph view"]').click()`);
for (let i = 0; i < 200; i++) { if (await js(`return !!document.querySelector('canvas, .graph svg, .gv')`)) break; await sleep(25); }
report('graph view appears', Date.now() - g);
report('worst frame gap while it settles (3s)', await jank(3000));
await js(`document.querySelector('.sidebar button[aria-label="Graph view"]').click()`);
await sleep(500);
// tasks list
const k = Date.now();
await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`);
for (let i = 0; i < 100; i++) { if (await js(`return !document.querySelector('.tasks-panel .trash-empty') || false`)) break; await sleep(20); }
report('tasks dialog', Date.now() - k);
} catch (err) {
console.log('harness', err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
app.exit(0);
});
+18
View File
@@ -0,0 +1,18 @@
const { run } = require('node:test');
const { spec } = require('node:test/reporters');
const fs = require('fs');
const path = require('path');
const { app } = require('electron');
app.whenReady().then(async () => {
const dir = path.join(__dirname, '../test');
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.test.js'))
.map(f => path.join(dir, f));
const stream = run({ files });
stream.on('test:fail', () => { process.exitCode = 1; });
stream.compose(new spec()).pipe(process.stdout);
stream.on('end', () => app.quit());
});