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:
1 parent
437887207c
commit
0b5f8bfa8b
27 files changed
+2050
No files matched your search
@@ -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 });
|
||||
})();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
Executable
+16
@@ -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/"
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
Reference in new issue
Block a user