diff --git a/.github/workflows/aur-check.yml b/.github/workflows/aur-check.yml index fbfe1e38..2f70d028 100644 --- a/.github/workflows/aur-check.yml +++ b/.github/workflows/aur-check.yml @@ -25,7 +25,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 - - name: Build the package + - name: Set up build user run: | set -euxo pipefail # makepkg refuses to run as root — build as an unprivileged user with @@ -35,11 +35,37 @@ jobs: install -d -o builder -g builder /tmp/build cp packaging/aur/PKGBUILD packaging/aur/.SRCINFO /tmp/build/ chown -R builder:builder /tmp/build + + # The PKGBUILD always points at the upcoming release's tarball, which does + # not exist until that release is published. Skip the full build (which has + # to download it) when the asset isn't up yet, rather than failing — the + # metadata + .SRCINFO are still checked, and the real build runs once the + # release exists. + - name: Check whether the release asset is published + id: asset + run: | + set -uo pipefail + # shellcheck disable=SC1091 + source packaging/aur/PKGBUILD + url="${source[0]#*::}" + echo "url=$url" + if curl -fsIL -o /dev/null "$url"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice title=AUR build skipped::Release asset not published yet ($url). PKGBUILD metadata + .SRCINFO are still validated; the full makepkg build runs once the release exists." + fi + + - name: Build the package + if: steps.asset.outputs.exists == 'true' + run: | + set -euxo pipefail cd /tmp/build sudo -u builder makepkg -s --noconfirm --noprogressbar ls -la ./*.pkg.tar.* - name: Lint (non-blocking) + if: steps.asset.outputs.exists == 'true' run: | cd /tmp/build namcap PKGBUILD || true @@ -47,6 +73,7 @@ jobs: - name: Verify .SRCINFO is in sync run: | + set -euxo pipefail cd /tmp/build sudo -u builder makepkg --printsrcinfo > .SRCINFO.generated if ! diff -u .SRCINFO .SRCINFO.generated; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 384634d7..56c06988 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,7 +103,7 @@ jobs: files+=("$file") done < <( find dist -maxdepth 1 -type f \ - \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.pacman' -o -name '*.blockmap' -o -name 'latest*.yml' -o -name 'latest*.yaml' \) + \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.pacman' -o -name '*.tar.gz' -o -name '*.blockmap' -o -name 'latest*.yml' -o -name 'latest*.yaml' \) ) if [ "${#files[@]}" -eq 0 ]; then echo "No release artifacts found in dist/" >&2 diff --git a/.gitignore b/.gitignore index 32b1644b..23a47f88 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ apps/server/bin data/ /vault/ /remotevault/ +# Nix build outputs +result +result-* +# Release notes / launch copy are kept local only, never committed. +docs/releases/ diff --git a/README.md b/README.md index 916afb91..b51fb8ca 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Pick whatever suits your distro: ```sh sudo apt install ./ZenNotes--linux-amd64.deb ``` +- **Nix / NixOS:** + Read [packaging/nix/README.md](packaging/nix/README.md) for installation instructions - **Any distro — AppImage:** ```sh chmod +x ZenNotes--linux-x86_64.AppImage @@ -141,14 +143,29 @@ System folders still exist, but the vault model is more flexible now: The built-in folder labels are also customizable in the UI without changing the underlying internal ids. -### Daily notes +### Daily and weekly notes -Daily notes are optional and can be enabled from Settings. +Daily and weekly notes are optional and can be enabled from Settings. -- when enabled, ZenNotes can open or create today's note automatically -- the title is a simple ISO date like `2026-04-21` -- daily notes live in a dedicated directory under your primary notes area -- the default directory is `Daily Notes` +- when enabled, ZenNotes can open or create today's daily note and this week's weekly note automatically +- the default daily title is a simple ISO date like `2026-04-21` +- the default weekly title is an ISO week title like `2026-W24` +- date notes live in dedicated directories under your primary notes area +- the default directories are `Daily Notes` and `Weekly Notes` +- the directory and title can use date patterns such as `yyyy/MM-MMM` and `yyyy-MM-dd-EEE` +- weekly title patterns can use ISO week tokens such as `yyyy-'W'ww` +- localized month and weekday names can follow `system`, `en-US`, `pt-BR`, or another BCP 47 locale + +Supported date note pattern tokens: + +- `yyyy` / `yy` for `2026` / `26` +- `M` / `MM` / `MMM` / `MMMM` for `6` / `06` / `Jun` / `June` +- `d` / `dd` for `9` / `09` +- `EEE` / `EEEE` for `Tue` / `Tuesday` +- `w` / `ww` for ISO week numbers like `24` +- single-quoted literals, such as `'Daily Notes'/yyyy/MM-MMM` + +Weekly notes render date tokens from the ISO week's Monday, and `yyyy` is the ISO week-year for weekly patterns. ### Editor and preview diff --git a/apps/desktop/build/icon.icns b/apps/desktop/build/icon.icns new file mode 100644 index 00000000..f99da412 Binary files /dev/null and b/apps/desktop/build/icon.icns differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ffe54f07..65856622 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.3.0", + "version": "2.4.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -191,6 +191,7 @@ "electronVersion": "41.2.1", "electronUpdaterCompatibility": ">=2.16", "mac": { + "icon": "build/icon.icns", "category": "public.app-category.productivity", "hardenedRuntime": true, "gatekeeperAssess": false, @@ -227,11 +228,28 @@ "target": [ "AppImage", "deb", - "pacman" + "pacman", + "tar.gz" ], "icon": "build/icons", "maintainer": "Adib Hanna ", - "category": "Office" + "category": "Office", + "extraResources": [ + { + "from": "../../packaging/aur/arch-extras/zennotes.desktop", + "to": "arch-extras/zennotes.desktop" + }, + { + "from": "build/icons", + "to": "arch-extras/icons" + } + ], + "extraFiles": [ + { + "from": "../../LICENSE", + "to": "LICENSE" + } + ] }, "deb": { "afterInstall": "build/after-install.sh" diff --git a/apps/desktop/src/main/assets-migrate.test.ts b/apps/desktop/src/main/assets-migrate.test.ts new file mode 100644 index 00000000..81882418 --- /dev/null +++ b/apps/desktop/src/main/assets-migrate.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, mkdir, rm, writeFile, access } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { migrateLooseAssets, listAssets } from './vault' + +const tempDirs: string[] = [] + +async function makeVault(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'zen-amig-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))) +}) + +const exists = (root: string, rel: string): Promise => + access(path.join(root, rel)) + .then(() => true) + .catch(() => false) + +describe('migrateLooseAssets (#185 assets/ unification)', () => { + it('moves root-level attachments + legacy dirs into assets/, never notes or databases', async () => { + const root = await makeVault() + await writeFile(path.join(root, 'photo.png'), 'x', 'utf8') + await writeFile(path.join(root, 'note.md'), '# hi', 'utf8') // a note — must stay + await writeFile(path.join(root, 'books.csv'), 'id\n', 'utf8') // legacy db — must stay + await mkdir(path.join(root, 'attachements'), { recursive: true }) + await writeFile(path.join(root, 'attachements', 'doc.pdf'), 'x', 'utf8') + + const { moved, skipped } = await migrateLooseAssets(root) + expect(moved.sort()).toEqual(['assets/doc.pdf', 'assets/photo.png']) + expect(skipped).toEqual([]) + + expect(await exists(root, 'assets/photo.png')).toBe(true) + expect(await exists(root, 'assets/doc.pdf')).toBe(true) + expect(await exists(root, 'photo.png')).toBe(false) + expect(await exists(root, 'attachements')).toBe(false) // emptied + removed + // Notes and database files are untouched. + expect(await exists(root, 'note.md')).toBe(true) + expect(await exists(root, 'books.csv')).toBe(true) + }) + + it('skips a file whose basename already exists in assets/ (keeps refs unambiguous)', async () => { + const root = await makeVault() + await mkdir(path.join(root, 'assets'), { recursive: true }) + await writeFile(path.join(root, 'assets', 'logo.png'), 'existing', 'utf8') + await writeFile(path.join(root, 'logo.png'), 'loose', 'utf8') + + const { moved, skipped } = await migrateLooseAssets(root) + expect(moved).toEqual([]) + expect(skipped).toEqual(['logo.png']) + expect(await exists(root, 'logo.png')).toBe(true) // left in place + }) + + it('is idempotent and surfaces migrated assets via listAssets', async () => { + const root = await makeVault() + await writeFile(path.join(root, 'pic.png'), 'x', 'utf8') + expect((await migrateLooseAssets(root)).moved).toEqual(['assets/pic.png']) + expect((await migrateLooseAssets(root)).moved).toEqual([]) + + const assets = await listAssets(root) + expect(assets.map((a) => a.path)).toContain('assets/pic.png') + }) +}) diff --git a/apps/desktop/src/main/databases-migrate.test.ts b/apps/desktop/src/main/databases-migrate.test.ts new file mode 100644 index 00000000..ebd0640b --- /dev/null +++ b/apps/desktop/src/main/databases-migrate.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, mkdir, rm, writeFile, readFile, access } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { migrateLegacyDatabases } from './vault' +import { readDatabase } from './databases' + +const tempDirs: string[] = [] + +async function makeVault(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'zen-dbmig-')) + tempDirs.push(dir) + await mkdir(path.join(dir, 'inbox'), { recursive: true }) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))) +}) + +const exists = (p: string): Promise => + access(p) + .then(() => true) + .catch(() => false) + +// A minimal legacy database: loose `.csv` + co-located sidecar, with one +// record-page note under the per-database `/` folder. +async function seedLegacyDatabase(root: string): Promise { + await writeFile(path.join(root, 'inbox', 'Books.csv'), 'id,Title\nr1,Dune\n', 'utf8') + await mkdir(path.join(root, 'inbox', 'Books'), { recursive: true }) + await writeFile(path.join(root, 'inbox', 'Books', 'Dune.md'), '# Dune\n\nGreat book.', 'utf8') + const sidecar = { + version: 1, + idFieldId: 'f_id', + fields: [ + { id: 'f_id', name: 'id', type: 'text', hidden: true }, + { id: 'f_title', name: 'Title', type: 'text' } + ], + views: [{ id: 'v1', name: 'Table', type: 'table', filters: [], sorts: [] }], + activeViewId: 'v1', + pages: { r1: 'inbox/Books/Dune.md' } + } + await writeFile( + path.join(root, 'inbox', 'Books.csv.base.json'), + JSON.stringify(sidecar, null, 2), + 'utf8' + ) +} + +describe('migrateLegacyDatabases (#185 .base reorg)', () => { + it('moves a legacy database into a self-contained .base folder', async () => { + const root = await makeVault() + await seedLegacyDatabase(root) + + const count = await migrateLegacyDatabases(root) + expect(count).toBe(1) + + const baseDir = path.join(root, 'inbox', 'Books.base') + expect(await exists(path.join(baseDir, 'data.csv'))).toBe(true) + expect(await exists(path.join(baseDir, 'schema.json'))).toBe(true) + expect(await exists(path.join(baseDir, 'Dune.md'))).toBe(true) + + // Old files + per-database folder are gone. + expect(await exists(path.join(root, 'inbox', 'Books.csv'))).toBe(false) + expect(await exists(path.join(root, 'inbox', 'Books.csv.base.json'))).toBe(false) + expect(await exists(path.join(root, 'inbox', 'Books'))).toBe(false) + + // schema.json stores the page path RELATIVE to the folder. + const schema = JSON.parse(await readFile(path.join(baseDir, 'schema.json'), 'utf8')) + expect(schema.pages).toEqual({ r1: 'Dune.md' }) + + // data.csv content is preserved. + expect(await readFile(path.join(baseDir, 'data.csv'), 'utf8')).toContain('Dune') + }) + + it('readDatabase resolves the migrated db and its pages to full paths', async () => { + const root = await makeVault() + await seedLegacyDatabase(root) + await migrateLegacyDatabases(root) + + const doc = await readDatabase(root, 'inbox/Books.base/data.csv') + expect(doc.title).toBe('Books') + expect(doc.rows).toHaveLength(1) + // pages resolved back to full vault-relative paths on read. + expect(doc.pages?.r1).toBe('inbox/Books.base/Dune.md') + }) + + it('is idempotent — a second run migrates nothing and leaves data intact', async () => { + const root = await makeVault() + await seedLegacyDatabase(root) + expect(await migrateLegacyDatabases(root)).toBe(1) + expect(await migrateLegacyDatabases(root)).toBe(0) + expect(await exists(path.join(root, 'inbox', 'Books.base', 'data.csv'))).toBe(true) + }) + + it('leaves a plain CSV (no sidecar) untouched', async () => { + const root = await makeVault() + await writeFile(path.join(root, 'inbox', 'data-dump.csv'), 'a,b\n1,2\n', 'utf8') + expect(await migrateLegacyDatabases(root)).toBe(0) + expect(await exists(path.join(root, 'inbox', 'data-dump.csv'))).toBe(true) + expect(await exists(path.join(root, 'inbox', 'data-dump.base'))).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/databases-walk.test.ts b/apps/desktop/src/main/databases-walk.test.ts new file mode 100644 index 00000000..8ecfa9bf --- /dev/null +++ b/apps/desktop/src/main/databases-walk.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createDatabase, createRecordPage } from './databases' +import { listFolders, listNotes, listAssets } from './vault' + +const tempDirs: string[] = [] + +async function makeVault(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'zen-dbwalk-')) + tempDirs.push(dir) + await mkdir(path.join(dir, 'inbox'), { recursive: true }) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))) +}) + +describe('vault walk treats a .base database as one unit (#185)', () => { + it('surfaces the database folder + record pages, hides data.csv/schema.json', async () => { + const root = await makeVault() + const doc = await createDatabase(root, 'inbox', '', 'Reading List') + expect(doc.path).toBe('inbox/Reading List.base/data.csv') + await createRecordPage(root, doc.path, 'Dune', '# Dune') + + // The database appears as a folder node (the sidebar renders it as a database). + const folderEntries = await listFolders(root) + expect(folderEntries.some((f) => f.subpath === 'Reading List.base')).toBe(true) + + // The record page is a note nested under the database folder. + const notes = await listNotes(root) + const page = notes.find((n) => n.path === 'inbox/Reading List.base/Dune.md') + expect(page).toBeTruthy() + expect(page?.folder).toBe('inbox') + + // data.csv and schema.json never appear as assets or notes. + const assets = await listAssets(root) + expect(assets.some((a) => a.path.includes('.base'))).toBe(false) + expect(notes.some((n) => n.path.endsWith('data.csv') || n.path.endsWith('schema.json'))).toBe( + false + ) + // The database folder is NOT descended into as ordinary subfolders. + expect(folderEntries.some((f) => f.subpath.startsWith('Reading List.base/'))).toBe(false) + }) +}) diff --git a/apps/desktop/src/main/databases.test.ts b/apps/desktop/src/main/databases.test.ts index 8894201b..205db6cf 100644 --- a/apps/desktop/src/main/databases.test.ts +++ b/apps/desktop/src/main/databases.test.ts @@ -2,7 +2,13 @@ import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { createDatabase, createRecordPage, readDatabase, writeDatabaseRows } from './databases' +import { + createDatabase, + createRecordPage, + readDatabase, + renameDatabase, + writeDatabaseRows +} from './databases' const tmpDirs: string[] = [] async function makeVault(): Promise { @@ -17,19 +23,19 @@ afterEach(async () => { }) describe('createDatabase + readDatabase', () => { - it('creates a .csv + sidecar and reads it back', async () => { + it('creates a .base folder (data.csv + schema.json) and reads it back', async () => { const root = await makeVault() const doc = await createDatabase(root, 'inbox', '', 'Projects') - expect(doc.path.endsWith('Projects.csv')).toBe(true) + expect(doc.path).toBe('inbox/Projects.base/data.csv') expect(doc.title).toBe('Projects') expect(doc.fields.map((f) => f.name)).toEqual(['id', 'Name']) expect(doc.rows).toEqual([]) expect(doc.views).toHaveLength(1) - // both files exist on disk + // both files exist inside the .base folder on disk await expect(readFile(path.join(root, doc.path), 'utf8')).resolves.toContain('id,Name') const sidecar = JSON.parse( - await readFile(path.join(root, `${doc.path}.base.json`), 'utf8') + await readFile(path.join(root, 'inbox/Projects.base/schema.json'), 'utf8') ) expect(sidecar.version).toBe(1) expect(sidecar.fields).toHaveLength(2) @@ -67,7 +73,7 @@ describe('writeDatabaseRows round-trip', () => { }) describe('createRecordPage', () => { - it('creates a page note in a per-database folder', async () => { + it('creates a record-page note inside the database .base folder', async () => { const root = await makeVault() const doc = await createDatabase(root, 'inbox', '', 'Projects') const noteRel = await createRecordPage( @@ -76,12 +82,31 @@ describe('createRecordPage', () => { 'My Task', '---\nName: My Task\n---\n# My Task\n' ) - expect(noteRel.endsWith('.md')).toBe(true) - expect(noteRel).toContain('Projects/') // pages folder named after the db + expect(noteRel).toBe('inbox/Projects.base/My Task.md') // record pages live in the .base folder await expect(readFile(path.join(root, noteRel), 'utf8')).resolves.toContain('# My Task') }) }) +describe('renameDatabase', () => { + it('renames the .base folder, preserving data, schema, and record pages', async () => { + const root = await makeVault() + const doc = await createDatabase(root, 'inbox', '', 'Old') + await createRecordPage(root, doc.path, 'Rec', '# Rec') + + const newPath = await renameDatabase(root, doc.path, 'New Name') + expect(newPath).toBe('inbox/New Name.base/data.csv') + + const reopened = await readDatabase(root, newPath) + expect(reopened.title).toBe('New Name') + // The record page moved with the folder. + await expect( + readFile(path.join(root, 'inbox/New Name.base/Rec.md'), 'utf8') + ).resolves.toContain('# Rec') + // The old folder is gone. + await expect(readFile(path.join(root, 'inbox/Old.base/data.csv'), 'utf8')).rejects.toThrow() + }) +}) + describe('adopting a plain CSV (no sidecar)', () => { it('infers schema, materializes the sidecar + stable ids, and is stable on re-read', async () => { const root = await makeVault() diff --git a/apps/desktop/src/main/databases.ts b/apps/desktop/src/main/databases.ts index e2f82249..06fea523 100644 --- a/apps/desktop/src/main/databases.ts +++ b/apps/desktop/src/main/databases.ts @@ -1,8 +1,9 @@ /** * Main-process IO for CSV-backed databases. Bridges the pure shared-domain - * logic (@shared/database-csv) to disk, mirroring the comments/tasks helpers: - * read/write the `.csv` + co-located `.csv.base.json` sidecar with the atomic - * writer, and adopt a plain CSV (no sidecar) by inferring its schema. + * logic (@shared/database-csv) to disk. A database is a self-contained + * `.base/` folder holding `data.csv`, `schema.json` (the sidecar), and a + * `pages/` folder of record-page notes; a legacy loose `.csv` + co-located + * `.csv.base.json` sidecar is still read (and migrated elsewhere on open). */ import { promises as fs } from 'node:fs' import path from 'node:path' @@ -15,7 +16,12 @@ import { serializeRows } from '@shared/database-csv' import { + csvPathForFormDir, DATABASE_SIDECAR_SUFFIX, + FORM_DIR_SUFFIX, + formDirFromCsvPath, + formTitleFromCsvPath, + isFormDirName, type DatabaseDoc, type DatabaseSidecar, type DatabaseSummary, @@ -39,11 +45,36 @@ function toPosix(p: string): string { return p.replace(/\\/g, '/') } +/** Database title: its `.base` folder name (legacy: the `.csv` basename). */ function titleFromPath(rel: string): string { - const base = toPosix(rel).split('/').filter(Boolean).pop() ?? rel + const posix = toPosix(rel) + if (formDirFromCsvPath(posix)) return formTitleFromCsvPath(posix) + const base = posix.split('/').filter(Boolean).pop() ?? rel return base.replace(/\.csv$/i, '') } +// Record-page paths are stored RELATIVE to the database folder (e.g. `pages/X.md`) +// so a folder rename/move never rewrites them; on read they're resolved to full +// vault-relative paths (e.g. `Books.base/pages/X.md`). Legacy loose databases +// have no folder, so paths pass through unchanged. +function pagesToFull(csvRel: string, pages: Record): Record { + const formDir = formDirFromCsvPath(toPosix(csvRel)) + if (!formDir) return pages + const prefix = `${formDir}/` + return Object.fromEntries( + Object.entries(pages).map(([id, p]) => [id, p.startsWith(prefix) ? p : `${prefix}${p}`]) + ) +} + +function pagesToRelative(csvRel: string, pages: Record): Record { + const formDir = formDirFromCsvPath(toPosix(csvRel)) + if (!formDir) return pages + const prefix = `${formDir}/` + return Object.fromEntries( + Object.entries(pages).map(([id, p]) => [id, p.startsWith(prefix) ? p.slice(prefix.length) : p]) + ) +} + function isMissing(err: unknown): boolean { return (err as NodeJS.ErrnoException)?.code === 'ENOENT' } @@ -84,7 +115,9 @@ function normalizeSidecar(raw: unknown): DatabaseSidecar | null { async function readSidecar(root: string, rel: string): Promise { try { const raw = await fs.readFile(databaseSidecarPath(root, rel), 'utf8') - return normalizeSidecar(JSON.parse(raw)) + const sidecar = normalizeSidecar(JSON.parse(raw)) + if (sidecar?.pages) sidecar.pages = pagesToFull(toPosix(rel), sidecar.pages) + return sidecar } catch (err) { if (isMissing(err)) return null if (err instanceof SyntaxError) return null @@ -135,7 +168,12 @@ async function readPageContentFlags( } async function persistSidecar(root: string, rel: string, sidecar: DatabaseSidecar): Promise { - await writeFileAtomic(databaseSidecarPath(root, rel), `${JSON.stringify(sidecar, null, 2)}\n`) + // Store record-page paths relative to the database folder (so a folder rename + // never rewrites them); a legacy loose database has no folder → unchanged. + const onDisk: DatabaseSidecar = sidecar.pages + ? { ...sidecar, pages: pagesToRelative(toPosix(rel), sidecar.pages) } + : sidecar + await writeFileAtomic(databaseSidecarPath(root, rel), `${JSON.stringify(onDisk, null, 2)}\n`) } // --------------------------------------------------------------------------- @@ -224,18 +262,20 @@ export async function createDatabase( const cleanSub = toPosix(subpath).replace(/^\/+|\/+$/g, '') const dirAbs = cleanSub ? path.join(topAbs, cleanSub) : topAbs const dirRel = toPosix(path.relative(root, dirAbs)) - const makeRel = (name: string): string => (dirRel ? `${dirRel}/${name}.csv` : `${name}.csv`) - // Resolve a non-colliding path under the directory. - let rel = makeRel(baseName) + const makeFormDir = (name: string): string => + dirRel ? `${dirRel}/${name}${FORM_DIR_SUFFIX}` : `${name}${FORM_DIR_SUFFIX}` + // Resolve a non-colliding `.base` folder under the directory. + let formDirRel = makeFormDir(baseName) let n = 2 for (;;) { try { - await fs.access(databaseDataPath(root, rel)) - rel = makeRel(`${baseName} ${n++}`) + await fs.access(databaseDataPath(root, formDirRel)) + formDirRel = makeFormDir(`${baseName} ${n++}`) } catch { break } } + const rel = csvPathForFormDir(formDirRel) const idField: DbField = { id: randomUUID(), name: 'id', type: 'text', hidden: true } const nameField: DbField = { id: randomUUID(), name: 'Name', type: 'text' } @@ -248,15 +288,18 @@ export async function createDatabase( views, activeViewId } + // Create the folder, then the two data files. + await fs.mkdir(databaseDataPath(root, formDirRel), { recursive: true }) await persistSidecar(root, rel, sidecar) await writeFileAtomic(databaseDataPath(root, rel), serializeRows([], fields)) return hydrate(rel, sidecar, []) } /** - * Create a "page" note for a database record under a per-database folder - * (`//.md`) with the given pre-composed body, and return - * its vault-relative path. The pages folder sits next to the `.csv`. + * Create a "page" note for a database record inside the database folder + * (`<Name>.base/<title>.md`) with the given pre-composed body, and return its + * vault-relative path. Record pages live directly in the `.base` folder so they + * nest under the database in the sidebar. */ export async function createRecordPage( root: string, @@ -264,20 +307,62 @@ export async function createRecordPage( title: string, body: string ): Promise<string> { - const posix = toPosix(csvPath) - const slash = posix.lastIndexOf('/') - const dbDir = slash >= 0 ? posix.slice(0, slash) : '' - const dbName = (slash >= 0 ? posix.slice(slash + 1) : posix).replace(/\.csv$/i, '') - const pagesDirRel = dbDir ? `${dbDir}/${dbName}` : dbName - const dirAbs = databaseDataPath(root, pagesDirRel) // resolveSafe + posix + const formDir = formDirFromCsvPath(toPosix(csvPath)) + if (!formDir) throw new Error(`Not a database folder: ${csvPath}`) + const dirAbs = databaseDataPath(root, formDir) // resolveSafe + posix await fs.mkdir(dirAbs, { recursive: true }) const finalTitle = await uniqueTitle(dirAbs, sanitizeNoteTitle(title)) - const noteRel = `${pagesDirRel}/${finalTitle}.md` + const noteRel = `${formDir}/${finalTitle}.md` await fs.writeFile(databaseDataPath(root, noteRel), body, 'utf8') return noteRel } -/** List `.csv` databases in the vault (skips sidecars, trash, and `.zennotes`). */ +/** + * Permanently delete a database: remove its entire `<Name>.base` folder + * (data.csv, schema.json, and the `pages/` record notes) in one shot. + */ +export async function deleteDatabase(root: string, csvRel: string): Promise<void> { + const formDir = formDirFromCsvPath(toPosix(csvRel)) + if (!formDir) throw new Error(`Not a database folder: ${csvRel}`) + await fs.rm(databaseDataPath(root, formDir), { recursive: true, force: true }) +} + +/** + * Rename a database in place: rename its `<oldName>.base` folder to + * `<newName>.base` (non-colliding). Returns the new `data.csv` path. Because the + * data, schema, and pages all live inside, nothing else needs rewriting. + */ +export async function renameDatabase( + root: string, + csvRel: string, + newTitle: string +): Promise<string> { + const formDir = formDirFromCsvPath(toPosix(csvRel)) + if (!formDir) throw new Error(`Not a database folder: ${csvRel}`) + const parentRel = formDir.includes('/') ? formDir.slice(0, formDir.lastIndexOf('/')) : '' + const safeName = (newTitle.trim() || 'Untitled Database').replace(/[\\/:*?"<>|]/g, '-') + const makeFormDir = (name: string): string => + parentRel ? `${parentRel}/${name}${FORM_DIR_SUFFIX}` : `${name}${FORM_DIR_SUFFIX}` + let targetRel = makeFormDir(safeName) + if (toPosix(targetRel) === toPosix(formDir)) return csvRel + let n = 2 + for (;;) { + try { + await fs.access(databaseDataPath(root, targetRel)) + targetRel = makeFormDir(`${safeName} ${n++}`) + } catch { + break + } + } + await fs.rename(databaseDataPath(root, formDir), databaseDataPath(root, targetRel)) + return csvPathForFormDir(targetRel) +} + +/** + * List databases in the vault: each `<Name>.base` folder (surfaced as its + * `data.csv` path), plus any legacy loose `.csv` not yet migrated. Skips + * `.base` internals, trash, and `.zennotes`. + */ export async function listDatabases(root: string): Promise<DatabaseSummary[]> { const out: DatabaseSummary[] = [] const walk = async (dirRel: string): Promise<void> => { @@ -293,11 +378,18 @@ export async function listDatabases(root: string): Promise<DatabaseSummary[]> { if (name.startsWith('.') || name === 'trash' || name === 'node_modules') continue const childRel = dirRel ? `${dirRel}/${name}` : name if (entry.isDirectory()) { + if (isFormDirName(name)) { + // A database folder — surface it, don't descend into its internals. + const rel = csvPathForFormDir(childRel) + out.push({ path: toPosix(rel), title: titleFromPath(rel) }) + continue + } await walk(childRel) } else if ( name.toLowerCase().endsWith('.csv') && !name.toLowerCase().endsWith(`.csv${DATABASE_SIDECAR_SUFFIX}`) ) { + // Legacy loose `.csv` (pre-migration). out.push({ path: toPosix(childRel), title: titleFromPath(childRel) }) } } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f9d36958..8bf31274 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -45,6 +45,7 @@ import { archiveNote, createFolder, createNote, + createExcalidraw, deleteAsset, DEFAULT_QUICK_CAPTURE_HOTKEY, deleteFolder, @@ -82,6 +83,7 @@ import { searchVaultTextCapabilities, searchVaultText, setVaultSettings, + rootContentHiddenByInboxMode, type PersistedRemoteWorkspaceConfig, type PersistedRemoteWorkspaceProfile, type PersistedWindowState, @@ -110,6 +112,7 @@ import { writeDatabaseRows, writeDatabaseSchema, createDatabase, + renameDatabase, createRecordPage, listDatabases } from './databases' @@ -391,7 +394,12 @@ function findWindowForVaultRoot(root: string): BrowserWindow | null { function queueMarkdownFileOpen(rawPath: string, reuseMainWindow: boolean): void { pendingFileOpens.push({ absPath: path.resolve(rawPath), reuseMainWindow }) - if (app.isReady()) void flushPendingFileOpens() + // Only flush eagerly once startup is finished. During startup `app.isReady()` + // is already true (we're inside whenReady), so an eager flush here would + // drain the queue before whenReady's own flush runs — that flush would then + // report "nothing opened" and open a redundant default window alongside the + // file's window, so `zen open` (and double-clicking a .md) opened two. (#178) + if (app.isReady() && appStartupComplete) void flushPendingFileOpens() } function handleStartupMarkdownArgs(argv: string[], reuseMainWindow: boolean): void { @@ -2003,6 +2011,13 @@ function registerIpc(): void { return await setVaultSettings(v.root, next) }) + handle(IPC.VAULT_ROOT_CONTENT_HIDDEN, async () => { + // Local-vault only: a remote workspace manages its own layout server-side. + if (isRemoteWorkspaceActive()) return false + const v = requireVault() + return await rootContentHiddenByInboxMode(v.root) + }) + handle(IPC.VAULT_LIST_NOTES, async () => { if (isRemoteWorkspaceActive()) return await requireRemoteWorkspaceClient().listNotes() const v = requireVault() @@ -2176,7 +2191,17 @@ function registerIpc(): void { handle(IPC.VAULT_OPEN_DATABASE, async (_e, relPath: string) => { ensureLocalForDatabases() - return await readDatabase(requireVault().root, relPath) + try { + return await readDatabase(requireVault().root, relPath) + } catch (err) { + // A missing database isn't exceptional — its tab can simply outlive the + // file (deleted by us or another client). Return null so the renderer + // forgets it, instead of rejecting and logging a noisy + // "Error occurred in handler for 'vault:open-database'". Real errors + // (parse/permission) still throw. + if (err instanceof Error && err.message.startsWith('Database not found')) return null + throw err + } }) handle(IPC.VAULT_WRITE_DATABASE_ROWS, async (_e, relPath: string, rows: DbRow[]) => { @@ -2200,6 +2225,11 @@ function registerIpc(): void { } ) + handle(IPC.VAULT_RENAME_DATABASE, async (_e, csvPath: string, newTitle: string) => { + ensureLocalForDatabases() + return await renameDatabase(requireVault().root, csvPath, newTitle) + }) + handle( IPC.VAULT_CREATE_RECORD_PAGE, async (_e, csvPath: string, title: string, body: string) => { @@ -2254,6 +2284,17 @@ function registerIpc(): void { } ) + handle( + IPC.VAULT_CREATE_EXCALIDRAW, + async (_e, folder: NoteFolder, subpath: string = '', title?: string) => { + if (isRemoteWorkspaceActive()) { + return await requireRemoteWorkspaceClient().createExcalidraw(folder, subpath, title) + } + const v = requireVault() + return await createExcalidraw(v.root, folder, subpath, title) + } + ) + handle(IPC.VAULT_RENAME_NOTE, async (_e, relPath: string, nextTitle: string) => { if (isRemoteWorkspaceActive()) { return await requireRemoteWorkspaceClient().renameNote(relPath, nextTitle) @@ -2755,6 +2796,7 @@ function ensureQuickCaptureWindow(): BrowserWindow { height: 340, minWidth: 460, minHeight: 260, + title: 'ZenNotes Quick Capture', show: false, frame: false, titleBarStyle: mac ? 'hiddenInset' : 'hidden', @@ -2992,10 +3034,12 @@ function installAppMenu(): void { { role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, - { role: 'toggleTabBar' }, - { role: 'selectNextTab' }, - { role: 'selectPreviousTab' }, - { role: 'mergeAllWindows' }, + // Electron 41 leaves these macOS tab roles unlabeled unless the + // template supplies text, which renders as blank Window menu rows. + { role: 'toggleTabBar', label: 'Toggle Tab Bar' }, + { role: 'selectNextTab', label: 'Show Next Tab' }, + { role: 'selectPreviousTab', label: 'Show Previous Tab' }, + { role: 'mergeAllWindows', label: 'Merge All Windows' }, { type: 'separator' }, { role: 'front' } ] diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index cacfd990..c64dc5e2 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -147,6 +147,13 @@ export class RemoteServerClient { }) } + async createExcalidraw(folder: NoteFolder, subpath = '', title?: string): Promise<NoteMeta> { + return this.jsonRequest<NoteMeta>('/api/excalidraw/create', { + method: 'POST', + body: { folder, subpath, title } + }) + } + async renameNote(relPath: string, nextTitle: string): Promise<NoteMeta> { return this.jsonRequest<NoteMeta>('/api/notes/rename', { method: 'POST', diff --git a/apps/desktop/src/main/tasklists.test.ts b/apps/desktop/src/main/tasklists.test.ts index a5ae07b3..634c200e 100644 --- a/apps/desktop/src/main/tasklists.test.ts +++ b/apps/desktop/src/main/tasklists.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest' import { + extractUncheckedTaskBlocks, + removeTaskAtIndex, + takeTaskLineAtIndex, setTaskCheckedAtIndex, setTaskDueAtIndex, setTaskPriorityAtIndex, + setTaskTextAtIndex, setTaskWaitingAtIndex, toggleTaskAtIndex } from '@shared/tasklists' @@ -119,3 +123,91 @@ describe('setTaskDueAtIndex', () => { ) }) }) + +describe('extractUncheckedTaskBlocks', () => { + it('pulls out only unchecked tasks, leaving checked + prose behind', () => { + const md = ['# 2026-06-16', '', '- [x] shipped', '- [ ] follow up', 'a note line'].join('\n') + const { moved, rest } = extractUncheckedTaskBlocks(md) + expect(moved).toEqual(['- [ ] follow up']) + expect(rest).toBe(['# 2026-06-16', '', '- [x] shipped', 'a note line'].join('\n')) + }) + + it('moves tokens verbatim (future due dates are preserved)', () => { + const md = ['- [ ] plan Q3 due:2026-09-01 !high', '- [x] done'].join('\n') + const { moved } = extractUncheckedTaskBlocks(md) + expect(moved).toEqual(['- [ ] plan Q3 due:2026-09-01 !high']) + }) + + it('carries indented child lines along with their task', () => { + const md = ['- [ ] parent', ' - [ ] child', ' notes', '- [ ] sibling'].join('\n') + const { moved, rest } = extractUncheckedTaskBlocks(md) + expect(moved).toEqual(['- [ ] parent', ' - [ ] child', ' notes', '- [ ] sibling']) + expect(rest).toBe('') + }) + + it('ignores checkboxes inside fenced code blocks', () => { + const md = ['- [ ] real', '```', '- [ ] fenced', '```'].join('\n') + const { moved, rest } = extractUncheckedTaskBlocks(md) + expect(moved).toEqual(['- [ ] real']) + expect(rest).toBe(['```', '- [ ] fenced', '```'].join('\n')) + }) + + it('returns nothing to move when all tasks are checked', () => { + const md = ['- [x] a', '- [x] b'].join('\n') + const { moved, rest } = extractUncheckedTaskBlocks(md) + expect(moved).toEqual([]) + expect(rest).toBe(md) + }) +}) + +describe('setTaskTextAtIndex', () => { + it('replaces the text after the checkbox, preserving the marker + check state', () => { + expect(setTaskTextAtIndex('- [x] old text', 0, 'new text')).toBe('- [x] new text') + expect(setTaskTextAtIndex(' - [ ] a\n - [ ] b', 1, 'edited')).toBe(' - [ ] a\n - [ ] edited') + }) + + it('writes tokens verbatim when included in the new text', () => { + expect(setTaskTextAtIndex('- [ ] a', 0, 'do thing due:2026-07-01 !high')).toBe( + '- [ ] do thing due:2026-07-01 !high' + ) + }) + + it('leaves an empty checkbox when text is blank', () => { + expect(setTaskTextAtIndex('- [ ] something', 0, ' ')).toBe('- [ ]') + }) +}) + +describe('removeTaskAtIndex', () => { + it('deletes the task line at the given index', () => { + expect(removeTaskAtIndex('- [ ] a\n- [ ] b\n- [ ] c', 1)).toBe('- [ ] a\n- [ ] c') + }) + + it('keeps surrounding prose intact', () => { + const md = ['# Day', '', '- [ ] keep', '- [ ] drop', 'a line'].join('\n') + expect(removeTaskAtIndex(md, 1)).toBe(['# Day', '', '- [ ] keep', 'a line'].join('\n')) + }) + + it('does not count checkboxes inside fenced code', () => { + const md = ['```', '- [ ] fenced', '```', '- [ ] real'].join('\n') + expect(removeTaskAtIndex(md, 0)).toBe(['```', '- [ ] fenced', '```'].join('\n')) + }) + + it('returns markdown unchanged for an out-of-range index', () => { + expect(removeTaskAtIndex('- [ ] only', 5)).toBe('- [ ] only') + }) +}) + +describe('takeTaskLineAtIndex', () => { + it('returns the removed line plus the remaining body (for moving a task)', () => { + const md = ['- [ ] a', '- [ ] b due:2026-07-01 !high', '- [ ] c'].join('\n') + const { line, body } = takeTaskLineAtIndex(md, 1) + expect(line).toBe('- [ ] b due:2026-07-01 !high') + expect(body).toBe('- [ ] a\n- [ ] c') + }) + + it('returns a null line and the unchanged body when out of range', () => { + const { line, body } = takeTaskLineAtIndex('- [ ] only', 9) + expect(line).toBeNull() + expect(body).toBe('- [ ] only') + }) +}) diff --git a/apps/desktop/src/main/tasks-domain.test.ts b/apps/desktop/src/main/tasks-domain.test.ts index 4138bb60..4bf50c89 100644 --- a/apps/desktop/src/main/tasks-domain.test.ts +++ b/apps/desktop/src/main/tasks-domain.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { bucketTasksByDueDate, + inferDailyTaskDueDates, parseTasksFromBody, tasksDueOn, toIsoDateLocal, @@ -67,3 +68,44 @@ describe('bucketTasksByDueDate', () => { expect(buckets.get('2026-04-30')?.length).toBe(1) }) }) + +describe('inferDailyTaskDueDates', () => { + const dailyA = parseTasksFromBody('- [ ] a\n- [ ] b due:2026-07-01', { + path: 'inbox/Daily Notes/2026-06-17.md', + title: '2026-06-17', + folder: 'inbox' + }) + const dueByPath = new Map([['inbox/Daily Notes/2026-06-17.md', '2026-06-17']]) + + it('gives undated daily-note tasks the note date, flagged inferred', () => { + const out = inferDailyTaskDueDates(dailyA, dueByPath) + expect(out[0].due).toBe('2026-06-17') + expect(out[0].dueInferred).toBe(true) + }) + + it('never overrides an explicit due token', () => { + const out = inferDailyTaskDueDates(dailyA, dueByPath) + expect(out[1].due).toBe('2026-07-01') + expect(out[1].dueInferred).toBeUndefined() + }) + + it('leaves tasks outside any daily note untouched', () => { + const other = parseTasksFromBody('- [ ] loose', { + path: 'inbox/Loose.md', + title: 'Loose', + folder: 'inbox' + }) + const out = inferDailyTaskDueDates(other, dueByPath) + expect(out[0].due).toBeUndefined() + }) + + it('returns the same array instance when nothing changes', () => { + const out = inferDailyTaskDueDates(dailyA, new Map()) + expect(out).toBe(dailyA) + }) + + it('makes inferred tasks show up in their day bucket', () => { + const out = inferDailyTaskDueDates(dailyA, dueByPath) + expect(tasksDueOn(out, '2026-06-17').length).toBe(1) + }) +}) diff --git a/apps/desktop/src/main/vault-asset-case.test.ts b/apps/desktop/src/main/vault-asset-case.test.ts new file mode 100644 index 00000000..57a6b476 --- /dev/null +++ b/apps/desktop/src/main/vault-asset-case.test.ts @@ -0,0 +1,96 @@ +import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { VaultSettings } from '@shared/ipc' +import { + assetBelongsToFolderView, + assetFolderSubpath, + folderForVaultRelativePath, + noteBelongsToFolderView, + noteFolderSubpath +} from '@renderer/lib/vault-layout' +import { listAssets, listNotes } from './vault' + +// End-to-end reproduction of #186 wiring the REAL main-process vault walk to the +// REAL renderer classifier. The bug: on a case-insensitive filesystem an inbox +// folder stored as `Inbox/` (capital) makes `listNotes` emit lowercase +// `inbox/…` paths (built from `folderRoot()`), while `listAssets` walks real +// directory entries and emits `Inbox/…`. The renderer then classified the +// capital asset paths to `null` and hid every image/PDF. + +const tempDirs: string[] = [] + +async function makeTempVault(): Promise<string> { + const dir = await mkdtemp(path.join(os.tmpdir(), 'zennotes-186-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +const inboxSettings = { primaryNotesLocation: 'inbox' } as VaultSettings + +describe('#186 — capitalized Inbox folder keeps its images/PDFs visible', () => { + it('classifies real listAssets output for a capital Inbox/ into the inbox view', async () => { + const root = await makeTempVault() + // A folder physically named "Inbox" (capital) — exactly the reporter's vault. + await mkdir(path.join(root, 'Inbox'), { recursive: true }) + await writeFile(path.join(root, 'Inbox', 'note.md'), '# hi', 'utf8') + await writeFile(path.join(root, 'Inbox', 'photo.png'), 'x', 'utf8') + await writeFile(path.join(root, 'Inbox', 'doc.pdf'), 'x', 'utf8') + + const assets = await listAssets(root) + const photo = assets.find((a) => a.name === 'photo.png') + const pdf = assets.find((a) => a.name === 'doc.pdf') + + // listAssets preserves the on-disk casing ("Inbox/…"), which is the crux. + expect(photo?.path).toBe('Inbox/photo.png') + expect(pdf?.path).toBe('Inbox/doc.pdf') + + // The fix: the renderer now folds that capital path into the inbox view at + // the top level (subpath ''), instead of returning null and hiding it. + expect(folderForVaultRelativePath(photo!.path, inboxSettings)).toBe('inbox') + expect(folderForVaultRelativePath(pdf!.path, inboxSettings)).toBe('inbox') + expect(assetFolderSubpath(photo!, inboxSettings)).toBe('') + }) + + it('puts a nested capital asset in the same subpath tree as its sibling note', async () => { + const root = await makeTempVault() + await mkdir(path.join(root, 'Inbox', 'Projects'), { recursive: true }) + await writeFile(path.join(root, 'Inbox', 'Projects', 'plan.md'), '# plan', 'utf8') + await writeFile(path.join(root, 'Inbox', 'Projects', 'diagram.png'), 'x', 'utf8') + + const assets = await listAssets(root) + const diagram = assets.find((a) => a.name === 'diagram.png') + + // Both note and asset paths come back with the on-disk capital casing, + // because `realpath` (used for symlink-loop detection in the walk) resolves + // `inbox` → `Inbox` on a case-insensitive filesystem. + expect(diagram?.path).toBe('Inbox/Projects/diagram.png') + expect(folderForVaultRelativePath(diagram!.path, inboxSettings)).toBe('inbox') + expect(assetFolderSubpath(diagram!, inboxSettings)).toBe('Projects') + expect(assetBelongsToFolderView(diagram!, 'inbox', 'Projects', inboxSettings)).toBe(true) + + // On a case-insensitive FS, prove the note half: it shows (folder assigned + // by the walk) AND resolves to the SAME "Projects" subpath as its image, so + // they group together in the file browser instead of splitting apart. + const caseInsensitive = await stat(path.join(root, 'inbox')) + .then(() => true) + .catch(() => false) + if (caseInsensitive) { + const notes = await listNotes(root) + const plan = notes.find((n) => n.title === 'plan') + expect(plan?.folder).toBe('inbox') + expect(plan?.path).toBe('Inbox/Projects/plan.md') + expect(noteFolderSubpath(plan!, inboxSettings)).toBe('Projects') + expect(noteBelongsToFolderView(plan!, 'inbox', 'Projects', inboxSettings)).toBe(true) + // The asset and its sibling note resolve to one and the same node. + expect(noteFolderSubpath(plan!, inboxSettings)).toBe( + assetFolderSubpath(diagram!, inboxSettings) + ) + } + }) +}) diff --git a/apps/desktop/src/main/vault-content-integrity.test.ts b/apps/desktop/src/main/vault-content-integrity.test.ts new file mode 100644 index 00000000..dd8b5665 --- /dev/null +++ b/apps/desktop/src/main/vault-content-integrity.test.ts @@ -0,0 +1,189 @@ +import { mkdtemp, mkdir, rm, readFile, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + getVaultSettings, + listNotes, + migrateLegacyDatabases, + migrateLooseAssets, + readNote, + setVaultSettings, + writeNote +} from './vault' + +// Reproduction harness for #202 ("Notes show the wrong content" → ZenNotes +// overwrote files on disk with another note's content). The reporter pointed +// ZenNotes at an existing Obsidian vault opened in ROOT mode, with nested +// folders and filenames containing SPACES (e.g. `Work/Documentation/Vault CLI +// Cheatsheet.md`), under `~/sync/obsidian` (a likely-symlinked sync dir). +// +// These tests wire the REAL main-process vault I/O end-to-end and assert that a +// note's identity (path) and content never cross-contaminate. + +const tempDirs: string[] = [] + +async function makeTempVault(prefix = 'zennotes-202-'): Promise<string> { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +// The reporter's vault shape: root mode, nested folders, spaces in names, and a +// neighbour in the same folder (the prime cross-contamination suspect). +const FILES: Record<string, string> = { + 'Work/Documentation/Vault CLI Cheatsheet.md': + '---\ntags:\n - "#cheatsheet"\ndate: 242024-11-14\n---\n> [!info] Vault CLI Cheatsheet\nCHEATSHEET BODY\n', + 'Work/Documentation/Another Note.md': '# Another Note\nNEIGHBOUR BODY\n', + 'Work/Projects/plan.md': '# Plan\nPLAN BODY\n', + 'index.md': '# Index\nINDEX BODY\n' +} + +async function buildObsidianVault(root: string, files: Record<string, string> = FILES): Promise<void> { + for (const [rel, body] of Object.entries(files)) { + const abs = path.join(root, ...rel.split('/')) + await mkdir(path.dirname(abs), { recursive: true }) + await writeFile(abs, body, 'utf8') + } + // The `.obsidian/` dir is what marks this as an existing Obsidian vault. + await mkdir(path.join(root, '.obsidian'), { recursive: true }) + const base = await getVaultSettings(root) + await setVaultSettings(root, { ...base, primaryNotesLocation: 'root' }) +} + +async function readDiskBody(root: string, rel: string): Promise<string> { + return await readFile(path.join(root, ...rel.split('/')), 'utf8') +} + +describe('#202 — note content/path integrity (root-mode Obsidian vault, spaces in names)', () => { + it('listNotes emits one correct path per file, no dupes or collisions', async () => { + const root = await makeTempVault() + await buildObsidianVault(root) + + const notes = await listNotes(root) + const paths = notes.map((n) => n.path).sort() + + expect(paths).toEqual([ + 'Work/Documentation/Another Note.md', + 'Work/Documentation/Vault CLI Cheatsheet.md', + 'Work/Projects/plan.md', + 'index.md' + ]) + // No two notes share a path (the collision that would cross-wire content). + expect(new Set(paths).size).toBe(paths.length) + }) + + it('readNote returns each note its OWN body — never a neighbour’s', async () => { + const root = await makeTempVault() + await buildObsidianVault(root) + + for (const [rel, expected] of Object.entries(FILES)) { + const content = await readNote(root, rel) + expect(content.body, `readNote(${rel}) returned the wrong body`).toBe(expected) + expect(content.path).toBe(rel) + } + }) + + it('writeNote to ONE note leaves every OTHER file byte-identical', async () => { + const root = await makeTempVault() + await buildObsidianVault(root) + + const target = 'Work/Documentation/Vault CLI Cheatsheet.md' + const newBody = '---\ntags: []\n---\n> [!info] EDITED\nNEW CHEATSHEET BODY\n' + const meta = await writeNote(root, target, newBody) + + // The path round-trips (so the renderer's `n.path === meta.path` match holds). + expect(meta.path).toBe(target) + expect(await readDiskBody(root, target)).toBe(newBody) + + // Every neighbour is untouched on disk. + for (const [rel, original] of Object.entries(FILES)) { + if (rel === target) continue + expect(await readDiskBody(root, rel), `${rel} was clobbered`).toBe(original) + } + }) + + it('a full list→read→write-back round-trip changes nothing on disk', async () => { + const root = await makeTempVault() + await buildObsidianVault(root) + + const notes = await listNotes(root) + // Simulate the renderer loading each note then autosaving its own buffer. + for (const note of notes) { + const loaded = await readNote(root, note.path) + await writeNote(root, note.path, loaded.body) + } + + for (const [rel, original] of Object.entries(FILES)) { + expect(await readDiskBody(root, rel), `${rel} drifted after round-trip`).toBe(original) + } + }) + + it('survives a symlinked vault root (the ~/sync case) without cross-writing', async () => { + const real = await makeTempVault('zennotes-202-real-') + await buildObsidianVault(real) + const linkParent = await makeTempVault('zennotes-202-link-') + const link = path.join(linkParent, 'obsidian') + await symlink(real, link, 'dir') + + // Open the vault THROUGH the symlink, as `~/sync/obsidian` would. + const notes = await listNotes(link) + expect(notes.map((n) => n.path).sort()).toContain('Work/Documentation/Vault CLI Cheatsheet.md') + + const target = 'Work/Documentation/Vault CLI Cheatsheet.md' + const loaded = await readNote(link, target) + expect(loaded.body).toBe(FILES[target]) + + const newBody = '# rewritten through the symlink\n' + await writeNote(link, target, newBody) + // The write landed on the correct REAL file, and nothing else moved. + expect(await readFile(path.join(real, 'Work', 'Documentation', 'Vault CLI Cheatsheet.md'), 'utf8')).toBe(newBody) + expect(await readFile(path.join(real, 'Work', 'Documentation', 'Another Note.md'), 'utf8')).toBe( + FILES['Work/Documentation/Another Note.md'] + ) + }) + + it('on-open migrations never rewrite or clobber note (.md) content', async () => { + const root = await makeTempVault() + await buildObsidianVault(root) + // An Obsidian vault often has loose non-md files at the root. + await writeFile(path.join(root, 'canvas board.canvas'), 'CANVAS DATA', 'utf8') + await writeFile(path.join(root, 'photo.png'), 'PNGDATA', 'utf8') + + await migrateLegacyDatabases(root) + const assetResult = await migrateLooseAssets(root) + + // Every note's body is exactly as authored — migrations must not touch .md. + for (const [rel, original] of Object.entries(FILES)) { + expect(await readDiskBody(root, rel), `${rel} changed during migration`).toBe(original) + } + // The fix for #202: a vault managed by Obsidian (`.obsidian/`) keeps its + // loose files exactly where the user put them — ZenNotes must not relocate + // `board.canvas`/`photo.png` into assets/ behind their back. + expect(assetResult.moved).toEqual([]) + expect(await readDiskBody(root, 'canvas board.canvas')).toBe('CANVAS DATA') + expect(await readDiskBody(root, 'photo.png')).toBe('PNGDATA') + }) + + it('still migrates loose attachments for a non-Obsidian vault (upgrade path intact)', async () => { + const root = await makeTempVault() + // Same shape but NOT an Obsidian vault (no `.obsidian/`): a former ZenNotes + // vault whose pasted image sits loose at the root. + for (const [rel, body] of Object.entries(FILES)) { + const abs = path.join(root, ...rel.split('/')) + await mkdir(path.dirname(abs), { recursive: true }) + await writeFile(abs, body, 'utf8') + } + const base = await getVaultSettings(root) + await setVaultSettings(root, { ...base, primaryNotesLocation: 'root' }) + await writeFile(path.join(root, 'pasted.png'), 'PNGDATA', 'utf8') + + const assetResult = await migrateLooseAssets(root) + expect(assetResult.moved).toContain('assets/pasted.png') + expect(await readDiskBody(root, 'assets/pasted.png')).toBe('PNGDATA') + }) +}) diff --git a/apps/desktop/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts index 392bc67b..e47826ac 100644 --- a/apps/desktop/src/main/vault.test.ts +++ b/apps/desktop/src/main/vault.test.ts @@ -22,6 +22,7 @@ import { renameFolder, restoreDeletedAsset, restoreFromTrash, + rootContentHiddenByInboxMode, searchVaultText, searchVaultTextCapabilities, setVaultSettings, @@ -41,6 +42,35 @@ afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) }) +describe('rootContentHiddenByInboxMode (#195)', () => { + it('flags an Obsidian-style vault (root notes + custom folders) stuck in inbox mode', async () => { + const root = await makeTempDir('zennotes-vault-hidden-') + await mkdir(root, { recursive: true }) + await writeFile(path.join(root, 'index.md'), '# Index\n') + await mkdir(path.join(root, 'concepts'), { recursive: true }) + const base = await getVaultSettings(root) + await setVaultSettings(root, { ...base, primaryNotesLocation: 'inbox' }) + expect(await rootContentHiddenByInboxMode(root)).toBe(true) + }) + + it('is false once the vault is switched to root mode', async () => { + const root = await makeTempDir('zennotes-vault-rootmode-') + await mkdir(root, { recursive: true }) + await writeFile(path.join(root, 'index.md'), '# Index\n') + const base = await getVaultSettings(root) + await setVaultSettings(root, { ...base, primaryNotesLocation: 'root' }) + expect(await rootContentHiddenByInboxMode(root)).toBe(false) + }) + + it('is false for an inbox-mode vault with no root content to hide', async () => { + const root = await makeTempDir('zennotes-vault-emptyroot-') + await mkdir(path.join(root, 'inbox'), { recursive: true }) + const base = await getVaultSettings(root) + await setVaultSettings(root, { ...base, primaryNotesLocation: 'inbox' }) + expect(await rootContentHiddenByInboxMode(root)).toBe(false) + }) +}) + describe('absolutePath', () => { it('rejects sibling-prefix escapes outside the vault root', async () => { const parent = await makeTempDir('zennotes-vault-parent-') @@ -137,7 +167,7 @@ describe('appendToNote', () => { }) describe('importPastedImage', () => { - it('writes clipboard image bytes to the vault root and returns a wiki embed', async () => { + it('writes clipboard image bytes into assets/ and returns a wiki embed', async () => { const root = await makeTempDir('zennotes-paste-image-') await ensureVaultLayout(root) @@ -153,19 +183,20 @@ describe('importPastedImage', () => { expect(imported).toEqual({ name: 'Screenshot 2026-05-13.png', - path: 'Screenshot 2026-05-13.png', - markdown: '![[Screenshot 2026-05-13.png]]', + path: 'assets/Screenshot 2026-05-13.png', + markdown: '![[assets/Screenshot 2026-05-13.png]]', kind: 'image' }) - await expect(readFile(path.join(root, 'Screenshot 2026-05-13.png'))).resolves.toEqual( + await expect(readFile(path.join(root, 'assets/Screenshot 2026-05-13.png'))).resolves.toEqual( Buffer.from([137, 80, 78, 71]) ) }) - it('generates a unique root filename when the clipboard has no useful name', async () => { + it('generates a unique filename in assets/ when the clipboard has no useful name', async () => { const root = await makeTempDir('zennotes-paste-image-name-') await ensureVaultLayout(root) - await writeFile(path.join(root, 'Pasted Image 2026-05-13 150405.webp'), 'existing', 'utf8') + await mkdir(path.join(root, 'assets'), { recursive: true }) + await writeFile(path.join(root, 'assets/Pasted Image 2026-05-13 150405.webp'), 'existing', 'utf8') const imported = await importPastedImage( root, @@ -177,9 +208,9 @@ describe('importPastedImage', () => { ) expect(imported.name).toBe('Pasted Image 2026-05-13 150405 2.webp') - expect(imported.path).toBe('Pasted Image 2026-05-13 150405 2.webp') - expect(imported.markdown).toBe('![[Pasted Image 2026-05-13 150405 2.webp]]') - await expect(readFile(path.join(root, imported.name))).resolves.toEqual(Buffer.from([1, 2, 3])) + expect(imported.path).toBe('assets/Pasted Image 2026-05-13 150405 2.webp') + expect(imported.markdown).toBe('![[assets/Pasted Image 2026-05-13 150405 2.webp]]') + await expect(readFile(path.join(root, imported.path))).resolves.toEqual(Buffer.from([1, 2, 3])) }) }) @@ -535,7 +566,7 @@ describe('listNotes metadata cache', () => { await writeFile( path.join(root, '.zennotes', 'note-meta-cache-v1.json'), `${JSON.stringify({ - version: 1, + version: 2, entries: [ { path: rel, @@ -551,6 +582,7 @@ describe('listNotes metadata cache', () => { size: info.size, tags: ['cached'], wikilinks: ['Cached Target'], + assetEmbeds: [], hasAttachments: false, excerpt: 'cached excerpt' } @@ -616,6 +648,23 @@ describe('listNotes metadata cache', () => { }) }) +describe('listNotes asset embeds (#185 usage)', () => { + it('captures ![[asset]] and ![](asset) targets, not note wikilinks or URLs', async () => { + const root = await makeTempDir('zennotes-asset-embeds-') + await ensureVaultLayout(root) + await writeFile( + path.join(root, 'inbox', 'n.md'), + // Includes the angle-bracket + alt-text form the editor writes: ![alt](<path>). + '![[photo.png]]\n![](assets/doc.pdf)\n![GreenGrass](<GreenGrass.jpg>)\n[[Some Note]]\n![](https://x.com/a.png)\n', + 'utf8' + ) + const notes = await listNotes(root) + const note = notes.find((n) => n.path === 'inbox/n.md') + expect(note?.assetEmbeds.sort()).toEqual(['GreenGrass.jpg', 'assets/doc.pdf', 'photo.png']) + expect(note?.wikilinks).toEqual(['Some Note']) // note links stay separate + }) +}) + describe('archive / trash round-trips', () => { async function makeVaultWithNestedNote(): Promise<{ root: string }> { const root = await makeTempDir('zennotes-folder-moves-') diff --git a/apps/desktop/src/main/vault.ts b/apps/desktop/src/main/vault.ts index 1fd2dc91..7d633ecf 100644 --- a/apps/desktop/src/main/vault.ts +++ b/apps/desktop/src/main/vault.ts @@ -12,11 +12,16 @@ import { type RenameNoteRef } from './wikilink-rename' import { + DEFAULT_DAILY_NOTE_LOCALE, + DEFAULT_DAILY_NOTE_TITLE_PATTERN, DEFAULT_DAILY_NOTES_DIRECTORY, + DEFAULT_WEEKLY_NOTE_LOCALE, + DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, DEFAULT_WEEKLY_NOTES_DIRECTORY, AssetMeta, DeletedAsset, type FolderIconId, + type FolderColorId, type PrimaryNotesLocation, type VaultSettings, FolderEntry, @@ -38,20 +43,33 @@ import { VaultInfo } from '@shared/ipc' import { DEMO_TOUR_DIR } from '@shared/demo-tour' -import { DATABASE_SIDECAR_SUFFIX } from '@shared/databases' +import { + DATABASE_SIDECAR_SUFFIX, + databaseCsvPathFor, + databaseSchemaPathFor, + FORM_DATA_FILE, + FORM_DIR_SUFFIX, + FORM_SCHEMA_FILE, + isDatabaseInternalPath, + isFormDirName +} from '@shared/databases' +import { isExcalidrawPath, emptyExcalidrawDocument } from '@shared/excalidraw' import { DEMO_TOUR_ASSETS, DEMO_TOUR_NOTES } from './demo-tour-data' const CONFIG_FILE = 'zennotes.config.json' const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] const SYSTEM_FOLDERS = new Set<NoteFolder>(FOLDERS) +// Assets are unified under a top-level `assets/` folder. `attachements`/`_assets` +// are recognized legacy dirs (read + migrated, never the import target). (#185) +const ASSETS_DIR = 'assets' const PRIMARY_ATTACHMENTS_DIR = 'attachements' -const LEGACY_ATTACHMENTS_DIRS = ['_assets'] -const ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, ...LEGACY_ATTACHMENTS_DIRS] +const LEGACY_ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, '_assets'] +const ATTACHMENTS_DIRS = [ASSETS_DIR, ...LEGACY_ATTACHMENTS_DIRS] const INTERNAL_VAULT_DIR = '.zennotes' const DELETED_ASSETS_DIR = 'deleted-assets' const VAULT_SETTINGS_FILE = 'vault.json' const NOTE_META_CACHE_FILE = 'note-meta-cache-v1.json' -const NOTE_META_CACHE_VERSION = 1 +const NOTE_META_CACHE_VERSION = 2 const NOTE_COMMENTS_DIR = 'comments' const NOTE_COMMENTS_SUFFIX = '.comments.json' const RESERVED_ROOT_NAMES = new Set<string>([...FOLDERS, ...ATTACHMENTS_DIRS, INTERNAL_VAULT_DIR]) @@ -133,17 +151,51 @@ function isFolderIconId(value: unknown): value is FolderIconId { return typeof value === 'string' && VALID_FOLDER_ICON_IDS.has(value as FolderIconId) } +const VALID_FOLDER_COLOR_IDS = new Set<FolderColorId>([ + 'red', + 'orange', + 'amber', + 'green', + 'teal', + 'sky', + 'blue', + 'indigo', + 'violet', + 'pink' +]) + +function isFolderColorId(value: unknown): value is FolderColorId { + return typeof value === 'string' && VALID_FOLDER_COLOR_IDS.has(value as FolderColorId) +} + +function normalizeFolderColors(value: unknown): Record<string, FolderColorId> { + const out: Record<string, FolderColorId> = {} + if (value && typeof value === 'object') { + for (const [key, colorId] of Object.entries(value as Record<string, unknown>)) { + if (!key || !isFolderColorId(colorId)) continue + out[key] = colorId + } + } + return out +} + const DEFAULT_VAULT_SETTINGS: VaultSettings = { primaryNotesLocation: 'inbox', dailyNotes: { enabled: false, - directory: DEFAULT_DAILY_NOTES_DIRECTORY + directory: DEFAULT_DAILY_NOTES_DIRECTORY, + titlePattern: DEFAULT_DAILY_NOTE_TITLE_PATTERN, + locale: DEFAULT_DAILY_NOTE_LOCALE }, weeklyNotes: { enabled: false, - directory: DEFAULT_WEEKLY_NOTES_DIRECTORY + directory: DEFAULT_WEEKLY_NOTES_DIRECTORY, + titlePattern: DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, + locale: DEFAULT_WEEKLY_NOTE_LOCALE }, - folderIcons: {} + folderIcons: {}, + folderColors: {}, + favorites: [] } interface VaultTextSearchCandidate { @@ -666,9 +718,14 @@ export function databaseDataPath(root: string, rel: string): string { return resolveSafe(root, toPosix(rel)) } -/** Absolute path of a database's co-located `.csv.base.json` sidecar. */ +/** + * Absolute path of a database's schema/sidecar. New layout: `<Name>.base/ + * schema.json`. A legacy loose `.csv` (not in a `.base` folder) falls back to + * the co-located `<rel>.csv.base.json` so old vaults still read. + */ export function databaseSidecarPath(root: string, rel: string): string { - return resolveSafe(root, `${toPosix(rel)}${DATABASE_SIDECAR_SUFFIX}`) + const schemaRel = databaseSchemaPathFor(toPosix(rel)) + return resolveSafe(root, schemaRel ?? `${toPosix(rel)}${DATABASE_SIDECAR_SUFFIX}`) } function cloneVaultSettings(settings: VaultSettings): VaultSettings { @@ -677,14 +734,22 @@ function cloneVaultSettings(settings: VaultSettings): VaultSettings { dailyNotes: { enabled: settings.dailyNotes.enabled, directory: settings.dailyNotes.directory, + titlePattern: settings.dailyNotes.titlePattern, + locale: settings.dailyNotes.locale, + legacyPatterns: settings.dailyNotes.legacyPatterns?.map((pattern) => ({ ...pattern })), templateId: settings.dailyNotes.templateId }, weeklyNotes: { enabled: settings.weeklyNotes.enabled, directory: settings.weeklyNotes.directory, + titlePattern: settings.weeklyNotes.titlePattern, + locale: settings.weeklyNotes.locale, + legacyPatterns: settings.weeklyNotes.legacyPatterns?.map((pattern) => ({ ...pattern })), templateId: settings.weeklyNotes.templateId }, - folderIcons: { ...settings.folderIcons } + folderIcons: { ...settings.folderIcons }, + folderColors: { ...settings.folderColors }, + favorites: [...settings.favorites] } } @@ -694,6 +759,30 @@ function normalizeDailyNotesDirectory(value: unknown): string { return trimmed || DEFAULT_DAILY_NOTES_DIRECTORY } +function normalizeDailyNoteTitlePattern(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_DAILY_NOTE_TITLE_PATTERN + const trimmed = value.trim().replace(/[\\/]+/g, '-') + return trimmed || DEFAULT_DAILY_NOTE_TITLE_PATTERN +} + +function normalizeDailyNoteLocale(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_DAILY_NOTE_LOCALE + const trimmed = value.trim() + return trimmed || DEFAULT_DAILY_NOTE_LOCALE +} + +function normalizeWeeklyNoteTitlePattern(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_WEEKLY_NOTE_TITLE_PATTERN + const trimmed = value.trim().replace(/[\\/]+/g, '-') + return trimmed || DEFAULT_WEEKLY_NOTE_TITLE_PATTERN +} + +function normalizeWeeklyNoteLocale(value: unknown): string { + if (typeof value !== 'string') return DEFAULT_WEEKLY_NOTE_LOCALE + const trimmed = value.trim() + return trimmed || DEFAULT_WEEKLY_NOTE_LOCALE +} + function normalizeWeeklyNotesDirectory(value: unknown): string { if (typeof value !== 'string') return DEFAULT_WEEKLY_NOTES_DIRECTORY const trimmed = value.trim().replace(/^\/+|\/+$/g, '') @@ -706,6 +795,46 @@ function normalizeTemplateId(value: unknown): string | undefined { return trimmed || undefined } +function normalizeDailyNoteLegacyPatterns(value: unknown): VaultSettings['dailyNotes']['legacyPatterns'] { + if (!Array.isArray(value)) return [] + const out: NonNullable<VaultSettings['dailyNotes']['legacyPatterns']> = [] + const seen = new Set<string>() + for (const item of value) { + if (!item || typeof item !== 'object') continue + const pattern = item as { directory?: unknown; titlePattern?: unknown; locale?: unknown } + const next = { + directory: normalizeDailyNotesDirectory(pattern.directory), + titlePattern: normalizeDailyNoteTitlePattern(pattern.titlePattern), + locale: normalizeDailyNoteLocale(pattern.locale) + } + const key = `${next.directory}\0${next.titlePattern}\0${next.locale}` + if (seen.has(key)) continue + seen.add(key) + out.push(next) + } + return out +} + +function normalizeWeeklyNoteLegacyPatterns(value: unknown): VaultSettings['weeklyNotes']['legacyPatterns'] { + if (!Array.isArray(value)) return [] + const out: NonNullable<VaultSettings['weeklyNotes']['legacyPatterns']> = [] + const seen = new Set<string>() + for (const item of value) { + if (!item || typeof item !== 'object') continue + const pattern = item as { directory?: unknown; titlePattern?: unknown; locale?: unknown } + const next = { + directory: normalizeWeeklyNotesDirectory(pattern.directory), + titlePattern: normalizeWeeklyNoteTitlePattern(pattern.titlePattern), + locale: normalizeWeeklyNoteLocale(pattern.locale) + } + const key = `${next.directory}\0${next.titlePattern}\0${next.locale}` + if (seen.has(key)) continue + seen.add(key) + out.push(next) + } + return out +} + function normalizePrimaryNotesLocation(value: unknown): PrimaryNotesLocation { return value === 'root' ? 'root' : 'inbox' } @@ -719,20 +848,42 @@ function normalizeVaultSettings( primaryNotesLocation: fallbackPrimary, dailyNotes: { enabled: DEFAULT_VAULT_SETTINGS.dailyNotes.enabled, - directory: DEFAULT_DAILY_NOTES_DIRECTORY + directory: DEFAULT_DAILY_NOTES_DIRECTORY, + titlePattern: DEFAULT_DAILY_NOTE_TITLE_PATTERN, + locale: DEFAULT_DAILY_NOTE_LOCALE }, weeklyNotes: { enabled: DEFAULT_VAULT_SETTINGS.weeklyNotes.enabled, - directory: DEFAULT_WEEKLY_NOTES_DIRECTORY + directory: DEFAULT_WEEKLY_NOTES_DIRECTORY, + titlePattern: DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, + locale: DEFAULT_WEEKLY_NOTE_LOCALE }, - folderIcons: {} + folderIcons: {}, + folderColors: {}, + favorites: [] } } const candidate = value as { primaryNotesLocation?: unknown - dailyNotes?: { enabled?: unknown; directory?: unknown; templateId?: unknown } | null - weeklyNotes?: { enabled?: unknown; directory?: unknown; templateId?: unknown } | null + dailyNotes?: { + enabled?: unknown + directory?: unknown + titlePattern?: unknown + locale?: unknown + legacyPatterns?: unknown + templateId?: unknown + } | null + weeklyNotes?: { + enabled?: unknown + directory?: unknown + titlePattern?: unknown + locale?: unknown + legacyPatterns?: unknown + templateId?: unknown + } | null folderIcons?: Record<string, unknown> | null + folderColors?: Record<string, unknown> | null + favorites?: unknown } const folderIcons: Record<string, FolderIconId> = {} if (candidate.folderIcons && typeof candidate.folderIcons === 'object') { @@ -751,6 +902,9 @@ function normalizeVaultSettings( ? candidate.dailyNotes.enabled : DEFAULT_VAULT_SETTINGS.dailyNotes.enabled, directory: normalizeDailyNotesDirectory(candidate.dailyNotes?.directory), + titlePattern: normalizeDailyNoteTitlePattern(candidate.dailyNotes?.titlePattern), + locale: normalizeDailyNoteLocale(candidate.dailyNotes?.locale), + legacyPatterns: normalizeDailyNoteLegacyPatterns(candidate.dailyNotes?.legacyPatterns), templateId: normalizeTemplateId(candidate.dailyNotes?.templateId) }, weeklyNotes: { @@ -759,12 +913,29 @@ function normalizeVaultSettings( ? candidate.weeklyNotes.enabled : DEFAULT_VAULT_SETTINGS.weeklyNotes.enabled, directory: normalizeWeeklyNotesDirectory(candidate.weeklyNotes?.directory), + titlePattern: normalizeWeeklyNoteTitlePattern(candidate.weeklyNotes?.titlePattern), + locale: normalizeWeeklyNoteLocale(candidate.weeklyNotes?.locale), + legacyPatterns: normalizeWeeklyNoteLegacyPatterns(candidate.weeklyNotes?.legacyPatterns), templateId: normalizeTemplateId(candidate.weeklyNotes?.templateId) }, - folderIcons + folderIcons, + folderColors: normalizeFolderColors(candidate.folderColors), + favorites: normalizeFavorites(candidate.favorites) } } +function normalizeFavorites(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const out: string[] = [] + const seen = new Set<string>() + for (const entry of value) { + if (typeof entry !== 'string' || !entry || seen.has(entry)) continue + seen.add(entry) + out.push(entry) + } + return out +} + function folderIconKey(folder: NoteFolder, subpath: string): string { return `${folder}:${subpath}` } @@ -807,6 +978,44 @@ function removeFolderIcons( return next } +function rewriteFolderColorsForRename( + folderColors: Record<string, FolderColorId>, + folder: NoteFolder, + oldSubpath: string, + newSubpath: string +): Record<string, FolderColorId> { + const next: Record<string, FolderColorId> = {} + const exactKey = folderIconKey(folder, oldSubpath) + const prefix = `${exactKey}/` + for (const [key, value] of Object.entries(folderColors)) { + if (key === exactKey) { + next[folderIconKey(folder, newSubpath)] = value + continue + } + if (key.startsWith(prefix)) { + next[folderIconKey(folder, newSubpath) + key.slice(exactKey.length)] = value + continue + } + next[key] = value + } + return next +} + +function removeFolderColors( + folderColors: Record<string, FolderColorId>, + folder: NoteFolder, + subpath: string +): Record<string, FolderColorId> { + const next: Record<string, FolderColorId> = {} + const exactKey = folderIconKey(folder, subpath) + const prefix = `${exactKey}/` + for (const [key, value] of Object.entries(folderColors)) { + if (key === exactKey || key.startsWith(prefix)) continue + next[key] = value + } + return next +} + function duplicateFolderIcons( folderIcons: Record<string, FolderIconId>, folder: NoteFolder, @@ -844,6 +1053,20 @@ async function inferPrimaryNotesLocation(root: string): Promise<PrimaryNotesLoca return DEFAULT_VAULT_SETTINGS.primaryNotesLocation } +/** + * True when the vault is in `inbox` primary-notes mode but its root holds + * markdown files or non-system folders that only `root` mode would surface — + * i.e. an Obsidian-style flat vault that was detected as Inbox (e.g. a flaky + * first directory read on an iCloud/symlinked folder fell back to the default) + * and is now silently hiding the user's notes. Drives the "Switch to Vault + * root" banner so an empty-looking vault is explained instead of silent. + */ +export async function rootContentHiddenByInboxMode(root: string): Promise<boolean> { + const settings = await getVaultSettings(root) + if (settings.primaryNotesLocation !== 'inbox') return false + return (await inferPrimaryNotesLocation(root)) === 'root' +} + async function vaultLooksEmpty(root: string): Promise<boolean> { let entries: Dirent[] try { @@ -929,6 +1152,228 @@ export async function ensureVaultLayout(root: string): Promise<void> { await fs.writeFile(welcomePath, WELCOME_NOTE, 'utf8') } } + if (!wasEmpty) { + try { + await migrateLegacyDatabases(root) + } catch (err) { + console.error('legacy database migration failed', err) + } + try { + await migrateLooseAssets(root) + } catch (err) { + console.error('loose asset migration failed', err) + } + } +} + +/** + * One-time, idempotent migration: unify assets under `assets/`. Moves loose + * root-level attachments and any files in the legacy `attachements/` / `_assets/` + * dirs into `assets/`, skipping a file whose basename already exists there (so + * the basename-fallback resolution of `![[…]]`/`![](…)` embeds stays + * unambiguous). Never touches notes (`.md`) or database files. Safe on every + * open. Returns the moved (assets-relative) and skipped (source) paths. + */ +export async function migrateLooseAssets( + root: string +): Promise<{ moved: string[]; skipped: string[] }> { + const moved: string[] = [] + const skipped: string[] = [] + const assetsAbs = path.join(root, ASSETS_DIR) + + const moveIntoAssets = async (srcRel: string): Promise<void> => { + const destRel = `${ASSETS_DIR}/${path.basename(srcRel)}` + if (toPosix(srcRel) === destRel) return + const destAbs = resolveSafe(root, destRel) + try { + await fs.access(destAbs) // a same-named asset already exists — don't clobber/rename + skipped.push(toPosix(srcRel)) + return + } catch { + /* destination free */ + } + await fs.mkdir(assetsAbs, { recursive: true }) + await fs.rename(resolveSafe(root, srcRel), destAbs) + moved.push(destRel) + } + + // 1. Loose asset files sitting directly at the vault root. + // + // Skip this for a vault managed by another app — an `.obsidian/` directory + // marks an Obsidian vault. Those loose root files (`.canvas`, images, PDFs, + // anything) are the user's own, not ZenNotes' legacy attachments, and + // silently relocating them into `assets/` surprised users who pointed + // ZenNotes at an existing Obsidian vault (#202). The legacy ZenNotes + // attachment dirs below never exist in such a vault, so they stay safe. + let isExternalVault = false + try { + await fs.access(path.join(root, '.obsidian')) + isExternalVault = true + } catch { + /* not an Obsidian vault */ + } + if (!isExternalVault) { + let rootEntries: Dirent[] = [] + try { + rootEntries = await fs.readdir(root, { withFileTypes: true }) + } catch { + rootEntries = [] + } + for (const entry of rootEntries) { + if (entry.name.startsWith('.') || !entry.isFile()) continue + if (entry.name.toLowerCase().endsWith('.md')) continue // a note + if (databaseCsvPathFor(entry.name) || isDatabaseInternalPath(entry.name)) continue // a database + await moveIntoAssets(entry.name) + } + } + + // 2. Files in the legacy attachment dirs, then drop the dir if it empties. + for (const dir of LEGACY_ATTACHMENTS_DIRS) { + let entries: Dirent[] + try { + entries = await fs.readdir(path.join(root, dir), { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.name.startsWith('.') || !entry.isFile()) continue + await moveIntoAssets(`${dir}/${entry.name}`) + } + try { + await fs.rmdir(path.join(root, dir)) + } catch { + /* not empty — leave it */ + } + } + + if (moved.length > 0) { + console.log(`[zen] migrated ${moved.length} asset(s) into ${ASSETS_DIR}/`) + } + return { moved, skipped } +} + +/** + * One-time, idempotent migration: convert legacy loose databases — a + * `<dir>/<Name>.csv` + co-located `<dir>/<Name>.csv.base.json` sidecar, with + * record-page notes under `<dir>/<Name>/` — into the self-contained + * `<dir>/<Name>.base/` folder (data.csv + schema.json + record `.md` pages). + * Safe to run on every open: anything already in `.base` form, or whose target + * folder exists, is skipped. Per-database failures are logged, not fatal. + * Returns the number of databases migrated. + */ +export async function migrateLegacyDatabases(root: string): Promise<number> { + let migrated = 0 + const walk = async (dirRel: string): Promise<void> => { + const dirAbs = dirRel ? path.join(root, dirRel) : root + let entries: Dirent[] + try { + entries = await fs.readdir(dirAbs, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const name = entry.name + if (name.startsWith('.')) continue + const childRel = dirRel ? `${dirRel}/${name}` : name + if (entry.isDirectory()) { + if (isFormDirName(name)) continue // already a database folder + await walk(childRel) + continue + } + const lower = name.toLowerCase() + if (!lower.endsWith('.csv') || lower.endsWith(`.csv${DATABASE_SIDECAR_SUFFIX}`)) continue + // Only migrate a managed database (a `.csv` with a sibling sidecar); a + // plain CSV without one is left as a file. + const sidecarRel = `${childRel}${DATABASE_SIDECAR_SUFFIX}` + try { + await fs.access(resolveSafe(root, sidecarRel)) + } catch { + continue + } + try { + if (await migrateOneLegacyDatabase(root, childRel, sidecarRel)) migrated++ + } catch (err) { + console.error(`database migration failed for ${childRel}`, err) + } + } + } + await walk('') + if (migrated > 0) { + console.log(`[zen] migrated ${migrated} database(s) to the .base folder layout`) + } + return migrated +} + +async function migrateOneLegacyDatabase( + root: string, + csvRel: string, + sidecarRel: string +): Promise<boolean> { + const name = path.basename(csvRel).replace(/\.csv$/i, '') + const parentRel = csvRel.includes('/') ? csvRel.slice(0, csvRel.lastIndexOf('/')) : '' + const formDirRel = parentRel ? `${parentRel}/${name}${FORM_DIR_SUFFIX}` : `${name}${FORM_DIR_SUFFIX}` + const formDirAbs = resolveSafe(root, formDirRel) + // Idempotent / collision safety: never clobber an existing folder. + try { + await fs.access(formDirAbs) + return false + } catch { + /* target free — proceed */ + } + + let sidecar: Record<string, unknown> = {} + try { + sidecar = JSON.parse(await fs.readFile(resolveSafe(root, sidecarRel), 'utf8')) as Record< + string, + unknown + > + } catch { + /* unreadable sidecar — still migrate the data, infer schema on next open */ + } + + await fs.mkdir(formDirAbs, { recursive: true }) + + // Move record-page notes into the folder; rewrite pages → relative basenames. + const pages = + sidecar.pages && typeof sidecar.pages === 'object' + ? (sidecar.pages as Record<string, unknown>) + : {} + const newPages: Record<string, string> = {} + const oldPageDirs = new Set<string>() + for (const [rowId, p] of Object.entries(pages)) { + if (typeof p !== 'string') continue + const srcAbs = resolveSafe(root, p) + try { + await fs.access(srcAbs) + } catch { + continue // page note already gone — drop the mapping + } + const finalTitle = await uniqueTitle(formDirAbs, sanitizeNoteTitle(path.basename(p, '.md'))) + await fs.rename(srcAbs, resolveSafe(root, `${formDirRel}/${finalTitle}.md`)) + newPages[rowId] = `${finalTitle}.md` + if (p.includes('/')) oldPageDirs.add(p.slice(0, p.lastIndexOf('/'))) + } + + // Move the data file, write schema.json (relative pages), drop the old sidecar. + await fs.rename(resolveSafe(root, csvRel), resolveSafe(root, `${formDirRel}/${FORM_DATA_FILE}`)) + const outSidecar: Record<string, unknown> = { ...sidecar } + if (Object.keys(newPages).length > 0) outSidecar.pages = newPages + else delete outSidecar.pages + await writeFileAtomic( + resolveSafe(root, `${formDirRel}/${FORM_SCHEMA_FILE}`), + `${JSON.stringify(outSidecar, null, 2)}\n` + ) + await fs.rm(resolveSafe(root, sidecarRel), { force: true }) + + // Remove now-empty legacy per-database page folders (e.g. `<dir>/<Name>/`). + for (const d of oldPageDirs) { + try { + await fs.rmdir(resolveSafe(root, d)) + } catch { + /* not empty / not ours — leave it */ + } + } + return true } export function vaultInfo(root: string): VaultInfo { @@ -976,7 +1421,7 @@ function localAssetTargetKind(target: string): ImportedAssetKind | null { function extractTags(body: string): string[] { if (!body.includes('#')) return [] const stripped = stripCodeContent(body) - const matches = stripped.match(/(?:^|\s)#([a-zA-Z][\w\-/]*)/g) || [] + const matches = stripped.match(/(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu) || [] const seen = new Set<string>() for (const m of matches) seen.add(m.trim().slice(1)) return [...seen] @@ -1026,6 +1471,37 @@ function extractWikilinks(body: string): string[] { return [...seen] } +/** Pull unique asset-embed targets out of markdown — both `![[asset]]` (which + * extractWikilinks deliberately skips) and `![](path)` image/file embeds. The + * raw targets are resolved to assets per-note by the renderer (relative path + + * basename fallback), to show which notes use each asset. */ +function extractAssetEmbeds(body: string): string[] { + const stripped = stripCodeContent(body) + const seen = new Set<string>() + if (stripped.includes('![[')) { + const re = /!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g + let m: RegExpExecArray | null + while ((m = re.exec(stripped)) !== null) { + const t = (m[1] ?? '').trim() + if (t && localAssetTargetKind(t)) seen.add(t) + } + } + if (stripped.includes('](')) { + const re = /!\[[^\]]*\]\(\s*<?([^)>\s]+)>?[^)]*\)/g + let m: RegExpExecArray | null + while ((m = re.exec(stripped)) !== null) { + const raw = (m[1] ?? '').trim() + if (!raw || raw.startsWith('#') || /^[a-zA-Z][\w+.-]*:/.test(raw)) continue // skip URLs/anchors + try { + seen.add(decodeURIComponent(raw)) + } catch { + seen.add(raw) + } + } + } + return [...seen] +} + /** Build a short plaintext preview from markdown. */ function buildExcerpt(body: string): string { const withoutFront = body.startsWith('---\n') ? body.replace(/^---\n[\s\S]*?\n---\n/, '') : body @@ -1160,6 +1636,8 @@ function normalizeCachedNoteMeta(value: unknown): NoteMeta | null { !candidate.tags.every((tag) => typeof tag === 'string') || !Array.isArray(candidate.wikilinks) || !candidate.wikilinks.every((wikilink) => typeof wikilink === 'string') || + !Array.isArray(candidate.assetEmbeds) || + !candidate.assetEmbeds.every((embed) => typeof embed === 'string') || typeof candidate.hasAttachments !== 'boolean' || typeof candidate.excerpt !== 'string' ) { @@ -1175,6 +1653,7 @@ function normalizeCachedNoteMeta(value: unknown): NoteMeta | null { size: candidate.size, tags: candidate.tags, wikilinks: candidate.wikilinks, + assetEmbeds: candidate.assetEmbeds, hasAttachments: candidate.hasAttachments, excerpt: candidate.excerpt } @@ -1446,6 +1925,21 @@ async function isMarkdownNoteEntry(full: string, entry: Dirent): Promise<boolean return false } +// `.excalidraw` drawings are listed alongside notes (so they show in the sidebar +// tree); the sidebar/editor route them by extension to the drawing view. +async function isExcalidrawFileEntry(full: string, entry: Dirent): Promise<boolean> { + if (!isExcalidrawPath(entry.name)) return false + if (entry.isFile()) return true + if (entry.isSymbolicLink()) { + try { + return (await fs.stat(full)).isFile() + } catch { + return false + } + } + return false +} + async function realpathOrResolve(p: string): Promise<string> { try { return await fs.realpath(p) @@ -1812,6 +2306,28 @@ async function readMeta( return { ...cached.meta, siblingOrder: resolvedSiblingOrder, isSymlink: linked } } + // Excalidraw drawings are JSON, not Markdown — don't parse their body for + // tags/links/excerpt (that would be garbage) or even read it for meta. + if (isExcalidrawPath(relPath)) { + const meta: NoteMeta = { + path: relPath, + title: path.basename(abs, path.extname(abs)), + folder, + siblingOrder: resolvedSiblingOrder, + createdAt: stat.birthtimeMs || stat.ctimeMs, + updatedAt: stat.mtimeMs, + size: stat.size, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '', + isSymlink: linked + } + noteMetaCache.set(cacheKey, { mtimeMs: stat.mtimeMs, size: stat.size, meta }) + return meta + } + let body = '' try { body = await fs.readFile(abs, 'utf8') @@ -1828,6 +2344,7 @@ async function readMeta( size: stat.size, tags: extractTags(body), wikilinks: extractWikilinks(body), + assetEmbeds: extractAssetEmbeds(body), hasAttachments: bodyHasLocalAsset(body), excerpt: buildExcerpt(body), isSymlink: linked @@ -1903,6 +2420,10 @@ export async function listFolders(root: string): Promise<FolderEntry[]> { if (isPrimaryRoot && dirAbs === topAbs && shouldHidePrimaryRootEntry(e.name)) continue const nextSub = subpath ? `${subpath}/${e.name}` : e.name out.push({ folder, subpath: nextSub, siblingOrder: index, isSymlink: e.isSymbolicLink() }) + // A `<Name>.base` database folder is listed (the renderer shows it as a + // database), but we don't descend — its data.csv/schema.json internals + // aren't folders, and its record-page notes are surfaced via listNotes. + if (isFormDirName(e.name)) continue ancestors.add(childReal) await walk(full, childReal, nextSub) ancestors.delete(childReal) @@ -1947,7 +2468,10 @@ export async function listNotes(root: string): Promise<NoteMeta[]> { ancestors.delete(childReal) continue } - if (await isMarkdownNoteEntry(full, entry)) { + if ( + (await isMarkdownNoteEntry(full, entry)) || + (await isExcalidrawFileEntry(full, entry)) + ) { noteFiles.push({ full, folder, siblingOrder: index, isSymlink: entry.isSymbolicLink() }) } } @@ -2280,7 +2804,8 @@ function cleanAssetFilename(name: string): string { function cleanAssetTargetDir(root: string, targetDir: string): string { const normalized = normalizeVaultRelativePath(targetDir).replace(/^\/+|\/+$/g, '') - if (!normalized) return root + // No explicit target → the unified `assets/` folder (not loose at the root). + if (!normalized) return resolveSafe(root, ASSETS_DIR) if (normalized.split('/').includes(INTERNAL_VAULT_DIR)) { throw new Error('Cannot move assets into internal ZenNotes files.') } @@ -2403,6 +2928,35 @@ export async function createNote( return await readMeta(root, abs, folder) } +// Create a new `.excalidraw` drawing seeded with an empty scene. Mirrors +// createNote (unique title, same folder/subpath resolution) but writes the +// Excalidraw JSON instead of a Markdown stub. +export async function createExcalidraw( + root: string, + folder: NoteFolder, + subpath = '', + title?: string +): Promise<NoteMeta> { + const base = sanitizeNoteTitle(title) || 'Untitled drawing' + const clean = subpath.replace(/^\/+|\/+$/g, '') + const topRoot = await folderRoot(root, folder) + const dir = clean ? resolveSafe(topRoot, clean) : topRoot + await fs.mkdir(dir, { recursive: true }) + let finalTitle = base + for (let n = 2; ; n++) { + try { + await fs.access(path.join(dir, `${finalTitle}.excalidraw`)) + finalTitle = `${base} ${n}` + } catch { + break + } + } + const abs = path.join(dir, `${finalTitle}.excalidraw`) + await fs.writeFile(abs, JSON.stringify(emptyExcalidrawDocument(), null, 2), 'utf8') + invalidateNoteMetaCache(root, toPosix(path.relative(root, abs))) + return await readMeta(root, abs, folder) +} + /** * Move a markdown file that lives outside the vault into the vault's * primary notes area, de-duplicating the title on collision. The source @@ -2434,7 +2988,10 @@ export async function renameNote( if (!folder) throw new Error(`Note not in a known folder: ${rel}`) const dir = path.dirname(abs) const trimmed = sanitizeNoteTitle(nextTitle) - const target = path.join(dir, `${trimmed}.md`) + // Preserve the file's type on rename — a `.excalidraw` drawing must stay a + // drawing, not get turned into a `.md` note (which would render its JSON). + const ext = isExcalidrawPath(abs) ? '.excalidraw' : '.md' + const target = path.join(dir, `${trimmed}${ext}`) const willRename = target !== abs // Snapshot the vault before the rename so inbound [[wikilinks]] still // resolve to this note under its current name; we rewrite them afterwards. @@ -2534,7 +3091,9 @@ async function moveBetweenFolders( await fs.mkdir(destDir, { recursive: true }) const baseTitle = path.basename(filename, path.extname(filename)) const finalTitle = await uniqueTitle(destDir, baseTitle) - const destAbs = path.join(destDir, `${finalTitle}.md`) + // Preserve the file type when moving (a `.excalidraw` drawing stays a drawing). + const ext = isExcalidrawPath(filename) ? '.excalidraw' : '.md' + const destAbs = path.join(destDir, `${finalTitle}${ext}`) await fs.rename(abs, destAbs) const meta = await readMeta(root, destAbs, target) await moveNoteComments(root, rel, meta.path) @@ -2740,6 +3299,12 @@ export async function renameFolder( topFolder, oldClean, newClean + ), + folderColors: rewriteFolderColorsForRename( + settings.folderColors, + topFolder, + oldClean, + newClean ) } await setVaultSettings(root, nextSettings) @@ -2764,7 +3329,8 @@ export async function deleteFolder( const settings = await getVaultSettings(root) const nextSettings: VaultSettings = { ...settings, - folderIcons: removeFolderIcons(settings.folderIcons, topFolder, clean) + folderIcons: removeFolderIcons(settings.folderIcons, topFolder, clean), + folderColors: removeFolderColors(settings.folderColors, topFolder, clean) } await setVaultSettings(root, nextSettings) invalidateNoteMetaCache(root) @@ -2826,7 +3392,7 @@ export function folderAbsolutePath( } export function assetsAbsolutePath(root: string): string { - return path.join(root, PRIMARY_ATTACHMENTS_DIR) + return path.join(root, ASSETS_DIR) } async function removeFileIfExists(abs: string): Promise<void> { @@ -2915,6 +3481,9 @@ export async function listAssets(root: string): Promise<AssetMeta[]> { const childReal = await resolveDirDescent(full, entry, dirReal, ancestors) if (childReal !== null) { if (dirAbs === root && entry.name === INTERNAL_VAULT_DIR) continue + // A `<Name>.base` database folder isn't an asset container — its + // data.csv/schema.json/record notes never appear in the assets list. + if (isFormDirName(entry.name)) continue ancestors.add(childReal) await walk(full, childReal, ancestors) ancestors.delete(childReal) @@ -2922,6 +3491,11 @@ export async function listAssets(root: string): Promise<AssetMeta[]> { } if (!entry.isFile()) continue if (entry.name.toLowerCase().endsWith('.md')) continue + // Excalidraw drawings are a first-class file type (listed with notes), not + // a generic attachment. + if (isExcalidrawPath(entry.name)) continue + // Legacy co-located sidecar/.bak (pre-migration) — not a user asset. + if (isDatabaseInternalPath(entry.name)) continue let stat try { stat = await fs.stat(full) @@ -3045,13 +3619,14 @@ export async function importPastedImage( input: PastedImageInput, now = new Date() ): Promise<ImportedAsset> { - await fs.mkdir(root, { recursive: true }) - const bytes = pastedImageBuffer(input.data) if (bytes.byteLength === 0) throw new Error('Clipboard image is empty.') - const finalName = await uniqueFilename(root, pastedImageFilename(input, now)) - const destAbs = path.join(root, finalName) + // Pasted images land in the unified `assets/` folder. + const assetsDir = path.join(root, ASSETS_DIR) + await fs.mkdir(assetsDir, { recursive: true }) + const finalName = await uniqueFilename(assetsDir, pastedImageFilename(input, now)) + const destAbs = path.join(assetsDir, finalName) await fs.writeFile(destAbs, bytes) const vaultRelPath = toPosix(path.relative(root, destAbs)) diff --git a/apps/desktop/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts index 538678cd..7d439ac1 100644 --- a/apps/desktop/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -4,7 +4,7 @@ import type { NoteFolder, VaultChangeEvent, VaultChangeKind } from '@shared/ipc' import { databaseCsvPathFor } from '@shared/databases' import { folderForRelativePath } from './vault' -const ATTACHMENTS_DIRS = new Set(['attachements', '_assets']) +const ATTACHMENTS_DIRS = new Set(['assets', 'attachements', '_assets']) const INTERNAL_VAULT_DIR = '.zennotes' const VAULT_SETTINGS_RELATIVE_PATH = `${INTERNAL_VAULT_DIR}/vault.json` const NOTE_COMMENTS_PREFIX = `${INTERNAL_VAULT_DIR}/comments/` @@ -80,8 +80,10 @@ export class VaultWatcher { }) return } - // A `.csv` data file or its `.csv.base.json` sidecar — normalize both to - // the canonical `.csv` path so the renderer re-hydrates the right database. + // Any database file — `<Name>.base/data.csv` or `schema.json` (or a legacy + // loose `.csv`/sidecar) — normalizes to the canonical `data.csv` path so + // the renderer re-hydrates the right database. (Record-page `.md` notes in + // a `.base` folder return null here and ride the normal note path below.) const dbCsvPath = databaseCsvPathFor(toPosix(path.relative(this.root, absPath))) if (dbCsvPath) { onEvent({ diff --git a/apps/desktop/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts index a3bf99d1..755ae67a 100644 --- a/apps/desktop/src/mcp/vault-ops.ts +++ b/apps/desktop/src/mcp/vault-ops.ts @@ -15,9 +15,14 @@ import os from 'node:os' export type NoteFolder = 'inbox' | 'quick' | 'archive' | 'trash' const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash'] const LIVE_FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive'] +/** A database is a self-contained folder whose name ends with `.base`; its + * internals (data.csv, schema.json, record-page notes) aren't part of the MCP + * note/folder surface, so the walks skip these folders. */ +const isFormDirName = (name: string): boolean => name.toLowerCase().endsWith('.base') +const ASSETS_DIR = 'assets' const PRIMARY_ATTACHMENTS_DIR = 'attachements' -const LEGACY_ATTACHMENTS_DIRS = ['_assets'] -const ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, ...LEGACY_ATTACHMENTS_DIRS] +const LEGACY_ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, '_assets'] +const ATTACHMENTS_DIRS = [ASSETS_DIR, ...LEGACY_ATTACHMENTS_DIRS] const INTERNAL_VAULT_DIR = '.zennotes' const VAULT_SETTINGS_FILE = 'vault.json' @@ -352,7 +357,7 @@ function stripCodeContent(body: string): string { function extractTags(body: string): string[] { const stripped = stripCodeContent(body) - const matches = stripped.match(/(?:^|\s)#([a-zA-Z][\w\-/]*)/g) || [] + const matches = stripped.match(/(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu) || [] const seen = new Set<string>() for (const m of matches) seen.add(m.trim().slice(1)) return [...seen] @@ -421,6 +426,7 @@ export async function listNotes(root: string): Promise<NoteMeta[]> { const full = path.join(dirAbs, entry.name) if (entry.isDirectory()) { if (entry.name.startsWith('.')) continue + if (isFormDirName(entry.name)) continue // database folder — not loose notes // When walking the vault root in primary='root' mode, system // subdirectories (quick/, archive/, trash/, attachments) are // not part of inbox — they're walked separately as their own @@ -458,6 +464,7 @@ export async function listFolders(root: string): Promise<{ folder: NoteFolder; s } for (const e of entries) { if (!e.isDirectory() || e.name.startsWith('.')) continue + if (isFormDirName(e.name)) continue // database folder — not a user folder if (isPrimaryRoot && dirAbs === topAbs && HIDDEN_PRIMARY_ROOT_NAMES.has(e.name)) { continue } @@ -825,7 +832,7 @@ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/ const INLINE_DUE_RE = /(?:^|\s)due:(\S+)/i const INLINE_PRIORITY_RE = /(?:^|\s)!(high|med|medium|low|h|m|l)\b/i const INLINE_WAITING_RE = /(?:^|\s)@waiting\b/i -const INLINE_TAG_RE = /(?:^|\s)#([a-z0-9][a-z0-9/_-]*)/gi +const INLINE_TAG_RE = /(?:^|\s)#([\p{L}\d][\p{L}\d/_-]*)/gu function normalizePriority(raw: string | undefined): 'high' | 'med' | 'low' | undefined { if (!raw) return undefined diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b27a0975..2c30c834 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -262,6 +262,8 @@ const api: ZenBridge = { getVaultSettings: (): Promise<VaultSettings> => ipcRenderer.invoke(IPC.VAULT_GET_SETTINGS), setVaultSettings: (next: VaultSettings): Promise<VaultSettings> => ipcRenderer.invoke(IPC.VAULT_SET_SETTINGS, next), + rootContentHiddenByInboxMode: (): Promise<boolean> => + ipcRenderer.invoke(IPC.VAULT_ROOT_CONTENT_HIDDEN), listNotes: (): Promise<NoteMeta[]> => listNotesStreamed(), listNotesPage: (request: ListNotesPageRequest): Promise<ListNotesPageResponse> => @@ -299,7 +301,7 @@ const api: ZenBridge = { scanTasks: (): Promise<VaultTask[]> => ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS), scanTasksForPath: (relPath: string): Promise<VaultTask[]> => ipcRenderer.invoke(IPC.VAULT_SCAN_TASKS_FOR, relPath), - openDatabase: (relPath: string): Promise<DatabaseDoc> => + openDatabase: (relPath: string): Promise<DatabaseDoc | null> => ipcRenderer.invoke(IPC.VAULT_OPEN_DATABASE, relPath), writeDatabaseRows: (relPath: string, rows: DbRow[]): Promise<DatabaseDoc> => ipcRenderer.invoke(IPC.VAULT_WRITE_DATABASE_ROWS, relPath, rows), @@ -311,6 +313,8 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.VAULT_WRITE_DATABASE_SCHEMA, relPath, sidecar, rows), createDatabase: (folder: NoteFolder, subpath: string, title?: string): Promise<DatabaseDoc> => ipcRenderer.invoke(IPC.VAULT_CREATE_DATABASE, folder, subpath, title), + renameDatabase: (csvPath: string, newTitle: string): Promise<string> => + ipcRenderer.invoke(IPC.VAULT_RENAME_DATABASE, csvPath, newTitle), createRecordPage: (csvPath: string, title: string, body: string): Promise<string> => ipcRenderer.invoke(IPC.VAULT_CREATE_RECORD_PAGE, csvPath, title, body), listDatabases: (): Promise<DatabaseSummary[]> => ipcRenderer.invoke(IPC.VAULT_LIST_DATABASES), @@ -320,6 +324,8 @@ const api: ZenBridge = { ipcRenderer.invoke(IPC.VAULT_APPEND_NOTE, relPath, body, position), createNote: (folder: NoteFolder, title?: string, subpath?: string): Promise<NoteMeta> => ipcRenderer.invoke(IPC.VAULT_CREATE_NOTE, folder, title, subpath), + createExcalidraw: (folder: NoteFolder, subpath?: string, title?: string): Promise<NoteMeta> => + ipcRenderer.invoke(IPC.VAULT_CREATE_EXCALIDRAW, folder, subpath, title), renameNote: (relPath: string, nextTitle: string): Promise<NoteMeta> => ipcRenderer.invoke(IPC.VAULT_RENAME_NOTE, relPath, nextTitle), deleteNote: (relPath: string): Promise<void> => ipcRenderer.invoke(IPC.VAULT_DELETE_NOTE, relPath), diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json index 0524e0ff..5b07f59c 100644 --- a/apps/desktop/tsconfig.node.json +++ b/apps/desktop/tsconfig.node.json @@ -5,6 +5,7 @@ "types": ["node", "electron-vite/node"], "baseUrl": ".", "paths": { + "@renderer/*": ["../../packages/app-core/src/*"], "@shared/*": ["../../packages/shared-domain/src/*"], "@bridge-contract/*": ["../../packages/bridge-contract/src/*"] } diff --git a/apps/server/cmd/zennotes-server/main.go b/apps/server/cmd/zennotes-server/main.go index 3d659158..4c86d599 100644 --- a/apps/server/cmd/zennotes-server/main.go +++ b/apps/server/cmd/zennotes-server/main.go @@ -41,10 +41,9 @@ func main() { log.Printf("warning: ignoring legacy vault config at %s; server secrets now stay in host config only", config.LegacyVaultConfigPath(v.Root())) } - w, err := watcher.Start(v.Root()) - if err != nil { - log.Fatalf("watcher start: %v", err) - } + // Never fatal: where inotify is restricted (e.g. unprivileged LXC) the + // watcher falls back to a no-op so the server still serves the vault. (#179) + w := watcher.StartOrDisabled(v.Root(), cfg.DisableWatcher) defer w.Close() dist, err := web.Dist() diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index 60518f1a..b41259f3 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -36,6 +36,10 @@ type Config struct { AllowUnscopedBrowse bool `json:"-"` AllowInsecureNoAuth bool `json:"-"` DevMode bool `json:"-"` + // DisableWatcher turns off the inotify file watcher (ZENNOTES_DISABLE_WATCHER). + // Live updates stop; the vault is still fully served. Useful where inotify is + // restricted and can hang the process (e.g. unprivileged LXC). (#179) + DisableWatcher bool `json:"-"` // Limits and security knobs. MaxAssetBytes int64 `json:"-"` @@ -111,6 +115,7 @@ func Load() Config { cfg.AllowUnscopedBrowse = envEnabled("ZENNOTES_ALLOW_UNSCOPED_BROWSE") cfg.AllowInsecureNoAuth = envEnabled("ZENNOTES_ALLOW_INSECURE_NOAUTH") cfg.DevMode = envEnabled("ZENNOTES_DEV") + cfg.DisableWatcher = envEnabled("ZENNOTES_DISABLE_WATCHER") cfg.BehindTLS = envEnabled("ZENNOTES_BEHIND_TLS") cfg.TrustedProxies = parseCIDRListEnv("ZENNOTES_TRUSTED_PROXIES") if v := parseInt64Env("ZENNOTES_MAX_ASSET_BYTES"); v > 0 { diff --git a/apps/server/internal/httpserver/excalidraw_test.go b/apps/server/internal/httpserver/excalidraw_test.go new file mode 100644 index 00000000..cf2d8ca9 --- /dev/null +++ b/apps/server/internal/httpserver/excalidraw_test.go @@ -0,0 +1,71 @@ +package httpserver + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/ZenNotes/zennotes/apps/server/internal/config" +) + +// TestCreateExcalidrawEndpoint exercises the full HTTP wiring: log in, POST +// /api/excalidraw/create, and confirm the drawing comes back as a `.excalidraw` +// note that then shows up in /api/notes (and not in /api/assets). +func TestCreateExcalidrawEndpoint(t *testing.T) { + root := t.TempDir() + server, _ := newTestServer(t, config.Config{ + VaultPath: root, + DefaultVaultPath: root, + Bind: "127.0.0.1:7878", + AuthToken: "secret-token", + BrowseRoots: []string{root}, + }) + jar := loginAndJar(t, server, "secret-token") + client := &http.Client{Jar: jar} + + body, _ := json.Marshal(map[string]string{"folder": "inbox", "title": "My Sketch"}) + resp, err := client.Post(server.URL+"/api/excalidraw/create", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST /api/excalidraw/create: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("create status: %d", resp.StatusCode) + } + var created struct { + Path string `json:"path"` + Title string `json:"title"` + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + if !strings.HasSuffix(created.Path, ".excalidraw") { + t.Fatalf("created path = %q, want a .excalidraw file", created.Path) + } + if created.Title != "My Sketch" { + t.Errorf("created title = %q, want My Sketch", created.Title) + } + + listResp, err := client.Get(server.URL + "/api/notes") + if err != nil { + t.Fatalf("GET /api/notes: %v", err) + } + defer listResp.Body.Close() + var notes []struct { + Path string `json:"path"` + } + if err := json.NewDecoder(listResp.Body).Decode(¬es); err != nil { + t.Fatalf("decode notes: %v", err) + } + found := false + for _, n := range notes { + if n.Path == created.Path { + found = true + } + } + if !found { + t.Errorf("created drawing %q not returned by /api/notes", created.Path) + } +} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 765751a6..bd822a0c 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -86,10 +86,9 @@ func (s *Server) switchVaultRoot(nextPath string) (*vault.Vault, error) { if err != nil { return nil, err } - nextWatcher, err := watcher.Start(nextVault.Root()) - if err != nil { - return nil, err - } + // Non-fatal: a vault switch must not fail just because inotify is + // unavailable; fall back to a no-op watcher in that case. (#179) + nextWatcher := watcher.StartOrDisabled(nextVault.Root(), cfg.DisableWatcher) s.mu.Lock() prevWatcher := s.Watcher @@ -182,6 +181,7 @@ func (s *Server) registerProtectedRoutes(r chi.Router) { r.Post("/comments/write", s.writeComments) r.Post("/notes/write", s.writeNote) r.Post("/notes/create", s.createNote) + r.Post("/excalidraw/create", s.createExcalidraw) r.Post("/notes/rename", s.renameNote) r.Post("/notes/delete", s.deleteNote) r.Post("/notes/trash", s.trashNote) @@ -593,6 +593,24 @@ func (s *Server) createNote(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, meta) } +func (s *Server) createExcalidraw(w http.ResponseWriter, r *http.Request) { + var req struct { + Folder vault.NoteFolder `json:"folder"` + Title string `json:"title"` + Subpath string `json:"subpath"` + } + if err := readJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + meta, err := s.currentVault().CreateExcalidraw(req.Folder, req.Title, req.Subpath) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, meta) +} + func (s *Server) renameNote(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` diff --git a/apps/server/internal/vault/parse.go b/apps/server/internal/vault/parse.go index 9310b8b9..60d08b65 100644 --- a/apps/server/internal/vault/parse.go +++ b/apps/server/internal/vault/parse.go @@ -12,7 +12,7 @@ import ( var ( fencedBlockRe = regexp.MustCompile("(?m)(^|\n)```[^\n]*\n[\\s\\S]*?\n```[ \t]*(?:\n|$)") inlineCodeRe = regexp.MustCompile("`[^`\n]*`") - tagRe = regexp.MustCompile(`(?:^|\s)#([A-Za-z][\w\-/]*)`) + tagRe = regexp.MustCompile(`(?:^|\s)#(\p{L}[\p{L}\d_/-]*)`) wikilinkRe = regexp.MustCompile(`(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) linkRe = regexp.MustCompile(`(!?)\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`) embedRe = regexp.MustCompile(`!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]`) @@ -199,7 +199,7 @@ var ( inlineDueRe = regexp.MustCompile(`(?i)(?:^|\s)due:(\S+)`) inlinePriority = regexp.MustCompile(`(?i)(?:^|\s)!(high|med|medium|low|h|m|l)\b`) inlineWaitingRe = regexp.MustCompile(`(?i)(?:^|\s)@waiting\b`) - inlineTagRe = regexp.MustCompile(`(?i)(?:^|\s)#([a-z0-9][a-z0-9/_\-]*)`) + inlineTagRe = regexp.MustCompile(`(?:^|\s)#([\p{L}\d][\p{L}\d/_\-]*)`) isoDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) ) diff --git a/apps/server/internal/vault/parse_test.go b/apps/server/internal/vault/parse_test.go index e6ae0887..71c4fff0 100644 --- a/apps/server/internal/vault/parse_test.go +++ b/apps/server/internal/vault/parse_test.go @@ -57,3 +57,18 @@ func TestExtractorsStillIgnoreCodeAfterFastPathGuards(t *testing.T) { t.Fatalf("ExtractWikilinks() = %#v, want [Target]", wikilinks) } } + +// #205: tags in non-Latin scripts (Cyrillic, CJK, …) must be recognized. +func TestExtractTagsUnicode(t *testing.T) { + body := "Заметки: #тест #ошибка/баг и 笔记 #标签 plus #ascii-1 done" + got := ExtractTags(body) + want := map[string]bool{"тест": true, "ошибка/баг": true, "标签": true, "ascii-1": true} + if len(got) != len(want) { + t.Fatalf("ExtractTags() = %#v, want keys %#v", got, want) + } + for _, tag := range got { + if !want[tag] { + t.Fatalf("unexpected tag %q in %#v", tag, got) + } + } +} diff --git a/apps/server/internal/vault/types.go b/apps/server/internal/vault/types.go index 2034b552..6b660c7a 100644 --- a/apps/server/internal/vault/types.go +++ b/apps/server/internal/vault/types.go @@ -20,10 +20,14 @@ const ( FolderArchive NoteFolder = "archive" FolderTrash NoteFolder = "trash" - PrimaryNotesInbox PrimaryNotesLocation = "inbox" - PrimaryNotesRoot PrimaryNotesLocation = "root" - DefaultDailyNotesDirectory = "Daily Notes" - DefaultWeeklyNotesDirectory = "Weekly Notes" + PrimaryNotesInbox PrimaryNotesLocation = "inbox" + PrimaryNotesRoot PrimaryNotesLocation = "root" + DefaultDailyNotesDirectory = "Daily Notes" + DefaultDailyNoteTitlePattern = "yyyy-MM-dd" + DefaultDailyNoteLocale = "system" + DefaultWeeklyNotesDirectory = "Weekly Notes" + DefaultWeeklyNoteTitlePattern = "yyyy-'W'ww" + DefaultWeeklyNoteLocale = "system" ) func IsValidFolder(f NoteFolder) bool { @@ -51,16 +55,33 @@ func FolderForRelativePath(rel string) (NoteFolder, bool) { return FolderInbox, true } +type DateNotePatternSettings struct { + Directory string `json:"directory"` + TitlePattern string `json:"titlePattern,omitempty"` + Locale string `json:"locale,omitempty"` +} + type DailyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TemplateID string `json:"templateId,omitempty"` + Enabled bool `json:"enabled"` + Directory string `json:"directory"` + TitlePattern string `json:"titlePattern,omitempty"` + Locale string `json:"locale,omitempty"` + LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` + TemplateID string `json:"templateId,omitempty"` + // Pointers so an absent field round-trips as "unset" (the TS client applies + // the real default — true for TasksDueOnNoteDate, false for rollover). These + // drive purely client-side behavior; the server only persists them. + TasksDueOnNoteDate *bool `json:"tasksDueOnNoteDate,omitempty"` + RolloverUnfinishedTasks *bool `json:"rolloverUnfinishedTasks,omitempty"` } type WeeklyNotesSettings struct { - Enabled bool `json:"enabled"` - Directory string `json:"directory"` - TemplateID string `json:"templateId,omitempty"` + Enabled bool `json:"enabled"` + Directory string `json:"directory"` + TitlePattern string `json:"titlePattern,omitempty"` + Locale string `json:"locale,omitempty"` + LegacyPatterns []DateNotePatternSettings `json:"legacyPatterns,omitempty"` + TemplateID string `json:"templateId,omitempty"` } type VaultSettings struct { @@ -68,6 +89,9 @@ type VaultSettings struct { DailyNotes DailyNotesSettings `json:"dailyNotes"` WeeklyNotes WeeklyNotesSettings `json:"weeklyNotes"` FolderIcons map[string]FolderIconID `json:"folderIcons"` + // Favorites are note paths or `folder:subpath` keys pinned to the top of + // the sidebar. Persisted so the web client's favorites survive a round-trip. + Favorites []string `json:"favorites"` } // NoteMeta — vault-relative note metadata. Mirrors shared/ipc.ts NoteMeta. diff --git a/apps/server/internal/vault/vault.go b/apps/server/internal/vault/vault.go index f115dd46..769f32fc 100644 --- a/apps/server/internal/vault/vault.go +++ b/apps/server/internal/vault/vault.go @@ -19,6 +19,9 @@ import ( ) const ( + // AssetsDir is the canonical top-level folder for assets; attachements/_assets + // are recognized legacy dirs. Asset migration runs on the desktop (#185). + AssetsDir = "assets" PrimaryAttachmentsDir = "attachements" internalVaultDir = ".zennotes" vaultSettingsFile = "vault.json" @@ -27,18 +30,60 @@ const ( noteCommentsDir = "comments" noteCommentsSuffix = ".comments.json" noteMetaReadLimit = 64 + // formDirSuffix marks a database folder (`<Name>.base/`), a self-contained + // folder holding data.csv, schema.json, and record-page notes. Databases are + // a desktop-only feature; the server hides these folders (it neither serves + // the grid nor exposes the internals as loose notes/assets). + formDirSuffix = ".base" ) +// isFormDirName reports whether a folder name marks a database folder. +func isFormDirName(name string) bool { + return strings.HasSuffix(strings.ToLower(name), formDirSuffix) +} + +// excalidrawExt marks a standalone Excalidraw drawing — the native Excalidraw +// JSON scene format. Drawings are a first-class file type alongside Markdown +// notes: listed in the sidebar (not as assets) and opened in a dedicated editor. +const excalidrawExt = ".excalidraw" + +// isExcalidrawName reports whether a filename is an Excalidraw drawing. +func isExcalidrawName(name string) bool { + return strings.EqualFold(filepath.Ext(name), excalidrawExt) +} + +// noteExt returns the on-disk extension for a note-like file, preserving +// `.excalidraw` for drawings and defaulting to `.md` otherwise. Rename/move/ +// duplicate use it so a drawing never silently becomes a Markdown note. +func noteExt(name string) string { + if isExcalidrawName(name) { + return excalidrawExt + } + return ".md" +} + +// emptyExcalidrawJSON mirrors emptyExcalidrawDocument() in +// packages/shared-domain/src/excalidraw.ts (JSON.stringify, 2-space indent). +const emptyExcalidrawJSON = `{ + "type": "excalidraw", + "version": 2, + "source": "zennotes", + "elements": [], + "appState": {}, + "files": {} +}` + // ErrAssetTooLarge is returned when an asset upload exceeds the // vault's MaxAssetBytes limit. var ErrAssetTooLarge = errors.New("asset exceeds maximum size") -var legacyAttachmentsDirs = []string{"_assets"} +var legacyAttachmentsDirs = []string{PrimaryAttachmentsDir, "_assets"} var reservedRootNames = map[string]struct{}{ string(FolderInbox): {}, string(FolderQuick): {}, string(FolderArchive): {}, string(FolderTrash): {}, + AssetsDir: {}, PrimaryAttachmentsDir: {}, internalVaultDir: {}, } @@ -47,6 +92,7 @@ var hiddenPrimaryRootNames = map[string]struct{}{ string(FolderQuick): {}, string(FolderArchive): {}, string(FolderTrash): {}, + AssetsDir: {}, PrimaryAttachmentsDir: {}, internalVaultDir: {}, } @@ -224,19 +270,34 @@ func cloneSettings(settings VaultSettings) VaultSettings { for key, value := range settings.FolderIcons { folderIcons[key] = value } + favorites := make([]string, len(settings.Favorites)) + copy(favorites, settings.Favorites) + dailyLegacyPatterns := make([]DateNotePatternSettings, len(settings.DailyNotes.LegacyPatterns)) + copy(dailyLegacyPatterns, settings.DailyNotes.LegacyPatterns) + weeklyLegacyPatterns := make([]DateNotePatternSettings, len(settings.WeeklyNotes.LegacyPatterns)) + copy(weeklyLegacyPatterns, settings.WeeklyNotes.LegacyPatterns) return VaultSettings{ PrimaryNotesLocation: settings.PrimaryNotesLocation, DailyNotes: DailyNotesSettings{ - Enabled: settings.DailyNotes.Enabled, - Directory: settings.DailyNotes.Directory, - TemplateID: settings.DailyNotes.TemplateID, + Enabled: settings.DailyNotes.Enabled, + Directory: settings.DailyNotes.Directory, + TitlePattern: settings.DailyNotes.TitlePattern, + Locale: settings.DailyNotes.Locale, + LegacyPatterns: dailyLegacyPatterns, + TemplateID: settings.DailyNotes.TemplateID, + TasksDueOnNoteDate: settings.DailyNotes.TasksDueOnNoteDate, + RolloverUnfinishedTasks: settings.DailyNotes.RolloverUnfinishedTasks, }, WeeklyNotes: WeeklyNotesSettings{ - Enabled: settings.WeeklyNotes.Enabled, - Directory: settings.WeeklyNotes.Directory, - TemplateID: settings.WeeklyNotes.TemplateID, + Enabled: settings.WeeklyNotes.Enabled, + Directory: settings.WeeklyNotes.Directory, + TitlePattern: settings.WeeklyNotes.TitlePattern, + Locale: settings.WeeklyNotes.Locale, + LegacyPatterns: weeklyLegacyPatterns, + TemplateID: settings.WeeklyNotes.TemplateID, }, FolderIcons: folderIcons, + Favorites: favorites, } } @@ -248,6 +309,22 @@ func normalizeDailyNotesDirectory(value string) string { return trimmed } +func normalizeDailyNoteTitlePattern(value string) string { + trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) + if trimmed == "" { + return DefaultDailyNoteTitlePattern + } + return trimmed +} + +func normalizeDailyNoteLocale(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return DefaultDailyNoteLocale + } + return trimmed +} + func normalizeWeeklyNotesDirectory(value string) string { trimmed := strings.Trim(value, "/") if trimmed == "" { @@ -256,6 +333,60 @@ func normalizeWeeklyNotesDirectory(value string) string { return trimmed } +func normalizeWeeklyNoteTitlePattern(value string) string { + trimmed := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(value)) + if trimmed == "" { + return DefaultWeeklyNoteTitlePattern + } + return trimmed +} + +func normalizeWeeklyNoteLocale(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return DefaultWeeklyNoteLocale + } + return trimmed +} + +func normalizeDailyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { + out := []DateNotePatternSettings{} + seen := map[string]bool{} + for _, pattern := range value { + next := DateNotePatternSettings{ + Directory: normalizeDailyNotesDirectory(pattern.Directory), + TitlePattern: normalizeDailyNoteTitlePattern(pattern.TitlePattern), + Locale: normalizeDailyNoteLocale(pattern.Locale), + } + key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale + if seen[key] { + continue + } + seen[key] = true + out = append(out, next) + } + return out +} + +func normalizeWeeklyNoteLegacyPatterns(value []DateNotePatternSettings) []DateNotePatternSettings { + out := []DateNotePatternSettings{} + seen := map[string]bool{} + for _, pattern := range value { + next := DateNotePatternSettings{ + Directory: normalizeWeeklyNotesDirectory(pattern.Directory), + TitlePattern: normalizeWeeklyNoteTitlePattern(pattern.TitlePattern), + Locale: normalizeWeeklyNoteLocale(pattern.Locale), + } + key := next.Directory + "\x00" + next.TitlePattern + "\x00" + next.Locale + if seen[key] { + continue + } + seen[key] = true + out = append(out, next) + } + return out +} + func normalizePrimaryNotesLocation(value PrimaryNotesLocation) PrimaryNotesLocation { if value == PrimaryNotesRoot { return PrimaryNotesRoot @@ -282,19 +413,44 @@ func normalizeVaultSettings(value VaultSettings, fallbackPrimary PrimaryNotesLoc return value.PrimaryNotesLocation }()), DailyNotes: DailyNotesSettings{ - Enabled: value.DailyNotes.Enabled, - Directory: normalizeDailyNotesDirectory(value.DailyNotes.Directory), - TemplateID: value.DailyNotes.TemplateID, + Enabled: value.DailyNotes.Enabled, + Directory: normalizeDailyNotesDirectory(value.DailyNotes.Directory), + TitlePattern: normalizeDailyNoteTitlePattern(value.DailyNotes.TitlePattern), + Locale: normalizeDailyNoteLocale(value.DailyNotes.Locale), + LegacyPatterns: normalizeDailyNoteLegacyPatterns(value.DailyNotes.LegacyPatterns), + TemplateID: value.DailyNotes.TemplateID, + TasksDueOnNoteDate: value.DailyNotes.TasksDueOnNoteDate, + RolloverUnfinishedTasks: value.DailyNotes.RolloverUnfinishedTasks, }, WeeklyNotes: WeeklyNotesSettings{ - Enabled: value.WeeklyNotes.Enabled, - Directory: normalizeWeeklyNotesDirectory(value.WeeklyNotes.Directory), - TemplateID: value.WeeklyNotes.TemplateID, + Enabled: value.WeeklyNotes.Enabled, + Directory: normalizeWeeklyNotesDirectory(value.WeeklyNotes.Directory), + TitlePattern: normalizeWeeklyNoteTitlePattern(value.WeeklyNotes.TitlePattern), + Locale: normalizeWeeklyNoteLocale(value.WeeklyNotes.Locale), + LegacyPatterns: normalizeWeeklyNoteLegacyPatterns(value.WeeklyNotes.LegacyPatterns), + TemplateID: value.WeeklyNotes.TemplateID, }, FolderIcons: folderIcons, + Favorites: normalizeFavorites(value.Favorites), } } +func normalizeFavorites(value []string) []string { + out := []string{} + seen := map[string]struct{}{} + for _, entry := range value { + if entry == "" { + continue + } + if _, ok := seen[entry]; ok { + continue + } + seen[entry] = struct{}{} + out = append(out, entry) + } + return out +} + func folderIconKey(folder NoteFolder, subpath string) string { return fmt.Sprintf("%s:%s", folder, subpath) } @@ -386,7 +542,7 @@ func (v *Vault) inferPrimaryNotesLocation() PrimaryNotesLocation { if _, reserved := reservedRootNames[name]; reserved { continue } - if entry.IsDir() || strings.EqualFold(filepath.Ext(name), ".md") { + if entry.IsDir() || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { return PrimaryNotesRoot } } @@ -628,6 +784,15 @@ func (v *Vault) persistNoteMetaCacheSnapshot(metas []NoteMeta) { // ListNotes walks every top-level folder and returns metadata for each // note. Sibling order is the directory-listing order per folder, which // matches the TS version's behaviour for non-sorted filesystems. +// isSkippableWalkErr reports whether a directory-walk error should skip the +// offending entry and keep scanning, rather than aborting the whole vault scan. +// Covers entries that vanished mid-scan and, importantly for self-hosted +// servers, entries the process lacks permission to read (e.g. a vault copied in +// with root-owned files while the container runs as a non-root user). (#159) +func isSkippableWalkErr(err error) bool { + return errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) +} + func (v *Vault) ListNotes() ([]NoteMeta, error) { v.mu.RLock() defer v.mu.RUnlock() @@ -647,7 +812,7 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { if err != nil { - if errors.Is(err, os.ErrNotExist) { + if isSkippableWalkErr(err) { return nil } return err @@ -656,6 +821,9 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { if strings.HasPrefix(d.Name(), ".") && path != folderRoot { return filepath.SkipDir } + if isFormDirName(d.Name()) { + return filepath.SkipDir // database folder — not loose notes + } if isPrimaryRoot && path != folderRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == filepath.Clean(folderRoot) { @@ -674,7 +842,7 @@ func (v *Vault) ListNotes() ([]NoteMeta, error) { } } } - if !strings.EqualFold(filepath.Ext(d.Name()), ".md") { + if !strings.EqualFold(filepath.Ext(d.Name()), ".md") && !isExcalidrawName(d.Name()) { return nil } files = append(files, noteFile{folder: folder, path: path}) @@ -746,7 +914,7 @@ func (v *Vault) ListFolders() ([]FolderEntry, error) { isPrimaryRoot := folder == FolderInbox && filepath.Clean(folderRoot) == filepath.Clean(v.root) err = filepath.WalkDir(folderRoot, func(path string, d os.DirEntry, err error) error { if err != nil { - if errors.Is(err, os.ErrNotExist) { + if isSkippableWalkErr(err) { return nil } return err @@ -776,6 +944,11 @@ func (v *Vault) ListFolders() ([]FolderEntry, error) { Folder: folder, Subpath: filepath.ToSlash(rel), }) + // A `<Name>.base` database folder is listed (the renderer shows it as + // a database) but its internals are not exposed as folders. + if isFormDirName(d.Name()) { + return filepath.SkipDir + } return nil }) if err != nil { @@ -800,9 +973,6 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { v.mu.RLock() defer v.mu.RUnlock() out := []AssetMeta{} - isSkippableWalkErr := func(err error) bool { - return errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) - } var walk func(dir string) error walk = func(dir string) error { entries, err := os.ReadDir(dir) @@ -822,6 +992,9 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { if filepath.Clean(dir) == filepath.Clean(v.root) && name == internalVaultDir { continue } + if isFormDirName(name) { + continue // database folder — its data.csv/schema.json aren't assets + } if err := walk(full); err != nil { if isSkippableWalkErr(err) { continue @@ -830,7 +1003,7 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { } continue } - if !entry.Type().IsRegular() || strings.EqualFold(filepath.Ext(name), ".md") { + if !entry.Type().IsRegular() || strings.EqualFold(filepath.Ext(name), ".md") || isExcalidrawName(name) { continue } info, err := entry.Info() @@ -864,7 +1037,7 @@ func (v *Vault) ListAssets() ([]AssetMeta, error) { func (v *Vault) HasAssetsDir() bool { v.mu.RLock() defer v.mu.RUnlock() - for _, dir := range append([]string{PrimaryAttachmentsDir}, legacyAttachmentsDirs...) { + for _, dir := range append([]string{AssetsDir}, legacyAttachmentsDirs...) { info, err := os.Stat(filepath.Join(v.root, dir)) if err == nil && info.IsDir() { return true @@ -889,6 +1062,30 @@ func kindForExt(ext string) string { // --- Read / Write --- +// buildNoteMeta assembles NoteMeta for a note-like file. Excalidraw drawings +// store JSON, not Markdown, so their tags/wikilinks/excerpt are skipped — a hex +// color like "#1971c2" in the scene must not register as a #tag. +func buildNoteMeta(relPosix, title string, folder NoteFolder, info os.FileInfo, bodyStr string) NoteMeta { + meta := NoteMeta{ + Path: relPosix, + Title: title, + Folder: folder, + CreatedAt: info.ModTime().UnixMilli(), + UpdatedAt: info.ModTime().UnixMilli(), + Size: info.Size(), + Tags: []string{}, + Wikilinks: []string{}, + } + if isExcalidrawName(relPosix) { + return meta + } + meta.Tags = ExtractTags(bodyStr) + meta.Wikilinks = ExtractWikilinks(bodyStr) + meta.HasAttachments = BodyHasLocalAsset(bodyStr) + meta.Excerpt = BuildExcerpt(bodyStr) + return meta +} + func (v *Vault) readMeta(folder NoteFolder, abs string) (NoteMeta, error) { info, err := os.Stat(abs) if err != nil { @@ -921,18 +1118,7 @@ func (v *Vault) readMeta(folder NoteFolder, abs string) (NoteMeta, error) { title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - meta := NoteMeta{ - Path: relPosix, - Title: title, - Folder: folder, - CreatedAt: info.ModTime().UnixMilli(), - UpdatedAt: info.ModTime().UnixMilli(), - Size: info.Size(), - Tags: ExtractTags(bodyStr), - Wikilinks: ExtractWikilinks(bodyStr), - HasAttachments: BodyHasLocalAsset(bodyStr), - Excerpt: BuildExcerpt(bodyStr), - } + meta := buildNoteMeta(relPosix, title, folder, info, bodyStr) v.metaCacheMu.Lock() v.metaCache[abs] = noteMetaCacheEntry{ mtimeMs: statMtimeMs, @@ -962,18 +1148,7 @@ func (v *Vault) ReadNote(rel string) (NoteContent, error) { bodyStr := string(body) rel = filepath.ToSlash(rel) title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - meta := NoteMeta{ - Path: rel, - Title: title, - Folder: folder, - CreatedAt: info.ModTime().UnixMilli(), - UpdatedAt: info.ModTime().UnixMilli(), - Size: info.Size(), - Tags: ExtractTags(bodyStr), - Wikilinks: ExtractWikilinks(bodyStr), - HasAttachments: BodyHasLocalAsset(bodyStr), - Excerpt: BuildExcerpt(bodyStr), - } + meta := buildNoteMeta(rel, title, folder, info, bodyStr) return NoteContent{NoteMeta: meta, Body: bodyStr}, nil } @@ -1253,6 +1428,40 @@ func (v *Vault) CreateNote(folder NoteFolder, title, subpath string) (NoteMeta, return v.readMeta(folder, abs) } +// CreateExcalidraw writes a new empty `.excalidraw` drawing under folder/subpath +// and returns its meta. Mirrors CreateNote but seeds an empty Excalidraw scene. +func (v *Vault) CreateExcalidraw(folder NoteFolder, title, subpath string) (NoteMeta, error) { + v.mu.Lock() + defer v.mu.Unlock() + if !IsValidFolder(folder) { + return NoteMeta{}, fmt.Errorf("invalid folder: %s", folder) + } + if title == "" { + title = defaultTitle() + } + title = sanitizeFileStem(title) + dir, err := v.folderRoot(folder) + if err != nil { + return NoteMeta{}, err + } + if subpath != "" { + sub, err := SafeJoin(dir, subpath) + if err != nil { + return NoteMeta{}, err + } + dir = sub + } + if err := os.MkdirAll(dir, v.dirMode); err != nil { + return NoteMeta{}, err + } + abs := uniquePath(dir, title, excalidrawExt) + if err := os.WriteFile(abs, []byte(emptyExcalidrawJSON), v.fileMode); err != nil { + return NoteMeta{}, err + } + v.invalidateTextSearchCache() + return v.readMeta(folder, abs) +} + func (v *Vault) RenameNote(rel, nextTitle string) (NoteMeta, error) { // Snapshot the vault before the rename (ListNotes takes its own read lock) // so inbound [[wikilinks]] still resolve to this note under its current name. @@ -1281,7 +1490,7 @@ func (v *Vault) renameNoteFile(rel, nextTitle string) (NoteMeta, error) { return NoteMeta{}, errors.New("empty title") } dir := filepath.Dir(abs) - newAbs := uniquePath(dir, nextTitle, ".md") + newAbs := uniquePath(dir, nextTitle, noteExt(abs)) if err := os.Rename(abs, newAbs); err != nil { return NoteMeta{}, err } @@ -1401,7 +1610,7 @@ func (v *Vault) moveBetweenFolders(rel string, target NoteFolder) (NoteMeta, err if err := os.MkdirAll(destDir, v.dirMode); err != nil { return NoteMeta{}, err } - newAbs := uniquePath(destDir, title, ".md") + newAbs := uniquePath(destDir, title, noteExt(abs)) if err := os.Rename(abs, newAbs); err != nil { return NoteMeta{}, err } @@ -1441,7 +1650,7 @@ func (v *Vault) DuplicateNote(rel string) (NoteMeta, error) { } folder, _ := v.folderOf(abs) title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) + " copy" - newAbs := uniquePath(filepath.Dir(abs), sanitizeFileStem(title), ".md") + newAbs := uniquePath(filepath.Dir(abs), sanitizeFileStem(title), noteExt(abs)) if err := copyFile(abs, newAbs, v.fileMode); err != nil { return NoteMeta{}, err } @@ -1481,7 +1690,7 @@ func (v *Vault) MoveNote(rel string, target NoteFolder, targetSubpath string) (N return NoteMeta{}, err } title := strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)) - newAbs := uniquePath(destDir, title, ".md") + newAbs := uniquePath(destDir, title, noteExt(abs)) if err := os.Rename(abs, newAbs); err != nil { return NoteMeta{}, err } @@ -1546,6 +1755,9 @@ func (v *Vault) RenameFolder(folder NoteFolder, oldSub, newSub string) (string, DailyNotes: settings.DailyNotes, WeeklyNotes: settings.WeeklyNotes, FolderIcons: rewriteFolderIconsForRename(settings.FolderIcons, folder, oldSub, newSub), + // Favorites are carried through verbatim; the client rewrites stale + // favorite keys after the rename and re-persists them. + Favorites: settings.Favorites, }) if err != nil { return "", err @@ -1581,6 +1793,9 @@ func (v *Vault) DeleteFolder(folder NoteFolder, subpath string) error { DailyNotes: settings.DailyNotes, WeeklyNotes: settings.WeeklyNotes, FolderIcons: removeFolderIcons(settings.FolderIcons, folder, subpath), + // Favorites are carried through verbatim; the client prunes the deleted + // folder's favorites and re-persists them. + Favorites: settings.Favorites, }) return err } @@ -1614,6 +1829,8 @@ func (v *Vault) DuplicateFolder(folder NoteFolder, subpath string) (string, erro DailyNotes: settings.DailyNotes, WeeklyNotes: settings.WeeklyNotes, FolderIcons: duplicateFolderIcons(settings.FolderIcons, folder, subpath, relPath), + // A duplicated folder isn't auto-favorited; carry existing favorites through. + Favorites: settings.Favorites, }) if err != nil { return "", err @@ -1641,6 +1858,9 @@ func (v *Vault) ScanTasks() ([]Task, error) { if strings.HasPrefix(d.Name(), ".") && path != folderRoot { return filepath.SkipDir } + if isFormDirName(d.Name()) { + return filepath.SkipDir // database folder — not loose notes + } if isPrimaryRoot && path != folderRoot { parent := filepath.Dir(path) if filepath.Clean(parent) == filepath.Clean(folderRoot) { diff --git a/apps/server/internal/vault/vault_test.go b/apps/server/internal/vault/vault_test.go index b884e751..ff1a1b34 100644 --- a/apps/server/internal/vault/vault_test.go +++ b/apps/server/internal/vault/vault_test.go @@ -549,8 +549,20 @@ func TestVaultSettingsWeeklyNotesRoundTrip(t *testing.T) { // the toggle always reverted after a reload. (#117) if _, err := v.SetSettings(VaultSettings{ PrimaryNotesLocation: PrimaryNotesInbox, - DailyNotes: DailyNotesSettings{Enabled: true, Directory: "Daily", TemplateID: "daily-tmpl"}, - WeeklyNotes: WeeklyNotesSettings{Enabled: true, Directory: "My Weeks", TemplateID: "weekly-tmpl"}, + DailyNotes: DailyNotesSettings{ + Enabled: true, + Directory: "Daily", + TitlePattern: "yyyy-MM-dd-EEE", + Locale: "pt-BR", + TemplateID: "daily-tmpl", + }, + WeeklyNotes: WeeklyNotesSettings{ + Enabled: true, + Directory: "My Weeks", + TitlePattern: "yyyy-'W'ww-EEE", + Locale: "en-US", + TemplateID: "weekly-tmpl", + }, }); err != nil { t.Fatal(err) } @@ -568,9 +580,21 @@ func TestVaultSettingsWeeklyNotesRoundTrip(t *testing.T) { if got.WeeklyNotes.TemplateID != "weekly-tmpl" { t.Errorf("weekly templateId = %q, want %q", got.WeeklyNotes.TemplateID, "weekly-tmpl") } + if got.WeeklyNotes.TitlePattern != "yyyy-'W'ww-EEE" { + t.Errorf("weekly titlePattern = %q, want %q", got.WeeklyNotes.TitlePattern, "yyyy-'W'ww-EEE") + } + if got.WeeklyNotes.Locale != "en-US" { + t.Errorf("weekly locale = %q, want %q", got.WeeklyNotes.Locale, "en-US") + } if got.DailyNotes.TemplateID != "daily-tmpl" { t.Errorf("daily templateId = %q, want %q", got.DailyNotes.TemplateID, "daily-tmpl") } + if got.DailyNotes.TitlePattern != "yyyy-MM-dd-EEE" { + t.Errorf("daily titlePattern = %q, want %q", got.DailyNotes.TitlePattern, "yyyy-MM-dd-EEE") + } + if got.DailyNotes.Locale != "pt-BR" { + t.Errorf("daily locale = %q, want %q", got.DailyNotes.Locale, "pt-BR") + } // The key must actually reach vault.json — the original bug was that it // never hit disk. @@ -597,3 +621,381 @@ func TestVaultSettingsWeeklyNotesRoundTrip(t *testing.T) { t.Errorf("empty weekly directory = %q, want default %q", got.WeeklyNotes.Directory, DefaultWeeklyNotesDirectory) } } + +// The web client drives the implicit-due and task-rollover behavior off two +// daily-notes booleans. They are pointers so "absent" round-trips as unset +// (the TS client applies the real default); an explicit value must survive a +// SetSettings -> GetSettings round-trip and reach vault.json, or the web +// toggles would silently revert like #117. +func TestVaultSettingsDailyTaskFlagsRoundTrip(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + + yes := true + no := false + if _, err := v.SetSettings(VaultSettings{ + PrimaryNotesLocation: PrimaryNotesInbox, + DailyNotes: DailyNotesSettings{ + Enabled: true, + Directory: "Daily", + TasksDueOnNoteDate: &no, // explicitly turn the default (true) OFF + RolloverUnfinishedTasks: &yes, + }, + }); err != nil { + t.Fatal(err) + } + + got, err := v.GetSettings() + if err != nil { + t.Fatal(err) + } + if got.DailyNotes.TasksDueOnNoteDate == nil || *got.DailyNotes.TasksDueOnNoteDate != false { + t.Errorf("tasksDueOnNoteDate = %v, want explicit false", got.DailyNotes.TasksDueOnNoteDate) + } + if got.DailyNotes.RolloverUnfinishedTasks == nil || *got.DailyNotes.RolloverUnfinishedTasks != true { + t.Errorf("rolloverUnfinishedTasks = %v, want explicit true", got.DailyNotes.RolloverUnfinishedTasks) + } + + raw, err := os.ReadFile(v.settingsPath()) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(raw, []byte("tasksDueOnNoteDate")) { + t.Errorf("vault.json missing tasksDueOnNoteDate key:\n%s", raw) + } + if !bytes.Contains(raw, []byte("rolloverUnfinishedTasks")) { + t.Errorf("vault.json missing rolloverUnfinishedTasks key:\n%s", raw) + } + + // Absent pointers must stay nil (omitted) so the client default wins. + if _, err := v.SetSettings(VaultSettings{ + PrimaryNotesLocation: PrimaryNotesInbox, + DailyNotes: DailyNotesSettings{Enabled: true, Directory: "Daily"}, + }); err != nil { + t.Fatal(err) + } + got, err = v.GetSettings() + if err != nil { + t.Fatal(err) + } + if got.DailyNotes.TasksDueOnNoteDate != nil { + t.Errorf("absent tasksDueOnNoteDate = %v, want nil", *got.DailyNotes.TasksDueOnNoteDate) + } +} + +// A file or directory the server can't read must be skipped, not abort the whole +// vault scan — otherwise one root-owned entry hides the entire vault. (#159) +func TestListSkipsUnreadableEntries(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission bits don't apply on Windows") + } + if os.Geteuid() == 0 { + t.Skip("permission errors are bypassed when running as root") + } + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, err := v.WriteNote("inbox/Readable.md", "ok"); err != nil { + t.Fatalf("WriteNote readable: %v", err) + } + if _, err := v.WriteNote("inbox/Locked/Secret.md", "secret"); err != nil { + t.Fatalf("WriteNote locked: %v", err) + } + + // Make the subfolder unreadable, simulating a root-owned dir the non-root + // server process can't read. Locate it by name so this is mode-independent. + var locked string + _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err == nil && d.IsDir() && d.Name() == "Locked" { + locked = p + } + return nil + }) + if locked == "" { + t.Fatal("could not locate the Locked subfolder on disk") + } + if err := os.Chmod(locked, 0o000); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + + notes, err := v.ListNotes() + if err != nil { + t.Fatalf("ListNotes aborted instead of skipping the unreadable dir: %v", err) + } + var sawReadable, sawSecret bool + for _, n := range notes { + if strings.Contains(n.Path, "Readable.md") { + sawReadable = true + } + if strings.Contains(n.Path, "Secret.md") { + sawSecret = true + } + } + if !sawReadable { + t.Errorf("readable note missing from %d listed notes", len(notes)) + } + if sawSecret { + t.Error("note inside the unreadable dir should have been skipped") + } + + if _, err := v.ListFolders(); err != nil { + t.Fatalf("ListFolders aborted instead of skipping the unreadable dir: %v", err) + } +} + +func TestDatabaseBaseFolderListedButInternalsHidden(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + // A database folder with its internals, a record-page note, and a nested dir + // (its internals + nested dirs must NOT surface as folders). + baseDir := filepath.Join(root, "inbox", "Books.base") + if err := os.MkdirAll(filepath.Join(baseDir, "pages"), 0o700); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "data.csv": "id,Title\nr1,Dune\n", + "schema.json": `{"version":1}`, + "Dune.md": "# Dune", + } { + if err := os.WriteFile(filepath.Join(baseDir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + // A regular note + folder that MUST still surface. + if err := os.WriteFile(filepath.Join(root, "inbox", "Regular.md"), []byte("# Hi"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "inbox", "RealFolder"), 0o700); err != nil { + t.Fatal(err) + } + + notes, err := v.ListNotes() + if err != nil { + t.Fatal(err) + } + for _, n := range notes { + if strings.Contains(n.Path, ".base") { + t.Errorf("ListNotes leaked a database-internal note: %s", n.Path) + } + } + if !hasNotePath(notes, "inbox/Regular.md") { + t.Error("ListNotes dropped a regular note") + } + + folders, err := v.ListFolders() + if err != nil { + t.Fatal(err) + } + sawReal := false + sawBase := false + for _, f := range folders { + // The database folder itself lists (renderer renders it as a database)... + if f.Subpath == "Books.base" { + sawBase = true + continue + } + // ...but nothing INSIDE it (e.g. Books.base/pages) is exposed as a folder. + if strings.Contains(f.Subpath, ".base/") { + t.Errorf("ListFolders leaked a database-internal folder: %s", f.Subpath) + } + if f.Subpath == "RealFolder" { + sawReal = true + } + } + if !sawBase { + t.Error("ListFolders should list the .base database folder itself") + } + if !sawReal { + t.Error("ListFolders dropped a regular folder") + } + + assets, err := v.ListAssets() + if err != nil { + t.Fatal(err) + } + for _, a := range assets { + if strings.Contains(a.Path, ".base") { + t.Errorf("ListAssets leaked a database-internal file: %s", a.Path) + } + } +} + +func hasNotePath(notes []NoteMeta, path string) bool { + for _, n := range notes { + if n.Path == path { + return true + } + } + return false +} + +func TestFavoritesRoundTripAndDedupe(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + saved, err := v.SetSettings(VaultSettings{ + // Mix of a note path and a folder key, with a duplicate and an empty entry. + Favorites: []string{"inbox/Idea.md", "inbox:Projects", "inbox/Idea.md", ""}, + }) + if err != nil { + t.Fatal(err) + } + want := []string{"inbox/Idea.md", "inbox:Projects"} + if len(saved.Favorites) != len(want) { + t.Fatalf("favorites = %v, want %v", saved.Favorites, want) + } + for i, f := range want { + if saved.Favorites[i] != f { + t.Errorf("favorites[%d] = %q, want %q", i, saved.Favorites[i], f) + } + } + // Persisted to disk and reloaded. + reloaded, err := v.GetSettings() + if err != nil { + t.Fatal(err) + } + if len(reloaded.Favorites) != len(want) { + t.Errorf("reloaded favorites = %v, want %v", reloaded.Favorites, want) + } +} + +func TestFavoritesSurviveFolderRename(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { + t.Fatal(err) + } + if _, err := v.SetSettings(VaultSettings{Favorites: []string{"inbox:Projects", "inbox/Idea.md"}}); err != nil { + t.Fatal(err) + } + if _, err := v.RenameFolder("inbox", "Projects", "Work"); err != nil { + t.Fatal(err) + } + // The server carries favorites through verbatim (the client rewrites keys). + settings, err := v.GetSettings() + if err != nil { + t.Fatal(err) + } + if len(settings.Favorites) != 2 { + t.Fatalf("folder rename dropped favorites: %v", settings.Favorites) + } +} + +func TestExcalidrawListedAsNoteNotAsset(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + // A drawing whose JSON body contains a hex color (#1971c2) that must NOT + // be mistaken for a #tag, plus an image that should stay an asset. + scene := `{"type":"excalidraw","version":2,"elements":[{"strokeColor":"#1971c2"}],"appState":{},"files":{}}` + if err := os.WriteFile(filepath.Join(root, "inbox", "Sketch.excalidraw"), []byte(scene), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "assets"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "assets", "pic.png"), []byte("PNG"), 0o600); err != nil { + t.Fatal(err) + } + + notes, err := v.ListNotes() + if err != nil { + t.Fatal(err) + } + if !hasNotePath(notes, "inbox/Sketch.excalidraw") { + t.Error("ListNotes dropped the .excalidraw drawing") + } + for _, n := range notes { + if n.Path == "inbox/Sketch.excalidraw" { + if n.Title != "Sketch" { + t.Errorf("drawing title = %q, want Sketch", n.Title) + } + if len(n.Tags) != 0 { + t.Errorf("drawing leaked tags from JSON hex colors: %v", n.Tags) + } + } + } + + assets, err := v.ListAssets() + if err != nil { + t.Fatal(err) + } + sawImage := false + for _, a := range assets { + if strings.HasSuffix(a.Path, ".excalidraw") { + t.Errorf("ListAssets leaked a drawing: %s", a.Path) + } + if a.Path == "assets/pic.png" { + sawImage = true + } + } + if !sawImage { + t.Error("ListAssets dropped a real asset") + } +} + +func TestCreateExcalidrawSeedsEmptyScene(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + meta, err := v.CreateExcalidraw(FolderInbox, "My Drawing", "") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(meta.Path, ".excalidraw") { + t.Errorf("created path = %q, want a .excalidraw file", meta.Path) + } + content, err := v.ReadNote(meta.Path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(content.Body, `"type": "excalidraw"`) { + t.Errorf("seeded scene missing excalidraw type: %s", content.Body) + } +} + +func TestRenameAndMovePreserveExcalidrawExt(t *testing.T) { + root := t.TempDir() + v, err := New(root, Options{}) + if err != nil { + t.Fatal(err) + } + created, err := v.CreateExcalidraw(FolderInbox, "Diagram", "") + if err != nil { + t.Fatal(err) + } + + renamed, err := v.RenameNote(created.Path, "Flowchart") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(renamed.Path, ".excalidraw") { + t.Errorf("rename dropped the extension: %q", renamed.Path) + } + + moved, err := v.MoveNote(renamed.Path, FolderArchive, "") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(moved.Path, ".excalidraw") { + t.Errorf("move dropped the extension: %q", moved.Path) + } +} diff --git a/apps/server/internal/watcher/watcher.go b/apps/server/internal/watcher/watcher.go index a0c7ed73..50abd8c4 100644 --- a/apps/server/internal/watcher/watcher.go +++ b/apps/server/internal/watcher/watcher.go @@ -48,6 +48,8 @@ func Start(root string) (*Watcher, error) { dirs: map[string]struct{}{}, } // Recursively add all existing directories under the vault. + var addErrs int + var firstAddErr error _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return nil @@ -57,15 +59,60 @@ func Start(root string) (*Watcher, error) { if path != root && strings.HasPrefix(name, ".") && name != internalVaultDir { return filepath.SkipDir } - _ = fsw.Add(path) + // Don't discard the error: inotify can be exhausted or restricted + // (notably in unprivileged LXC containers), and a silent failure + // leaves clients without live updates for no apparent reason. (#179) + if addErr := fsw.Add(path); addErr != nil { + addErrs++ + if firstAddErr == nil { + firstAddErr = addErr + } + } w.dirs[path] = struct{}{} } return nil }) + if addErrs > 0 { + log.Printf("watcher: could not watch %d director(ies) (first error: %v); live updates may be incomplete — set ZENNOTES_DISABLE_WATCHER=1 if this environment restricts inotify (e.g. unprivileged LXC)", addErrs, firstAddErr) + } go w.loop() return w, nil } +// Disabled returns a watcher that does no filesystem watching. It still +// supports Subscribe/Close so the rest of the server can treat it like a real +// watcher; it simply never emits change events. Used where inotify is +// unavailable or explicitly turned off — notably unprivileged LXC containers, +// where inotify operations can wedge the process (unkillable, bind-mount +// locked) instead of returning an error. (#179) +func Disabled(root string) *Watcher { + w := &Watcher{ + root: root, + fs: nil, + subs: map[chan vault.ChangeEvent]struct{}{}, + stopCh: make(chan struct{}), + dirs: map[string]struct{}{}, + } + go w.loop() + return w +} + +// StartOrDisabled starts a real watcher, or falls back to a no-op watcher when +// watching is turned off (disable) or unavailable. It never returns an error, +// so the server can always serve the vault even where inotify is restricted. (#179) +func StartOrDisabled(root string, disable bool) *Watcher { + if disable { + log.Printf("watcher: disabled via ZENNOTES_DISABLE_WATCHER; live updates are off") + return Disabled(root) + } + w, err := Start(root) + if err != nil { + log.Printf("watcher: unavailable (%v); continuing without live updates — set ZENNOTES_DISABLE_WATCHER=1 to disable watching explicitly", err) + return Disabled(root) + } + return w +} + func (w *Watcher) Subscribe() (<-chan vault.ChangeEvent, func()) { ch := make(chan vault.ChangeEvent, 64) w.mu.Lock() @@ -94,10 +141,17 @@ func (w *Watcher) Close() { close(ch) } w.mu.Unlock() - _ = w.fs.Close() + if w.fs != nil { + _ = w.fs.Close() + } } func (w *Watcher) loop() { + // A disabled (no-op) watcher has no fsnotify handle — just block until close. + if w.fs == nil { + <-w.stopCh + return + } for { select { case <-w.stopCh: @@ -144,7 +198,9 @@ func (w *Watcher) handle(ev fsnotify.Event) { info, statErr := os.Stat(ev.Name) if statErr == nil && info.IsDir() { if ev.Op&fsnotify.Create != 0 { - _ = w.fs.Add(ev.Name) + if err := w.fs.Add(ev.Name); err != nil { + log.Printf("watcher: cannot watch new directory %s: %v", ev.Name, err) + } w.dirs[ev.Name] = struct{}{} // An empty folder produces no note event, so clients would never // learn about it until a manual refresh. Surface it explicitly. @@ -200,7 +256,9 @@ func (w *Watcher) handle(ev fsnotify.Event) { } folder, ok := vault.FolderForRelativePath(relPosix) if !ok { - if relPosix == vault.PrimaryAttachmentsDir || + if relPosix == vault.AssetsDir || + strings.HasPrefix(relPosix, vault.AssetsDir+"/") || + relPosix == vault.PrimaryAttachmentsDir || strings.HasPrefix(relPosix, vault.PrimaryAttachmentsDir+"/") || relPosix == "_assets" || strings.HasPrefix(relPosix, "_assets/") { diff --git a/apps/server/internal/watcher/watcher_test.go b/apps/server/internal/watcher/watcher_test.go index 2f456ed6..68f36fd8 100644 --- a/apps/server/internal/watcher/watcher_test.go +++ b/apps/server/internal/watcher/watcher_test.go @@ -77,6 +77,46 @@ func TestWatcherBroadcastsFolderCreateAndRemove(t *testing.T) { } } +func TestDisabledWatcherIsNoop(t *testing.T) { + root := t.TempDir() + w := Disabled(root) + if w.fs != nil { + t.Fatal("disabled watcher should have no fsnotify handle") + } + ch, unsub := w.Subscribe() + defer unsub() + + // Creating a directory must NOT produce an event — nothing is watched. + if err := os.MkdirAll(filepath.Join(root, "inbox", "Projects"), 0o700); err != nil { + t.Fatal(err) + } + select { + case ev := <-ch: + t.Fatalf("disabled watcher emitted an event: %+v", ev) + case <-time.After(100 * time.Millisecond): + // Expected: a no-op watcher never emits. + } + + // Close must be safe even though there is no fsnotify handle to close. + w.Close() +} + +func TestStartOrDisabledFallsBackWhenDisabled(t *testing.T) { + root := t.TempDir() + + disabled := StartOrDisabled(root, true) + if disabled.fs != nil { + t.Error("StartOrDisabled(_, true) should return a no-op watcher") + } + disabled.Close() + + enabled := StartOrDisabled(root, false) + if enabled.fs == nil { + t.Error("StartOrDisabled(_, false) should start a real watcher") + } + enabled.Close() +} + func TestWatcherDoesNotSurfaceInternalDirAsFolder(t *testing.T) { root := t.TempDir() w := newTestWatcher(t, root) diff --git a/apps/server/package.json b/apps/server/package.json index 9ffe9db5..d48a7c38 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.3.0", + "version": "2.4.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 4185784a..786c426c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.3.0", + "version": "2.4.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 4c376062..2fbd5138 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -59,7 +59,27 @@ import type { VaultTextSearchToolPaths } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' -import type { DatabaseDoc, DatabaseSummary } from '@shared/databases' +import { + csvPathForFormDir, + databaseSchemaPathFor, + formDirFromCsvPath, + formTitleFromCsvPath, + FORM_DIR_SUFFIX, + isFormDirName, + type DatabaseDoc, + type DatabaseSidecar, + type DatabaseSummary, + type DbField, + type DbRow, + type DbView +} from '@shared/databases' +import { + buildDefaultViews, + inferFields, + parseCsv, + parseRows, + serializeRows +} from '@shared/database-csv' import type { McpClientId, McpClientStatus, @@ -280,6 +300,11 @@ function setVaultSettings(next: VaultSettings): Promise<VaultSettings> { }) } +function rootContentHiddenByInboxMode(): Promise<boolean> { + // Desktop-local concern; the web/server build never hides root content this way. + return Promise.resolve(false) +} + function listLocalVaults(): Promise<LocalVaultEntry[]> { return Promise.resolve([]) } @@ -396,6 +421,17 @@ function createNote( }) } +function createExcalidraw( + folder: NoteFolder, + subpath?: string, + title?: string +): Promise<NoteMeta> { + return jsonRequest<NoteMeta>('/excalidraw/create', { + method: 'POST', + body: { folder, subpath, title } + }) +} + function renameNote(relPath: string, nextTitle: string): Promise<NoteMeta> { return jsonRequest<NoteMeta>('/notes/rename', { method: 'POST', @@ -563,24 +599,305 @@ function scanTasksForPath(relPath: string): Promise<VaultTask[]> { } // Databases are desktop-only for now (no server-side CSV endpoints yet). -const DATABASES_WEB_MSG = 'Databases are only available in the desktop app right now.' -function openDatabase(): Promise<DatabaseDoc> { - return Promise.reject(new Error(DATABASES_WEB_MSG)) +// -------------------------------------------------------------------- +// Databases — reuse the shared CSV/schema logic (@shared/database-csv + +// @shared/databases) over HTTP, mirroring apps/desktop/src/main/databases.ts so +// the on-disk format is byte-identical to the desktop. Reads/writes use the +// generic /notes/read|write endpoints (which accept any vault path, including +// `.base/` internals); the `.base` folder is created/renamed via the folder +// endpoints. The server now lists `.base` folders so the sidebar renders them. +// -------------------------------------------------------------------- + +const SCHEMA_SAMPLE_ROWS = 50 +const DB_TITLE_BAD = /[\\/:*?"<>|]/g + +function dbGenId(): string { + return crypto.randomUUID() } -function writeDatabaseRows(): Promise<DatabaseDoc> { - return Promise.reject(new Error(DATABASES_WEB_MSG)) +function dbToPosix(p: string): string { + return p.replace(/\\/g, '/') } -function writeDatabaseSchema(): Promise<DatabaseDoc> { - return Promise.reject(new Error(DATABASES_WEB_MSG)) +function joinSub(...parts: string[]): string { + return parts + .map((p) => p.replace(/^\/+|\/+$/g, '')) + .filter(Boolean) + .join('/') } -function createDatabase(): Promise<DatabaseDoc> { - return Promise.reject(new Error(DATABASES_WEB_MSG)) + +/** Title of a database from its data.csv path. */ +function dbTitleFromPath(csvPath: string): string { + const posix = dbToPosix(csvPath) + if (formDirFromCsvPath(posix)) return formTitleFromCsvPath(posix) + const base = posix.split('/').filter(Boolean).pop() ?? csvPath + return base.replace(/\.csv$/i, '') } -function createRecordPage(): Promise<string> { - return Promise.reject(new Error(DATABASES_WEB_MSG)) + +/** Read a vault file's text, or null when missing/unreadable (matches the + * desktop's optional-file reads, which treat ENOENT/parse errors as absent). */ +async function readFileTextOrNull(relPath: string): Promise<string | null> { + try { + return (await readNote(relPath)).body + } catch { + return null + } } -function listDatabases(): Promise<DatabaseSummary[]> { - return Promise.resolve([]) + +/** True if a note has body beyond frontmatter + a single title heading. */ +function dbNoteHasBody(text: string): boolean { + let body = text + const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(body) + if (fm) body = body.slice(fm[0].length) + body = body.replace(/^\s*#[^\n]*\r?\n?/, '') + return body.trim().length > 0 +} + +/** Defensive sidecar parse — mirror of databases.ts normalizeSidecar. */ +function dbNormalizeSidecar(raw: unknown): DatabaseSidecar | null { + if (!raw || typeof raw !== 'object') return null + const obj = raw as Record<string, unknown> + const fields = Array.isArray(obj.fields) ? (obj.fields as DbField[]) : null + if (!fields || fields.length === 0) return null + if (!fields.every((f) => f && typeof f.id === 'string' && typeof f.name === 'string')) return null + const fieldIds = new Set(fields.map((f) => f.id)) + const idFieldId = + typeof obj.idFieldId === 'string' && fieldIds.has(obj.idFieldId) ? obj.idFieldId : fields[0].id + let views = Array.isArray(obj.views) ? (obj.views as DbView[]) : [] + views = views.filter( + (v) => v && typeof v.id === 'string' && (v.type === 'table' || v.type === 'board') + ) + if (views.length === 0) views = buildDefaultViews(fields).views + const activeViewId = + typeof obj.activeViewId === 'string' && views.some((v) => v.id === obj.activeViewId) + ? obj.activeViewId + : views[0].id + const pages = + obj.pages && typeof obj.pages === 'object' + ? (Object.fromEntries( + Object.entries(obj.pages as Record<string, unknown>).filter( + ([, v]) => typeof v === 'string' + ) + ) as Record<string, string>) + : undefined + return { version: 1, idFieldId, fields, views, activeViewId, ...(pages ? { pages } : {}) } +} + +function dbPagesToFull(csvRel: string, pages: Record<string, string>): Record<string, string> { + const formDir = formDirFromCsvPath(dbToPosix(csvRel)) + if (!formDir) return pages + const prefix = `${formDir}/` + return Object.fromEntries( + Object.entries(pages).map(([id, p]) => [id, p.startsWith(prefix) ? p : `${prefix}${p}`]) + ) +} +function dbPagesToRelative(csvRel: string, pages: Record<string, string>): Record<string, string> { + const formDir = formDirFromCsvPath(dbToPosix(csvRel)) + if (!formDir) return pages + const prefix = `${formDir}/` + return Object.fromEntries( + Object.entries(pages).map(([id, p]) => [id, p.startsWith(prefix) ? p.slice(prefix.length) : p]) + ) +} + +function dbHydrate( + csvPath: string, + sidecar: DatabaseSidecar, + rows: DbRow[], + pageHasContent?: Record<string, boolean> +): DatabaseDoc { + return { + ...sidecar, + path: dbToPosix(csvPath), + title: dbTitleFromPath(csvPath), + rows, + ...(pageHasContent ? { pageHasContent } : {}) + } +} + +async function dbReadSidecar(csvPath: string): Promise<DatabaseSidecar | null> { + const schemaPath = databaseSchemaPathFor(dbToPosix(csvPath)) + if (!schemaPath) return null + const raw = await readFileTextOrNull(schemaPath) + if (raw === null) return null + let sidecar: DatabaseSidecar | null + try { + sidecar = dbNormalizeSidecar(JSON.parse(raw)) + } catch { + return null + } + if (sidecar?.pages) sidecar.pages = dbPagesToFull(dbToPosix(csvPath), sidecar.pages) + return sidecar +} + +async function dbPersistSidecar(csvPath: string, sidecar: DatabaseSidecar): Promise<void> { + const schemaPath = databaseSchemaPathFor(dbToPosix(csvPath)) + if (!schemaPath) throw new Error(`Not a database folder: ${csvPath}`) + const onDisk: DatabaseSidecar = sidecar.pages + ? { ...sidecar, pages: dbPagesToRelative(dbToPosix(csvPath), sidecar.pages) } + : sidecar + await writeNote(schemaPath, `${JSON.stringify(onDisk, null, 2)}\n`) +} + +async function dbReadPageFlags( + pages?: Record<string, string> +): Promise<Record<string, boolean> | undefined> { + if (!pages || Object.keys(pages).length === 0) return undefined + const flags: Record<string, boolean> = {} + await Promise.all( + Object.entries(pages).map(async ([rowId, notePath]) => { + const text = await readFileTextOrNull(notePath) + if (text !== null) flags[rowId] = dbNoteHasBody(text) + }) + ) + return flags +} + +async function primaryNotesAtRoot(): Promise<boolean> { + try { + return (await getVaultSettings()).primaryNotesLocation === 'root' + } catch { + return false + } +} + +/** Vault-relative directory for a (folder, subpath) — mirrors the server folderRoot. */ +function vaultRelDir(folder: NoteFolder, subpath: string, atRoot: boolean): string { + const sub = (subpath ?? '').replace(/^\/+|\/+$/g, '') + if (folder === 'inbox') return atRoot ? sub : joinSub('inbox', sub) + return joinSub(folder, sub) +} + +/** Split a vault-relative folder path into (folder, subpath) — inverse of vaultRelDir. */ +function splitVaultPath(rel: string, atRoot: boolean): { folder: NoteFolder; subpath: string } { + const parts = dbToPosix(rel).split('/').filter(Boolean) + const top = parts[0] + if (top === 'quick' || top === 'archive' || top === 'trash') { + return { folder: top as NoteFolder, subpath: parts.slice(1).join('/') } + } + if (top === 'inbox' && !atRoot) { + return { folder: 'inbox', subpath: parts.slice(1).join('/') } + } + return { folder: 'inbox', subpath: parts.join('/') } +} + +async function openDatabase(csvPath: string): Promise<DatabaseDoc> { + const rel = dbToPosix(csvPath) + const csvText = await readFileTextOrNull(rel) + if (csvText === null) throw new Error(`Database not found: ${rel}`) + + const existing = await dbReadSidecar(rel) + if (existing) { + const rows = parseRows(csvText, existing.fields, existing.idFieldId, dbGenId) + const pageHasContent = await dbReadPageFlags(existing.pages) + return dbHydrate(rel, existing, rows, pageHasContent) + } + + // Adopt a plain CSV: infer the schema + materialize (matches desktop readDatabase). + const grid = parseCsv(csvText) + const headers = grid[0] ?? [] + const { fields, idFieldId } = inferFields(headers, grid.slice(1, 1 + SCHEMA_SAMPLE_ROWS), dbGenId) + const { views, activeViewId } = buildDefaultViews(fields, dbGenId) + const sidecar: DatabaseSidecar = { version: 1, idFieldId, fields, views, activeViewId } + const rows = parseRows(csvText, fields, idFieldId, dbGenId) + await dbPersistSidecar(rel, sidecar) + await writeNote(rel, serializeRows(rows, fields)) // canonicalize + persist ids + return dbHydrate(rel, sidecar, rows) +} + +async function writeDatabaseRows(csvPath: string, rows: DbRow[]): Promise<DatabaseDoc> { + const rel = dbToPosix(csvPath) + const sidecar = await dbReadSidecar(rel) + if (!sidecar) throw new Error(`Database sidecar missing: ${rel}`) + await writeNote(rel, serializeRows(rows, sidecar.fields)) + return dbHydrate(rel, sidecar, rows.map((r) => ({ ...r }))) +} + +async function writeDatabaseSchema( + csvPath: string, + sidecar: DatabaseSidecar, + rows: DbRow[] +): Promise<DatabaseDoc> { + const rel = dbToPosix(csvPath) + const normalized = dbNormalizeSidecar(sidecar) + if (!normalized) throw new Error(`Invalid database schema: ${rel}`) + await dbPersistSidecar(rel, normalized) + await writeNote(rel, serializeRows(rows, normalized.fields)) + return dbHydrate(rel, normalized, rows.map((r) => ({ ...r }))) +} + +async function createDatabase( + folder: NoteFolder, + subpath: string, + title?: string +): Promise<DatabaseDoc> { + const atRoot = await primaryNotesAtRoot() + const baseTitle = (title ?? 'Untitled Database').trim() || 'Untitled Database' + const baseName = baseTitle.replace(DB_TITLE_BAD, '-') + const dirRel = vaultRelDir(folder, subpath, atRoot) + const csvFor = (name: string): string => + csvPathForFormDir(joinSub(dirRel, `${name}${FORM_DIR_SUFFIX}`)) + // Resolve a non-colliding <Name>.base under the directory. + let name = baseName + let n = 2 + while ((await readFileTextOrNull(csvFor(name))) !== null) name = `${baseName} ${n++}` + const csvPath = csvFor(name) + const folderSub = joinSub(subpath, `${name}${FORM_DIR_SUFFIX}`) + + const idField: DbField = { id: dbGenId(), name: 'id', type: 'text', hidden: true } + const nameField: DbField = { id: dbGenId(), name: 'Name', type: 'text' } + const fields = [idField, nameField] + const { views, activeViewId } = buildDefaultViews(fields, dbGenId) + const sidecar: DatabaseSidecar = { version: 1, idFieldId: idField.id, fields, views, activeViewId } + + await createFolder(folder, folderSub) + await dbPersistSidecar(csvPath, sidecar) + await writeNote(csvPath, serializeRows([], fields)) + return dbHydrate(csvPath, sidecar, []) +} + +async function createRecordPage(csvPath: string, title: string, body: string): Promise<string> { + const formDir = formDirFromCsvPath(dbToPosix(csvPath)) + if (!formDir) throw new Error(`Not a database folder: ${csvPath}`) + const safe = (title.trim() || 'Untitled').replace(/[\\/]/g, '-') + let finalTitle = safe + let n = 2 + while ((await readFileTextOrNull(`${formDir}/${finalTitle}.md`)) !== null) { + finalTitle = `${safe} ${n++}` + } + const noteRel = `${formDir}/${finalTitle}.md` + await writeNote(noteRel, body) + return noteRel +} + +async function renameDatabase(csvPath: string, newTitle: string): Promise<string> { + const oldFormDir = formDirFromCsvPath(dbToPosix(csvPath)) + if (!oldFormDir) throw new Error(`Not a database folder: ${csvPath}`) + const parentRel = oldFormDir.includes('/') ? oldFormDir.slice(0, oldFormDir.lastIndexOf('/')) : '' + const safeName = (newTitle.trim() || 'Untitled Database').replace(DB_TITLE_BAD, '-') + const makeFormDir = (name: string): string => + parentRel ? `${parentRel}/${name}${FORM_DIR_SUFFIX}` : `${name}${FORM_DIR_SUFFIX}` + let targetFormDir = makeFormDir(safeName) + if (targetFormDir === oldFormDir) return csvPath + let n = 2 + while ((await readFileTextOrNull(csvPathForFormDir(targetFormDir))) !== null) { + targetFormDir = makeFormDir(`${safeName} ${n++}`) + } + const atRoot = await primaryNotesAtRoot() + const { folder, subpath: oldSub } = splitVaultPath(oldFormDir, atRoot) + const { subpath: newSub } = splitVaultPath(targetFormDir, atRoot) + await renameFolder(folder, oldSub, newSub) + return csvPathForFormDir(targetFormDir) +} + +async function listDatabases(): Promise<DatabaseSummary[]> { + const [folders, atRoot] = await Promise.all([listFolders(), primaryNotesAtRoot()]) + const out: DatabaseSummary[] = [] + for (const f of folders) { + if (!isFormDirName(f.subpath)) continue + // The folder subpath is folder-relative; reconstruct the vault-relative path. + const csv = csvPathForFormDir(vaultRelDir(f.folder, f.subpath, atRoot)) + out.push({ path: csv, title: dbTitleFromPath(csv) }) + } + return out.sort((a, b) => a.title.localeCompare(b.title)) } // -------------------------------------------------------------------- @@ -1113,6 +1430,7 @@ export const httpBridge: ZenBridge = { browseServerDirectories, getVaultSettings, setVaultSettings, + rootContentHiddenByInboxMode, listNotes, listFolders, @@ -1135,11 +1453,13 @@ export const httpBridge: ZenBridge = { writeDatabaseRows, writeDatabaseSchema, createDatabase, + renameDatabase, createRecordPage, listDatabases, writeNote, appendToNote, createNote, + createExcalidraw, renameNote, deleteNote, moveToTrash, diff --git a/docs/how-to/self-host-with-docker.md b/docs/how-to/self-host-with-docker.md index a496773f..901399ac 100644 --- a/docs/how-to/self-host-with-docker.md +++ b/docs/how-to/self-host-with-docker.md @@ -190,6 +190,11 @@ or via the orchestrator of your choice. subpath instead of the domain root. Use this when deploying behind a reverse proxy that routes by path (e.g. `example.com/zennotes/`). See [Reverse-proxy with a path prefix](#reverse-proxy-with-a-path-prefix). +- `ZENNOTES_DISABLE_WATCHER=1` — turn off the inotify file watcher. The + vault is still fully served; only live updates (auto-refresh when files + change on disk) stop. Set this where inotify is restricted or unstable — + notably **unprivileged LXC containers**, where inotify on a bind-mount can + wedge the process and lock the volume (see Common problems below). ## Reverse-proxy with a path prefix @@ -271,6 +276,23 @@ If you want a flatter layout, change: - `Settings -> Vault -> Primary notes location -> Vault root` +### The container hangs and won't stop (unprivileged LXC) + +If the web page loads but the container ignores `docker stop`/`docker kill` +(and even `kill -9`), and the bind-mounted volume on the host is locked, the +culprit is almost always the inotify file watcher on a restricted host — +typically an **unprivileged LXC container**, where inotify on a bind-mounted +directory can put the process into an unkillable state. + +Run with the watcher off: + +- `ZENNOTES_DISABLE_WATCHER=1` + +The vault is still fully served; you only lose live auto-refresh when files +change on disk (reload the page to pick up external edits). On startup the +server now also logs a warning instead of failing silently if it can only +watch part of the vault. + ## Related docs - [Connect Desktop to a Remote ZenNotes Server](./connect-desktop-to-remote-server.md) diff --git a/docs/reference/settings-reference.md b/docs/reference/settings-reference.md index febda672..4dc411ac 100644 --- a/docs/reference/settings-reference.md +++ b/docs/reference/settings-reference.md @@ -175,9 +175,55 @@ Daily notes are optional. Related settings include: - enable/disable daily notes -- daily notes directory +- daily notes directory pattern +- daily note naming pattern +- daily note locale -When enabled, ZenNotes can open or create today's note using an ISO-style date title. +The directory pattern is stored inside the primary notes area. The default is `Daily Notes`. + +The naming pattern is used as the daily note title and filename. The default is `yyyy-MM-dd`. + +Supported pattern tokens: + +| Token | Example | Meaning | +| --- | --- | --- | +| `yyyy` | `2026` | 4-digit year; ISO week-year for weekly notes | +| `yy` | `26` | 2-digit year | +| `M` | `6` | month | +| `MM` | `06` | padded month | +| `MMM` | `Jun` | short month name | +| `MMMM` | `June` | full month name | +| `d` | `9` | day of month | +| `dd` | `09` | padded day of month | +| `EEE` | `Tue` | short weekday name | +| `EEEE` | `Tuesday` | full weekday name | +| `w` | `24` | ISO week number | +| `ww` | `24` | padded ISO week number | + +Wrap literal text in single quotes, for example `'Daily Notes'/yyyy/MM-MMM`. + +The locale controls localized month and weekday names. Use `system`, `en-US`, `pt-BR`, or another BCP 47 locale. + +For example, directory `yyyy/MM-MMM`, naming `yyyy-MM-dd-EEE`, and locale `en-US` creates a note like `2026/06-Jun/2026-06-09-Tue.md`. + +### Weekly notes + +Weekly notes are optional. + +Related settings include: + +- enable/disable weekly notes +- weekly notes directory pattern +- weekly note naming pattern +- weekly note locale + +The directory pattern is stored inside the primary notes area. The default is `Weekly Notes`. + +The naming pattern is used as the weekly note title and filename. The default is `yyyy-'W'ww`. + +Weekly notes use the same pattern tokens as daily notes, plus `w` and `ww` for ISO week numbers. Weekly date tokens render from the ISO week's Monday, and `yyyy` is the ISO week-year. + +For example, directory `yyyy/MM-MMM`, naming `yyyy-'W'ww-EEE`, and locale `en-US` creates a note like `2026/06-Jun/2026-W24-Mon.md`. ### Quick Notes label diff --git a/docs/reference/vault-and-folder-model.md b/docs/reference/vault-and-folder-model.md index 7a7d776a..013451f6 100644 --- a/docs/reference/vault-and-folder-model.md +++ b/docs/reference/vault-and-folder-model.md @@ -81,15 +81,22 @@ Trash: - allows restore - can be emptied -## Daily notes +## Daily and weekly notes -Daily notes are optional. +Daily and weekly notes are optional. When enabled: - ZenNotes creates one note per day -- title format is an ISO date -- notes live in a dedicated daily notes directory under the primary notes area +- ZenNotes creates one note per ISO week +- the default daily title format is an ISO date +- the default weekly title format is an ISO week title +- notes live in dedicated date-note directories under the primary notes area +- the directory and title can use date patterns such as `yyyy/MM-MMM` and `yyyy-MM-dd-EEE` +- weekly title patterns can use ISO week tokens such as `yyyy-'W'ww` +- localized month and weekday names can use `system`, `en-US`, `pt-BR`, or another BCP 47 locale + +Supported pattern tokens are `yyyy`, `yy`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `EEE`, `EEEE`, `w`, and `ww`. Weekly notes render date tokens from the ISO week's Monday, and `yyyy` is the ISO week-year for weekly patterns. Literal words can be wrapped in single quotes, for example `'Daily Notes'/yyyy/MM-MMM`. ## Assets and local files diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..6769406b --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1781074563, + "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..336b7f73 --- /dev/null +++ b/flake.nix @@ -0,0 +1,46 @@ +{ + description = "ZenNotes - Keyboard-first local Markdown notes"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { nixpkgs, ... }: + let + systems = nixpkgs.lib.platforms.linux ++ nixpkgs.lib.platforms.darwin; + + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + rec { + zennotes-desktop = pkgs.callPackage ./packaging/nix/package-desktop.nix { }; + + zennotes-server = pkgs.callPackage ./packaging/nix/package-server.nix { }; + + default = zennotes-desktop; + } + ); + + devShell = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + pkgs.mkShell { + buildInputs = with pkgs; [ + go + nodejs + electron + turbo + ]; + + shellHook = '' + export ELECTRON_SKIP_BINARY_DOWNLOAD=1 + ''; + } + ); + }; +} diff --git a/package-lock.json b/package-lock.json index dc701fd8..01cbbdf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -492,6 +492,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1916,6 +1925,245 @@ "node": ">=12" } }, + "node_modules/@excalidraw/excalidraw": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.18.1.tgz", + "integrity": "sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "6.0.2", + "@excalidraw/laser-pointer": "1.3.1", + "@excalidraw/mermaid-to-excalidraw": "2.2.2", + "@excalidraw/random-username": "1.1.0", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-tabs": "1.0.2", + "browser-fs-access": "0.29.1", + "canvas-roundrect-polyfill": "0.0.1", + "clsx": "1.1.1", + "cross-env": "7.0.3", + "es6-promise-pool": "2.5.0", + "fractional-indexing": "3.2.0", + "fuzzy": "0.1.3", + "image-blob-reduce": "3.0.1", + "jotai": "2.11.0", + "jotai-scope": "0.7.2", + "lodash.debounce": "4.0.8", + "lodash.throttle": "4.1.1", + "nanoid": "3.3.3", + "open-color": "1.9.1", + "pako": "2.0.3", + "perfect-freehand": "1.2.0", + "pica": "7.1.1", + "png-chunk-text": "1.0.0", + "png-chunks-encode": "1.0.0", + "png-chunks-extract": "1.0.0", + "points-on-curve": "1.0.1", + "pwacompat": "2.0.17", + "roughjs": "4.6.4", + "sass": "1.51.0", + "tunnel-rat": "0.1.2" + }, + "peerDependencies": { + "react": "^17.0.2 || ^18.2.0 || ^19.0.0", + "react-dom": "^17.0.2 || ^18.2.0 || ^19.0.0" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/@braintree/sanitize-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz", + "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/points-on-curve": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", + "integrity": "sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==", + "license": "MIT" + }, + "node_modules/@excalidraw/excalidraw/node_modules/roughjs": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.4.tgz", + "integrity": "sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/@excalidraw/excalidraw/node_modules/roughjs/node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/@excalidraw/laser-pointer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@excalidraw/laser-pointer/-/laser-pointer-1.3.1.tgz", + "integrity": "sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==", + "license": "MIT" + }, + "node_modules/@excalidraw/markdown-to-text": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz", + "integrity": "sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==", + "license": "MIT" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-2.2.2.tgz", + "integrity": "sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==", + "license": "MIT", + "dependencies": { + "@excalidraw/markdown-to-text": "0.1.2", + "@mermaid-js/parser": "^0.6.3", + "mermaid": "^11.12.1", + "nanoid": "4.0.2" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@excalidraw/mermaid-to-excalidraw/node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, + "node_modules/@excalidraw/random-username": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@excalidraw/random-username/-/random-username-1.1.0.tgz", + "integrity": "sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -1934,6 +2182,44 @@ } } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -2799,173 +3085,941 @@ "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", "license": "MIT", "dependencies": { - "langium": "^4.0.0" + "langium": "^4.0.0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prinsss/dvi2html": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@prinsss/dvi2html/-/dvi2html-0.0.1.tgz", + "integrity": "sha512-AdlM+1uAXH278q50vfpwIU7ykA5FKuxi6mx5haGZCmVTeNw6nxcbfyTIBRmlviesu4W+YJeFFfdv+gJ68O47aw==", + "license": "GPL-3.0", + "dependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/@prinsss/dvi2html/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.1.tgz", + "integrity": "sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz", + "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz", + "integrity": "sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-collection": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-use-callback-ref": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz", + "integrity": "sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.1", + "@radix-ui/react-roving-focus": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz", + "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", + "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz", + "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz", + "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz", + "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0", + "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz", + "integrity": "sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.1" }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz", + "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", + "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz", + "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", - "dev": true, - "license": "ISC", + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz", + "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==", + "license": "MIT", "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" + "@babel/runtime": "^7.13.10" }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0" } }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "engines": { - "node": ">=10" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", "license": "MIT", "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" + "@radix-ui/react-use-callback-ref": "1.1.0" }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@prinsss/dvi2html": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@prinsss/dvi2html/-/dvi2html-0.0.1.tgz", - "integrity": "sha512-AdlM+1uAXH278q50vfpwIU7ykA5FKuxi6mx5haGZCmVTeNw6nxcbfyTIBRmlviesu4W+YJeFFfdv+gJ68O47aw==", - "license": "GPL-3.0", - "dependencies": { - "buffer": "^6.0.3" + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@prinsss/dvi2html/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true } - ], + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", "license": "MIT", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, "node_modules/@replit/codemirror-vim": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz", @@ -3881,7 +4935,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3891,7 +4944,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -4296,7 +5349,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4583,6 +5635,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -4842,7 +5906,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4952,7 +6015,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -4961,6 +6023,12 @@ "node": ">=8" } }, + "node_modules/browser-fs-access": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.29.1.tgz", + "integrity": "sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==", + "license": "Apache-2.0" + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -5336,6 +6404,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-roundrect-polyfill": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/canvas-roundrect-polyfill/-/canvas-roundrect-polyfill-0.0.1.tgz", + "integrity": "sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==", + "license": "MIT" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -5589,6 +6663,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -5912,6 +6995,24 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6898,6 +7999,12 @@ "license": "MIT", "optional": true }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -7703,6 +8810,15 @@ "license": "MIT", "optional": true }, + "node_modules/es6-promise-pool": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", + "integrity": "sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -8114,7 +9230,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8248,6 +9363,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fractional-indexing": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fractional-indexing/-/fractional-indexing-3.2.0.tgz", + "integrity": "sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==", + "license": "CC0-1.0", + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -8303,7 +9427,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8342,6 +9465,14 @@ "interval-arithmetic-eval": "^0.5.1" } }, + "node_modules/fuzzy": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", + "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/gauge": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", @@ -8407,6 +9538,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -8569,6 +9709,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glur": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9182,6 +10328,21 @@ ], "license": "BSD-3-Clause" }, + "node_modules/image-blob-reduce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz", + "integrity": "sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==", + "license": "MIT", + "dependencies": { + "pica": "^7.1.0" + } + }, + "node_modules/immutable": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -9277,7 +10438,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -9328,7 +10488,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9348,7 +10507,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9378,7 +10536,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9490,6 +10647,38 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jotai": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.11.0.tgz", + "integrity": "sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-scope": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/jotai-scope/-/jotai-scope-0.7.2.tgz", + "integrity": "sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.9.2", + "react": ">=17.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9792,6 +10981,12 @@ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -9833,6 +11028,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", @@ -11308,6 +12509,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multimath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "object-assign": "^4.1.1" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -11667,7 +12878,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11794,6 +13004,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open-color": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/open-color/-/open-color-1.9.1.tgz", + "integrity": "sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==", + "license": "MIT" + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -11873,6 +13089,12 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -12009,6 +13231,25 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-freehand": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz", + "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==", + "license": "MIT" + }, + "node_modules/pica": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", + "license": "MIT", + "dependencies": { + "glur": "^1.1.2", + "inherits": "^2.0.3", + "multimath": "^2.0.0", + "object-assign": "^4.1.1", + "webworkify": "^1.5.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12019,7 +13260,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -12083,6 +13323,49 @@ "node": ">=10.4.0" } }, + "node_modules/png-chunk-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", + "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", + "license": "MIT" + }, + "node_modules/png-chunks-encode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", + "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0", + "sliced": "^1.0.1" + } + }, + "node_modules/png-chunks-encode/node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0" + } + }, + "node_modules/png-chunks-extract/node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -12370,6 +13653,12 @@ "node": ">=6" } }, + "node_modules/pwacompat": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/pwacompat/-/pwacompat-2.0.17.tgz", + "integrity": "sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==", + "license": "Apache-2.0" + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -12483,6 +13772,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12501,6 +13791,75 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -13079,6 +14438,71 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "node_modules/sass": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.51.0.tgz", + "integrity": "sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==", + "license": "MIT", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/sass/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", @@ -13364,6 +14788,13 @@ "node": ">=8" } }, + "node_modules/sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", + "deprecated": "Unsupported", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -14197,7 +15628,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -14310,6 +15740,43 @@ "license": "0BSD", "peer": true }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/turbo": { "version": "2.9.6", "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.6.tgz", @@ -14604,6 +16071,59 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -14966,6 +16486,12 @@ "node": ">=20" } }, + "node_modules/webworkify": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==", + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -15325,6 +16851,7 @@ "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", + "@excalidraw/excalidraw": "^0.18.1", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", "dompurify": "^3.3.4", diff --git a/package.json b/package.json index 71032f7a..7b36f508 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.3.0", + "version": "2.4.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 7f7db1d7..838b1ba3 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.3.0", + "version": "2.4.0", "type": "module", "exports": { "./main": "./src/main.tsx" @@ -28,6 +28,7 @@ "@codemirror/search": "^6.5.8", "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", + "@excalidraw/excalidraw": "^0.18.1", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.3.0", "dompurify": "^3.3.4", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 8a916bb4..4df35052 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -529,6 +529,17 @@ function App(): JSX.Element { void window.zen.resetAppZoom() return } + if (matchesShortcut(e, overrides, 'global.historyBack')) { + // Back in note navigation history (works in any mode). + e.preventDefault() + void state.jumpToPreviousNote() + return + } + if (matchesShortcut(e, overrides, 'global.historyForward')) { + e.preventDefault() + void state.jumpToNextNote() + return + } if (matchesShortcut(e, overrides, 'global.searchNotes')) { // ⌘P — note search e.preventDefault() @@ -538,16 +549,28 @@ function App(): JSX.Element { return } if (matchesShortcut(e, overrides, 'global.closeActiveTab')) { - // On Linux/Windows `Mod+W` (close tab) resolves to Ctrl+W, which also - // is the vim pane-focus prefix (`<C-w>hjkl`) and insert-mode word - // delete. When vim mode is on, reserve that key for vim — VimNav's - // capture-phase handler arms the prefix; close tabs via :q / :bd / the - // command palette. On macOS close-tab is Cmd+W, so this never triggers. - if (state.vimMode && matchesSequenceToken(e, overrides, 'vim.panePrefix')) { + // On Linux/Windows `Mod+W` (close tab) resolves to Ctrl+W, which is also + // the vim pane-focus prefix (`<C-w>hjkl`) and insert-mode word delete. + // When vim mode is on AND a tab is open, reserve Ctrl+W for vim (close + // tabs via :q / :bd / the palette). With no tab open the prefix has + // nothing to act on, so fall through and close the window. On macOS + // close-tab is Cmd+W, so the vim guard never matches there. + const hasActiveTab = !!state.selectedPath + if ( + state.vimMode && + hasActiveTab && + matchesSequenceToken(e, overrides, 'vim.panePrefix') + ) { return } e.preventDefault() - void state.closeActiveNote() + if (hasActiveTab) { + void state.closeActiveNote() + } else { + // No tab left to close — close the window, matching native Cmd+W + // (macOS) / Ctrl+W behavior even with vim mode on (#192). + window.zen.windowClose() + } return } if (e.key === 'Escape' && state.searchOpen) { @@ -610,24 +633,9 @@ function App(): JSX.Element { window.dispatchEvent(new Event('zen:add-comment')) return } - // ⌥h/j/k/l — focus the neighbouring pane/panel. Works regardless of vim - // mode (unlike <C-w>hjkl) and never needs the command palette. - { - const paneDir = matchesShortcut(e, overrides, 'global.focusPaneLeft') - ? 'h' - : matchesShortcut(e, overrides, 'global.focusPaneDown') - ? 'j' - : matchesShortcut(e, overrides, 'global.focusPaneUp') - ? 'k' - : matchesShortcut(e, overrides, 'global.focusPaneRight') - ? 'l' - : null - if (paneDir) { - e.preventDefault() - focusPaneOrEdgePanel(paneDir) - return - } - } + // Pane-focus shortcuts (⌥h/j/k/l by default) are handled by a separate + // capture-phase listener so a remap onto an editor key still wins over + // CodeMirror — see focusPaneHandler below. (#124) if (matchesShortcut(e, overrides, 'global.modeEdit')) { e.preventDefault() requestPaneMode('edit') @@ -656,8 +664,53 @@ function App(): JSX.Element { return } } + // Pane-focus shortcuts must win over the editor. CodeMirror binds keys such + // as Ctrl-h (delete character) and Ctrl-k (delete to line end), so when a + // user remaps a focusPane shortcut onto one of them the bubble-phase handler + // above would run *after* CodeMirror already executed its command — the key + // would both delete text and move focus (#124). Handle these in the capture + // phase and consume the event so the editor never sees the key. + const focusPaneHandler = (e: KeyboardEvent): void => { + const state = useStore.getState() + // Don't move focus (or swallow the key) while a modal, palette, or the + // Settings keybinding recorder is open — the recorder also captures keys + // in this phase, so a focusPane shortcut bound to e.g. Ctrl+H must not + // intercept it. (#124) + if ( + state.settingsOpen || + state.searchOpen || + state.vaultTextSearchOpen || + state.commandPaletteOpen || + state.bufferPaletteOpen || + state.templatePaletteOpen || + state.outlinePaletteOpen || + document.querySelector('[data-ctx-menu]') || + document.querySelector('[data-prompt-modal]') || + document.querySelector('[data-confirm-modal]') + ) { + return + } + const overrides = state.keymapOverrides + const paneDir = matchesShortcut(e, overrides, 'global.focusPaneLeft') + ? 'h' + : matchesShortcut(e, overrides, 'global.focusPaneDown') + ? 'j' + : matchesShortcut(e, overrides, 'global.focusPaneUp') + ? 'k' + : matchesShortcut(e, overrides, 'global.focusPaneRight') + ? 'l' + : null + if (!paneDir) return + e.preventDefault() + e.stopImmediatePropagation() + focusPaneOrEdgePanel(paneDir) + } window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) + window.addEventListener('keydown', focusPaneHandler, true) + return () => { + window.removeEventListener('keydown', handler) + window.removeEventListener('keydown', focusPaneHandler, true) + } }, [ setBufferPaletteOpen, setCommandPaletteOpen, diff --git a/packages/app-core/src/components/ArchiveView.tsx b/packages/app-core/src/components/ArchiveView.tsx index cd030d94..95d6a2d7 100644 --- a/packages/app-core/src/components/ArchiveView.tsx +++ b/packages/app-core/src/components/ArchiveView.tsx @@ -10,6 +10,7 @@ import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' import { promptApp } from '../lib/prompt-requests' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' +import { isAppOverlayOpen } from '../lib/overlay-open' function formatDate(ms: number): string { const d = new Date(ms) @@ -40,6 +41,7 @@ export function ArchiveView(): JSX.Element { const selectedPath = useStore((s) => s.selectedPath) const renameNote = useStore((s) => s.renameNote) const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) const setFocusedPanel = useStore((s) => s.setFocusedPanel) const systemFolderLabels = useStore((s) => s.systemFolderLabels) const workspaceMode = useStore((s) => s.workspaceMode) @@ -272,9 +274,9 @@ export function ArchiveView(): JSX.Element { useEffect(() => { if (!amActive) return const handler = (e: KeyboardEvent): void => { - if (document.querySelector('[data-ctx-menu]') || document.querySelector('[data-prompt-modal]')) { - return - } + // A modal/menu owns the keyboard while open — don't fire list shortcuts + // through it. (songgenqing report) + if (isAppOverlayOpen()) return const active = document.activeElement as HTMLElement | null if (active) { const tag = active.tagName @@ -284,6 +286,10 @@ export function ArchiveView(): JSX.Element { const key = e.key const overrides = keymapOverrides + // When Vim mode is off, the single-key Vim shortcuts (j/k/x/d/gg/G/o/r//…) + // are disabled — only arrows/Enter/Escape navigate. (songgenqing report) + const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => + vimMode && matchesSequenceToken(e, overrides, id) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -300,35 +306,36 @@ export function ArchiveView(): JSX.Element { return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (seq('nav.filter')) { consume() filterRef.current?.focus() filterRef.current?.select() return } - if (matchesSequenceToken(e, overrides, 'nav.contextMenu') && current) { + if (seq('nav.contextMenu') && current) { consume() openMenuForCurrent() return } - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (seq('nav.moveDown') || key === 'ArrowDown') { consume() setCursorIndex((i) => Math.max(0, Math.min(filtered.length - 1, i + 1))) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (seq('nav.moveUp') || key === 'ArrowUp') { consume() setCursorIndex((i) => Math.max(0, Math.min(filtered.length - 1, i - 1))) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (seq('nav.jumpBottom')) { consume() setCursorIndex(filtered.length - 1) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -341,17 +348,17 @@ export function ArchiveView(): JSX.Element { ) { return } - if ((key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openResult')) && current) { + if ((key === 'Enter' || seq('nav.openResult')) && current) { consume() void openNote(current.path) return } - if (matchesSequenceToken(e, overrides, 'nav.unarchive') && current) { + if (seq('nav.unarchive') && current) { consume() void unarchiveNote(current) return } - if ((matchesSequenceToken(e, overrides, 'nav.delete') || key === 'd') && current) { + if ((seq('nav.delete') || (vimMode && key === 'd')) && current) { consume() void moveNoteToTrash(current) } @@ -369,6 +376,7 @@ export function ArchiveView(): JSX.Element { filter, filtered.length, keymapOverrides, + vimMode, moveNoteToTrash, openMenuForCurrent, openNote, diff --git a/packages/app-core/src/components/AssetsView.tsx b/packages/app-core/src/components/AssetsView.tsx new file mode 100644 index 00000000..817b46f4 --- /dev/null +++ b/packages/app-core/src/components/AssetsView.tsx @@ -0,0 +1,238 @@ +import { useMemo, useState } from 'react' +import type { AssetMeta } from '@shared/ipc' +import { useStore } from '../store' +import { assetTabPath } from '../lib/asset-tabs' +import { confirmMoveToTrash } from '../lib/confirm-trash' +import { promptApp } from '../lib/prompt-requests' +import { naturalCompare } from '../lib/natural-sort' +import { resolveAssetVaultRelativePath } from '../lib/local-assets' +import { ContextMenu, type ContextMenuItem } from './ContextMenu' +import { DocumentIcon, ImageIcon, PaperclipIcon, SearchIcon, TrashIcon } from './icons' + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const units = ['KB', 'MB', 'GB'] + let value = bytes / 1024 + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}` +} + +function formatDate(ms: number): string { + const d = new Date(ms) + const sameYear = d.getFullYear() === new Date().getFullYear() + return d.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: sameYear ? undefined : 'numeric' + }) +} + +// Shared column template for the header + every row, so columns line up: +// name (flex) · used · type · size · modified · action. +const ASSET_ROW_GRID = + 'grid grid-cols-[minmax(0,1fr)_6rem_4rem_5rem_5rem_1.75rem] items-center gap-4' + +function AssetGlyph({ kind }: { kind: AssetMeta['kind'] }): JSX.Element { + if (kind === 'image') return <ImageIcon width={15} height={15} /> + if (kind === 'pdf') return <DocumentIcon width={15} height={15} /> + return <PaperclipIcon width={15} height={15} /> +} + +/** + * The built-in Assets view: browse every asset in the vault in one place + * (images, PDFs, attachments), independent of the notes tree. + */ +export function AssetsView(): JSX.Element { + const assetFiles = useStore((s) => s.assetFiles) + const openNoteInTab = useStore((s) => s.openNoteInTab) + const deleteAsset = useStore((s) => s.deleteAsset) + const refreshAssets = useStore((s) => s.refreshAssets) + const notes = useStore((s) => s.notes) + const vaultRoot = useStore((s) => s.vault?.root ?? null) + const [filter, setFilter] = useState('') + const [menu, setMenu] = useState<{ x: number; y: number; asset: AssetMeta } | null>(null) + + const assets = useMemo(() => { + const q = filter.trim().toLowerCase() + const matched = q + ? assetFiles.filter((a) => a.name.toLowerCase().includes(q) || a.path.toLowerCase().includes(q)) + : assetFiles + return [...matched].sort((a, b) => naturalCompare(a.name, b.name)) + }, [assetFiles, filter]) + + // assetPath → note paths that embed it (resolved via relative-path + the + // unique-basename fallback, matching how embeds render). (#185) + const usage = useMemo(() => { + const map = new Map<string, string[]>() + for (const note of notes) { + for (const href of note.assetEmbeds ?? []) { + const resolved = resolveAssetVaultRelativePath(vaultRoot, note.path, href) + if (!resolved) continue + const arr = map.get(resolved) + if (arr) { + if (!arr.includes(note.path)) arr.push(note.path) + } else { + map.set(resolved, [note.path]) + } + } + } + return map + }, [notes, vaultRoot, assetFiles]) + + const copyEmbed = (asset: AssetMeta): void => { + void navigator.clipboard?.writeText(`![[${asset.name}]]`) + } + + const renameAsset = async (asset: AssetMeta): Promise<void> => { + if (typeof window.zen.renameAsset !== 'function') return + const ext = asset.name.includes('.') ? asset.name.slice(asset.name.lastIndexOf('.')) : '' + const base = ext ? asset.name.slice(0, -ext.length) : asset.name + const next = await promptApp({ + title: 'Rename asset', + initialValue: base, + okLabel: 'Rename', + validate: (v) => (v.includes('/') ? 'Use only a name' : null) + }) + if (!next) return + const clean = next.trim() + if (!clean || `${clean}${ext}` === asset.name) return + try { + await window.zen.renameAsset(asset.path, `${clean}${ext}`) + await refreshAssets() + } catch (err) { + window.alert(err instanceof Error ? err.message : String(err)) + } + } + + const menuItems = (asset: AssetMeta): ContextMenuItem[] => [ + { label: 'Open', onSelect: () => void openNoteInTab(assetTabPath(asset.path)) }, + { label: 'Copy embed', onSelect: () => copyEmbed(asset) }, + { label: 'Reveal in file manager', onSelect: () => void window.zen.revealNote(asset.path) }, + { label: 'Rename…', onSelect: () => void renameAsset(asset) }, + { + label: 'Move to Trash', + danger: true, + onSelect: async () => { + if (await confirmMoveToTrash(asset.name)) await deleteAsset(asset.path) + } + } + ] + + return ( + <div className="flex min-h-0 flex-1 flex-col bg-paper-100 text-ink-900"> + <header className="glass-header flex h-12 shrink-0 items-center gap-2 px-4"> + <PaperclipIcon className="h-4 w-4 shrink-0 text-ink-500" /> + <h2 className="text-sm font-semibold text-ink-900">Assets</h2> + <span className="shrink-0 text-xs text-ink-500">{assetFiles.length}</span> + <div className="ml-auto flex items-center gap-1.5 rounded-md bg-paper-200/60 px-2 py-1"> + <SearchIcon className="h-3.5 w-3.5 text-ink-400" /> + <input + value={filter} + onChange={(e) => setFilter(e.target.value)} + placeholder="Filter assets" + className="w-40 bg-transparent text-xs text-ink-900 outline-none placeholder:text-ink-400" + /> + </div> + </header> + + <div className="min-h-0 flex-1 overflow-auto"> + {assets.length === 0 ? ( + <div className="flex h-full items-center justify-center text-sm text-ink-400"> + {assetFiles.length === 0 ? 'No assets yet.' : 'No assets match your filter.'} + </div> + ) : ( + <> + <div className={`${ASSET_ROW_GRID} sticky top-0 z-10 border-b border-paper-300/60 bg-paper-100 px-3 py-1.5 text-2xs font-medium uppercase tracking-wide text-ink-400`}> + <span>Name</span> + <span className="text-right">Used</span> + <span className="text-right">Type</span> + <span className="text-right">Size</span> + <span className="text-right">Modified</span> + <span /> + </div> + <ul className="flex flex-col py-1"> + {assets.map((asset) => { + const usedIn = usage.get(asset.path)?.length ?? 0 + const open = (): void => void openNoteInTab(assetTabPath(asset.path)) + return ( + <li key={asset.path}> + <div + role="button" + tabIndex={0} + onClick={open} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + open() + } + }} + onContextMenu={(e) => { + e.preventDefault() + setMenu({ x: e.clientX, y: e.clientY, asset }) + }} + className={`${ASSET_ROW_GRID} group cursor-pointer px-3 py-1.5 outline-none hover:bg-paper-200/40 focus-visible:bg-paper-200/40`} + title={asset.path} + > + <div className="flex min-w-0 items-center gap-2.5"> + <span className="shrink-0 text-ink-500"> + <AssetGlyph kind={asset.kind} /> + </span> + <span className="min-w-0 truncate text-sm text-ink-900">{asset.name}</span> + </div> + <span + className={[ + 'truncate text-right text-2xs', + usedIn === 0 ? 'text-ink-400/70' : 'text-ink-500' + ].join(' ')} + title={ + usedIn === 0 + ? 'Not referenced by any note' + : (usage.get(asset.path) ?? []).join('\n') + } + > + {usedIn === 0 ? 'unused' : `used in ${usedIn}`} + </span> + <span className="text-right text-2xs uppercase tracking-wide text-ink-400"> + {asset.kind} + </span> + <span className="text-right text-xs tabular-nums text-ink-500"> + {formatBytes(asset.size)} + </span> + <span className="text-right text-xs tabular-nums text-ink-500"> + {formatDate(asset.updatedAt)} + </span> + <button + type="button" + aria-label={`Delete ${asset.name}`} + title="Move to Trash" + onClick={async (e) => { + e.stopPropagation() + if (await confirmMoveToTrash(asset.name)) await deleteAsset(asset.path) + }} + className="flex h-6 w-6 items-center justify-center justify-self-end rounded text-ink-400 opacity-0 transition hover:bg-paper-300/60 hover:text-danger group-hover:opacity-100" + > + <TrashIcon width={14} height={14} /> + </button> + </div> + </li> + ) + })} + </ul> + </> + )} + </div> + {menu && ( + <ContextMenu + x={menu.x} + y={menu.y} + items={menuItems(menu.asset)} + onClose={() => setMenu(null)} + /> + )} + </div> + ) +} diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 14a58a0c..dd277b1f 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -13,6 +13,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' import { allLeaves, findLeafWithActiveTab, @@ -283,6 +284,8 @@ export function BufferPalette(): JSX.Element { placeholder="Switch buffer…" onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index 157940e0..2173a998 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -14,11 +14,17 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { NoteContent, NoteMeta } from '@shared/ipc' -import { parseTasksFromBody } from '@shared/tasks' +import { + inferDailyTaskDueDates, + parseTasksFromBody, + tasksDueOn, + type VaultTask, +} from '@shared/tasks' import { useStore } from '../store' import { + buildDailyNoteDateByPath, + buildDateNoteIndexes, classifyDateNote, - noteFolderSubpath, normalizeVaultSettings, weeklyNoteTitle, } from '../lib/vault-layout' @@ -32,8 +38,6 @@ import { PanelResizeHandle } from './PanelResizeHandle' import { ContextMenu, type ContextMenuItem } from './ContextMenu' const FULL_DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] -const DAILY_RE = /^\d{4}-\d{2}-\d{2}$/ -const WEEKLY_RE = /^\d{4}-W\d{2}$/ const WORDS_PER_DOT = 80 const MAX_DOTS = 3 const HOVER_DELAY_MS = 280 @@ -82,6 +86,21 @@ function isoDateStr(d: Date): string { ).padStart(2, '0')}` } +/** Parse a `YYYY-MM-DD` string back to a local-midnight Date. */ +function parseIsoDate(iso: string): Date { + const [y, m, d] = iso.split('-').map((s) => Number.parseInt(s, 10)) + return new Date(y, m - 1, d) +} + +const TASK_PREFIX_RE = /^\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[[ xX]\]\s?/ + +/** The raw text after the checkbox (content + any `due:`/`!priority` tokens) — + * what an inline edit field prefills with so editing is lossless. */ +function taskTail(task: VaultTask): string { + const m = task.rawText.match(TASK_PREFIX_RE) + return m ? task.rawText.slice(m[0].length) : task.content +} + function isoWeekStr(d: Date): string { return `${getISOWeekYear(d)}-W${String(getISOWeek(d)).padStart(2, '0')}` } @@ -102,6 +121,15 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { const vaultSettings = useStore((s) => s.vaultSettings) const openDailyNoteForDate = useStore((s) => s.openDailyNoteForDate) const openWeeklyNoteForDate = useStore((s) => s.openWeeklyNoteForDate) + const vaultTasks = useStore((s) => s.vaultTasks) + const tasksLoading = useStore((s) => s.tasksLoading) + const refreshTasks = useStore((s) => s.refreshTasks) + const addTaskForDate = useStore((s) => s.addTaskForDate) + const toggleTaskFromList = useStore((s) => s.toggleTaskFromList) + const openTaskAt = useStore((s) => s.openTaskAt) + const applyTaskMutation = useStore((s) => s.applyTaskMutation) + const moveTaskToDate = useStore((s) => s.moveTaskToDate) + const deleteTaskFromList = useStore((s) => s.deleteTaskFromList) const width = useStore((s) => s.panelWidths.calendar) const setPanelWidth = useStore((s) => s.setPanelWidth) const weekStart = useStore((s) => s.calendarWeekStart) @@ -111,8 +139,6 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { const settings = useMemo(() => normalizeVaultSettings(vaultSettings), [vaultSettings]) const dailyEnabled = settings.dailyNotes.enabled const weeklyEnabled = settings.weeklyNotes.enabled - const dailySubpath = settings.dailyNotes.directory - const weeklySubpath = settings.weeklyNotes.directory const firstDay = weekStart === 'sunday' ? 0 : weekStart === 'locale' ? localeFirstDay() : 1 const dayLabels = useMemo( @@ -142,28 +168,96 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [note.path]) - // title -> NoteMeta for the daily/weekly notes that exist on disk. - const dailyByTitle = useMemo(() => { - const m = new Map<string, NoteMeta>() - if (!dailyEnabled) return m - for (const n of notes) { - if (n.folder !== 'inbox') continue - if (noteFolderSubpath(n, settings) !== dailySubpath) continue - if (DAILY_RE.test(n.title)) m.set(n.title, n) - } - return m - }, [notes, settings, dailyEnabled, dailySubpath]) - - const weeklyByTitle = useMemo(() => { - const m = new Map<string, NoteMeta>() - if (!weeklyEnabled) return m - for (const n of notes) { - if (n.folder !== 'inbox') continue - if (noteFolderSubpath(n, settings) !== weeklySubpath) continue - if (WEEKLY_RE.test(n.title)) m.set(n.title, n) - } - return m - }, [notes, settings, weeklyEnabled, weeklySubpath]) + // Calendar key -> NoteMeta for the daily/weekly notes that exist on disk. + const { dailyByDate, weeklyByWeek } = useMemo( + () => buildDateNoteIndexes(notes, settings), + [notes, settings] + ) + + // The day the user has clicked to inspect (tasks + actions shown below the + // grid). Defaults to the active daily note's date, else today; follows the + // active note as it changes. + const [selectedIso, setSelectedIso] = useState<string>(() => activeDayIso ?? todayIso) + useEffect(() => { + setSelectedIso(activeDayIso ?? todayIso) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [note.path]) + const selectedDate = useMemo(() => parseIsoDate(selectedIso), [selectedIso]) + + // Tasks scheduled for the selected day. Tasks written in a daily note inherit + // that note's date (implicit due) just like the full Tasks calendar, so we run + // the same inference over the vault-wide index. Load it lazily the first time. + const dueByPath = useMemo( + () => buildDailyNoteDateByPath(notes, vaultSettings), + [notes, vaultSettings] + ) + const inferredTasks = useMemo( + () => inferDailyTaskDueDates(vaultTasks, dueByPath), + [vaultTasks, dueByPath] + ) + // The 7 days of the selected day's week (respecting the week-start setting), + // shown as an agenda under the calendar. + const weekDays = useMemo(() => { + const offset = (selectedDate.getDay() - firstDay + 7) % 7 + const start = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate() - offset + ) + return Array.from( + { length: 7 }, + (_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i) + ) + }, [selectedDate, firstDay]) + const weekRangeLabel = useMemo(() => { + const start = weekDays[0] + const end = weekDays[6] + const startStr = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + const endStr = + end.getMonth() === start.getMonth() + ? end.toLocaleDateString(undefined, { day: 'numeric' }) + : end.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + return `${startStr} – ${endStr}` + }, [weekDays]) + useEffect(() => { + if (dailyEnabled && vaultTasks.length === 0 && !tasksLoading) void refreshTasks() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dailyEnabled]) + + const [addValue, setAddValue] = useState('') + // Drag-to-reschedule: dragged task held in a ref (drop handlers read it + // synchronously); the hovered day is highlighted while dragging. + const dragTaskRef = useRef<VaultTask | null>(null) + const [dragOverIso, setDragOverIso] = useState<string | null>(null) + // Inline task editing (right-click → Edit). + const [editingTaskId, setEditingTaskId] = useState<string | null>(null) + const [editValue, setEditValue] = useState('') + // Set just before blurring on Escape so the blur handler cancels instead of commits. + const cancelEditRef = useRef(false) + // Keyboard control (active only while the panel has focus, so it never steals + // keys from the editor). Mirrors the big calendar. + const panelRef = useRef<HTMLElement>(null) + const addInputRef = useRef<HTMLInputElement>(null) + const focusedTaskRef = useRef<HTMLDivElement | null>(null) + const [activeTaskIndex, setActiveTaskIndex] = useState(0) + const [grabbedTask, setGrabbedTask] = useState<VaultTask | null>(null) + const gPending = useRef(0) + const gTimer = useRef<ReturnType<typeof setTimeout>>() + const dPending = useRef(false) + const dTimer = useRef<ReturnType<typeof setTimeout>>() + + // The selected day's tasks drive the keyboard "active task" cursor. + const selectedDayTasks = useMemo( + () => tasksDueOn(inferredTasks, selectedIso), + [inferredTasks, selectedIso] + ) + const activeIdx = Math.min(activeTaskIndex, Math.max(0, selectedDayTasks.length - 1)) + useEffect(() => { + setActiveTaskIndex(0) + }, [selectedIso]) + useEffect(() => { + focusedTaskRef.current?.scrollIntoView({ block: 'nearest' }) + }, [selectedIso, activeIdx]) const grid = useMemo(() => buildGrid(anchor, firstDay), [anchor, firstDay]) const rows = useMemo(() => { @@ -186,10 +280,10 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { list.push(n) } } - for (const d of grid) push(dailyByTitle.get(isoDateStr(d))) - for (const { monday } of rows) push(weeklyByTitle.get(isoWeekStr(monday))) + for (const d of grid) push(dailyByDate.get(isoDateStr(d))) + for (const { monday } of rows) push(weeklyByWeek.get(isoWeekStr(monday))) return list - }, [grid, rows, dailyByTitle, weeklyByTitle]) + }, [grid, rows, dailyByDate, weeklyByWeek]) const [stats, setStats] = useState<Map<string, NoteStats>>(new Map()) const statsRef = useRef(stats) @@ -231,10 +325,13 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { } }, [visibleNotes]) - const handleDayClick = useCallback( + // Open the daily note for a day, creating it (with a prompt) if missing. + // Single-click now just *selects* a day (see the grid); this runs on + // double-click, the "Open/Create note" action, and the context menu. + const openOrCreateDay = useCallback( async (day: Date, iso: string) => { if (!dailyEnabled) return - if (dailyByTitle.has(iso)) { + if (dailyByDate.has(iso)) { await openDailyNoteForDate(day) return } @@ -246,13 +343,19 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { }) if (ok) await openDailyNoteForDate(day) }, - [dailyEnabled, dailyByTitle, openDailyNoteForDate] + [dailyEnabled, dailyByDate, openDailyNoteForDate] + ) + + const addDaysIso = useCallback( + (n: number) => + isoDateStr(new Date(today.getFullYear(), today.getMonth(), today.getDate() + n)), + [today] ) const handleWeekClick = useCallback( async (monday: Date, weekIso: string) => { if (!weeklyEnabled) return - if (weeklyByTitle.has(weekIso)) { + if (weeklyByWeek.has(weekIso)) { await openWeeklyNoteForDate(monday) return } @@ -264,7 +367,7 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { }) if (ok) await openWeeklyNoteForDate(monday) }, - [weeklyEnabled, weeklyByTitle, openWeeklyNoteForDate] + [weeklyEnabled, weeklyByWeek, openWeeklyNoteForDate] ) // --- Hover preview ------------------------------------------------------- @@ -295,24 +398,24 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { if (!dailyEnabled) return e.preventDefault() clearHover() - const meta = dailyByTitle.get(iso) + const meta = dailyByDate.get(iso) const items: ContextMenuItem[] = meta ? [ { label: 'Open note', onSelect: () => void openDailyNoteForDate(day) }, { kind: 'separator' }, { label: 'Move to Trash', danger: true, onSelect: () => void trashNote(meta) }, ] - : [{ label: `Create ${iso}`, onSelect: () => void handleDayClick(day, iso) }] + : [{ label: `Create ${iso}`, onSelect: () => void openOrCreateDay(day, iso) }] setMenu({ x: e.clientX, y: e.clientY, items }) }, - [dailyEnabled, dailyByTitle, openDailyNoteForDate, handleDayClick, trashNote, clearHover] + [dailyEnabled, dailyByDate, openDailyNoteForDate, openOrCreateDay, trashNote, clearHover] ) const openWeekMenu = useCallback( (e: React.MouseEvent, monday: Date, weekIso: string) => { if (!weeklyEnabled) return e.preventDefault() clearHover() - const meta = weeklyByTitle.get(weekIso) + const meta = weeklyByWeek.get(weekIso) const items: ContextMenuItem[] = meta ? [ { label: 'Open note', onSelect: () => void openWeeklyNoteForDate(monday) }, @@ -327,9 +430,313 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { ] setMenu({ x: e.clientX, y: e.clientY, items }) }, - [weeklyEnabled, weeklyByTitle, openWeeklyNoteForDate, handleWeekClick, trashNote, clearHover] + [weeklyEnabled, weeklyByWeek, openWeeklyNoteForDate, handleWeekClick, trashNote, clearHover] ) + // Right-click a task in the detail panel: reschedule presets, inline edit, or + // delete. Arbitrary dates are assigned by dragging the task onto a day. + const openTaskMenu = useCallback( + (e: React.MouseEvent, task: VaultTask) => { + e.preventDefault() + e.stopPropagation() + clearHover() + const reschedule = (iso: string | null): void => + void applyTaskMutation(task, { kind: 'set-due', due: iso }) + const items: ContextMenuItem[] = [ + { label: 'Due today', hint: addDaysIso(0), onSelect: () => reschedule(addDaysIso(0)) }, + { + label: 'Due tomorrow', + hint: addDaysIso(1), + onSelect: () => reschedule(addDaysIso(1)), + }, + { + label: 'Due next week', + hint: addDaysIso(7), + onSelect: () => reschedule(addDaysIso(7)), + }, + ] + if (task.due && !task.dueInferred) { + items.push({ label: 'Clear due date', onSelect: () => reschedule(null) }) + } + items.push( + { kind: 'separator' }, + { + label: 'Edit', + onSelect: () => { + setEditingTaskId(task.id) + setEditValue(taskTail(task)) + }, + }, + { kind: 'separator' }, + { label: 'Delete', danger: true, onSelect: () => void deleteTaskFromList(task) } + ) + setMenu({ x: e.clientX, y: e.clientY, items }) + }, + [clearHover, applyTaskMutation, deleteTaskFromList, addDaysIso] + ) + + // After dropping a task on a day (calendar cell or week-agenda row), offer to + // move it into that day's note or just set its due date. + const openDropMenu = useCallback( + (clientX: number, clientY: number, task: VaultTask, iso: string, day: Date) => { + const dropLabel = day.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + setMenu({ + x: clientX, + y: clientY, + items: [ + { + label: `Move into ${dropLabel}'s note`, + onSelect: () => void moveTaskToDate(task, iso), + }, + { + label: `Just set due: ${dropLabel}`, + onSelect: () => void applyTaskMutation(task, { kind: 'set-due', due: iso }), + }, + ], + }) + }, + [moveTaskToDate, applyTaskMutation] + ) + + const moveSelectedDay = useCallback( + (deltaDays: number): void => { + const next = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate() + deltaDays + ) + setSelectedIso(isoDateStr(next)) + if (next.getMonth() !== anchor.getMonth() || next.getFullYear() !== anchor.getFullYear()) { + setAnchor(new Date(next.getFullYear(), next.getMonth(), 1)) + } + }, + [selectedDate, anchor] + ) + + // Vim keyboard control — active only while focus is inside the panel, so it + // never intercepts keys meant for the editor. Mirrors the big calendar. + useEffect(() => { + if (!dailyEnabled) return + const handler = (e: KeyboardEvent): void => { + const panel = panelRef.current + const active = document.activeElement as HTMLElement | null + if (!panel || !active || !panel.contains(active)) return + if (document.querySelector('[data-vim-hint-overlay]')) return + const tag = active.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return + + const consume = (): void => { + e.preventDefault() + e.stopImmediatePropagation() + } + const tasks = selectedDayTasks + const cur = tasks[Math.min(activeTaskIndex, Math.max(0, tasks.length - 1))] + + if (e.metaKey || e.ctrlKey || e.altKey) return + + if (grabbedTask) { + if (e.key === 'Escape') { + consume() + setGrabbedTask(null) + return + } + if (e.key === 'Enter') { + consume() + const cell = document.querySelector(`[data-cal-day="${selectedIso}"]`) + const rect = cell?.getBoundingClientRect() + openDropMenu( + rect ? rect.left + rect.width / 2 : window.innerWidth / 2, + rect ? rect.bottom : window.innerHeight / 2, + grabbedTask, + selectedIso, + selectedDate + ) + setGrabbedTask(null) + return + } + switch (e.key) { + case 'h': + case 'ArrowLeft': + consume() + moveSelectedDay(-1) + return + case 'l': + case 'ArrowRight': + consume() + moveSelectedDay(1) + return + case 'j': + case 'ArrowDown': + consume() + moveSelectedDay(7) + return + case 'k': + case 'ArrowUp': + consume() + moveSelectedDay(-7) + return + case '[': + consume() + setAnchor((a) => addMonths(a, -1)) + return + case ']': + consume() + setAnchor((a) => addMonths(a, 1)) + return + default: + consume() + return + } + } + + if (e.key === 'a') { + consume() + requestAnimationFrame(() => addInputRef.current?.focus()) + return + } + if (e.key === 't' && gPending.current > 0) { + consume() + gPending.current = 0 + if (gTimer.current) clearTimeout(gTimer.current) + setSelectedIso(todayIso) + setAnchor(new Date(today.getFullYear(), today.getMonth(), 1)) + return + } + if (e.key === 'g') { + consume() + if (gPending.current > 0) { + gPending.current = 0 + if (gTimer.current) clearTimeout(gTimer.current) + setSelectedIso(isoDateStr(grid[0])) + return + } + gPending.current = 1 + if (gTimer.current) clearTimeout(gTimer.current) + gTimer.current = setTimeout(() => (gPending.current = 0), 600) + return + } + if (e.key === 'G') { + consume() + setSelectedIso(isoDateStr(grid[grid.length - 1])) + return + } + + if (tasks.length > 0) { + if (e.key === 'Tab') { + consume() + const n = tasks.length + const dir = e.shiftKey ? -1 : 1 + setActiveTaskIndex((i) => (((Math.min(i, n - 1) + dir) % n) + n) % n) + return + } + if (cur) { + if (e.key === '>' || e.key === '<') { + consume() + const d = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate() + (e.key === '>' ? 1 : -1) + ) + void applyTaskMutation(cur, { kind: 'set-due', due: isoDateStr(d) }) + return + } + if (e.key === 'T') { + consume() + void applyTaskMutation(cur, { kind: 'set-due', due: todayIso }) + return + } + if (e.key === 'x' || e.key === ' ') { + consume() + void toggleTaskFromList(cur) + return + } + if (e.key === 'e') { + consume() + setEditingTaskId(cur.id) + setEditValue(taskTail(cur)) + return + } + if (e.key === 'm') { + consume() + setGrabbedTask(cur) + return + } + if (e.key === 'd') { + consume() + if (dPending.current) { + dPending.current = false + if (dTimer.current) clearTimeout(dTimer.current) + void deleteTaskFromList(cur) + } else { + dPending.current = true + if (dTimer.current) clearTimeout(dTimer.current) + dTimer.current = setTimeout(() => (dPending.current = false), 600) + } + return + } + } + } + + switch (e.key) { + case 'h': + case 'ArrowLeft': + consume() + moveSelectedDay(-1) + return + case 'l': + case 'ArrowRight': + consume() + moveSelectedDay(1) + return + case 'j': + case 'ArrowDown': + consume() + moveSelectedDay(7) + return + case 'k': + case 'ArrowUp': + consume() + moveSelectedDay(-7) + return + case '[': + consume() + setAnchor((a) => addMonths(a, -1)) + return + case ']': + consume() + setAnchor((a) => addMonths(a, 1)) + return + case 'Enter': + consume() + if (cur) void openTaskAt(cur) + else void openOrCreateDay(selectedDate, selectedIso) + return + default: + return + } + } + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + }, [ + dailyEnabled, + selectedDayTasks, + activeTaskIndex, + grabbedTask, + selectedIso, + selectedDate, + anchor, + grid, + todayIso, + today, + moveSelectedDay, + openDropMenu, + applyTaskMutation, + toggleTaskFromList, + deleteTaskFromList, + openTaskAt, + openOrCreateDay + ]) + const atRefMonth = anchor.getFullYear() === refDate.getFullYear() && anchor.getMonth() === refDate.getMonth() const atTodayMonth = @@ -351,11 +758,98 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { </span> ) + // One task row in the week agenda: draggable to a calendar day (move / set + // due), click to open, right-click for actions, inline-editable. + const renderTaskRow = ( + task: VaultTask, + dayIso: string, + opts?: { isActive?: boolean; rowRef?: React.RefObject<HTMLDivElement> | null } + ): JSX.Element => + editingTaskId === task.id ? ( + <div key={task.id} className="flex items-center gap-2 px-1.5 py-1"> + <span className="h-3.5 w-3.5 shrink-0 rounded-sm border border-paper-400/70" /> + <input + autoFocus + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + } else if (e.key === 'Escape') { + e.stopPropagation() + cancelEditRef.current = true + e.currentTarget.blur() + } + }} + onBlur={() => { + if (cancelEditRef.current) { + cancelEditRef.current = false + } else { + const text = editValue.trim() + if (text && text !== taskTail(task)) { + void applyTaskMutation(task, { kind: 'set-text', text }) + } + } + setEditingTaskId(null) + }} + className="min-w-0 flex-1 rounded border border-accent/60 bg-paper-100 px-1 py-0.5 text-xs outline-none" + spellCheck={false} + autoComplete="off" + /> + </div> + ) : ( + // Draggable <div> (not <button>) — Chromium/Electron won't fire dragstart + // on a native button. + <div + key={task.id} + ref={opts?.rowRef ?? undefined} + role="button" + tabIndex={0} + draggable + onDragStart={(e) => { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', task.id) + dragTaskRef.current = task + }} + onDragEnd={() => { + dragTaskRef.current = null + setDragOverIso(null) + }} + onClick={() => void openTaskAt(task)} + onContextMenu={(e) => openTaskMenu(e, task)} + title={`${task.content} — drag to a day to reschedule, right-click for more`} + className={[ + 'flex w-full cursor-grab items-center gap-2 rounded px-1.5 py-1 text-left text-xs text-ink-700 transition-colors active:cursor-grabbing', + opts?.isActive + ? 'bg-accent/10 ring-1 ring-inset ring-accent/40' + : 'hover:bg-paper-200', + grabbedTask?.id === task.id ? 'opacity-50' : '' + ].join(' ')} + > + <span + role="checkbox" + aria-checked={false} + onClick={(e) => { + e.stopPropagation() + void toggleTaskFromList(task) + }} + className="flex h-3.5 w-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm border border-paper-400/70 transition-colors hover:bg-paper-300/60" + /> + <span className="min-w-0 flex-1 truncate">{task.content || '(empty task)'}</span> + {task.sourcePath !== (dailyByDate.get(dayIso)?.path ?? '') && ( + <span className="shrink-0 truncate text-2xs text-ink-400">{task.noteTitle}</span> + )} + </div> + ) + return ( <section + ref={panelRef} aria-label="Calendar" + tabIndex={0} style={{ width }} - className="relative flex shrink-0 flex-col border-l border-paper-300/70 bg-paper-50/18" + className="relative flex shrink-0 flex-col border-l border-paper-300/70 bg-paper-50/18 outline-none" > <PanelResizeHandle onStart={startResize} /> @@ -409,7 +903,7 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { )} </div> - <div className="min-h-0 flex-1 overflow-y-auto px-3 py-3"> + <div className="shrink-0 px-3 py-3"> <div className={`grid ${gridCols} gap-y-1`}> {showWeekNumbers && ( <div className="flex items-center justify-center text-2xs font-medium uppercase text-ink-400"> @@ -429,7 +923,7 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { const weekIso = isoWeekStr(monday) const weekNum = getISOWeek(monday) const isActiveWeek = weekIso === activeWeekIso - const weekMeta = weeklyByTitle.get(weekIso) + const weekMeta = weeklyByWeek.get(weekIso) const weekDots = dotsFor(weekMeta ? stats.get(weekMeta.path) : undefined) const weekTasks = weekMeta ? stats.get(weekMeta.path)?.openTasks ?? 0 : 0 @@ -477,8 +971,10 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { const iso = isoDateStr(day) const inMonth = day.getMonth() === anchorMonth const isActiveDay = iso === activeDayIso + const isSelected = iso === selectedIso const isToday = iso === todayIso - const dayMeta = dailyByTitle.get(iso) + const isDragOver = dragOverIso === iso + const dayMeta = dailyByDate.get(iso) const dayStats = dayMeta ? stats.get(dayMeta.path) : undefined const dots = dotsFor(dayStats) const openTasks = dayStats?.openTasks ?? 0 @@ -487,25 +983,47 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { <button key={iso} type="button" - onClick={() => void handleDayClick(day, iso)} + data-cal-day={iso} + onClick={() => dailyEnabled && setSelectedIso(iso)} + onDoubleClick={() => void openOrCreateDay(day, iso)} onContextMenu={(e) => openDayMenu(e, day, iso)} onMouseEnter={(e) => armHover(e.currentTarget, dayMeta)} onMouseLeave={clearHover} + onDragOver={(e) => { + if (!dragTaskRef.current || !dailyEnabled) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + if (dragOverIso !== iso) setDragOverIso(iso) + }} + onDragLeave={() => { + if (dragOverIso === iso) setDragOverIso(null) + }} + onDrop={(e) => { + e.preventDefault() + const dragged = dragTaskRef.current + dragTaskRef.current = null + setDragOverIso(null) + if (dragged) openDropMenu(e.clientX, e.clientY, dragged, iso, day) + }} disabled={!dailyEnabled} - title={iso} + title={`${iso} — click to view, double-click to open`} className={[ 'relative flex flex-col items-center rounded py-1 text-xs leading-tight transition-colors', !dailyEnabled ? inMonth ? 'cursor-default text-ink-600' : 'cursor-default text-ink-400' - : isActiveDay - ? 'bg-accent font-semibold text-white' - : isToday - ? 'font-semibold text-accent ring-1 ring-inset ring-accent/50' - : inMonth - ? 'text-ink-700 hover:bg-paper-200' - : 'text-ink-400 hover:bg-paper-200', + : isDragOver + ? 'bg-accent/25 font-semibold text-ink-900 ring-2 ring-inset ring-accent/80' + : isActiveDay + ? 'bg-accent font-semibold text-white' + : isSelected + ? 'bg-paper-200 font-semibold text-ink-900 ring-1 ring-inset ring-accent/60' + : isToday + ? 'font-semibold text-accent ring-1 ring-inset ring-accent/50' + : inMonth + ? 'text-ink-700 hover:bg-paper-200' + : 'text-ink-400 hover:bg-paper-200', ].join(' ')} > {openTasks > 0 && ( @@ -532,6 +1050,124 @@ export function CalendarPanel({ note }: { note: NoteContent }): JSX.Element { </div> </div> + {dailyEnabled && ( + <div className="flex min-h-0 flex-1 flex-col border-t border-paper-300/60 px-3 py-3"> + {grabbedTask && ( + <div className="mb-2 rounded bg-accent/10 px-2 py-1 text-2xs text-accent"> + Moving “{grabbedTask.content || 'task'}” — h/j/k/l pick a day · Enter place · Esc cancel + </div> + )} + <div className="mb-2 flex items-center justify-between gap-2"> + <div className="min-w-0 truncate text-xs font-semibold text-ink-800"> + Week of {weekRangeLabel} + </div> + <button + type="button" + onClick={() => void openOrCreateDay(selectedDate, selectedIso)} + className="shrink-0 rounded px-1.5 py-0.5 text-2xs text-ink-500 transition-colors hover:bg-paper-200 hover:text-accent" + > + {dailyByDate.has(selectedIso) ? 'Open note →' : 'Create note'} + </button> + </div> + + <form + className="mb-2" + onSubmit={(e) => { + e.preventDefault() + const value = addValue.trim() + if (!value) return + void addTaskForDate(selectedIso, value) + setAddValue('') + }} + > + <input + ref={addInputRef} + type="text" + value={addValue} + onChange={(e) => setAddValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation() + if (addValue) setAddValue('') + else e.currentTarget.blur() + } + }} + placeholder={`Add a task for ${selectedDate.toLocaleDateString(undefined, { + weekday: 'short', + day: 'numeric', + })}…`} + className="w-full rounded-md border border-paper-300/60 bg-paper-200/40 px-2 py-1 text-xs outline-none placeholder:text-ink-400 focus:border-accent/60" + spellCheck={false} + autoComplete="off" + /> + </form> + + <div className="min-h-0 flex-1 space-y-2 overflow-y-auto"> + {weekDays.map((day) => { + const iso = isoDateStr(day) + const dayTasks = tasksDueOn(inferredTasks, iso) + const isSel = iso === selectedIso + const isDragOver = dragOverIso === iso + return ( + <div + key={iso} + onDragOver={(e) => { + if (!dragTaskRef.current) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + if (dragOverIso !== iso) setDragOverIso(iso) + }} + onDragLeave={() => { + if (dragOverIso === iso) setDragOverIso(null) + }} + onDrop={(e) => { + e.preventDefault() + const dragged = dragTaskRef.current + dragTaskRef.current = null + setDragOverIso(null) + if (dragged) openDropMenu(e.clientX, e.clientY, dragged, iso, day) + }} + className={[ + 'rounded', + isDragOver ? 'bg-accent/5 ring-1 ring-inset ring-accent/50' : '', + ].join(' ')} + > + <button + type="button" + onClick={() => setSelectedIso(iso)} + className={[ + 'flex w-full items-center justify-between rounded px-1.5 py-0.5 text-left text-2xs font-semibold uppercase tracking-wide transition-colors', + isSel ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700', + ].join(' ')} + > + <span> + {day.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric' })} + {iso === todayIso && ( + <span className="ml-1.5 rounded bg-accent/15 px-1 py-px text-2xs font-medium normal-case text-accent"> + Today + </span> + )} + </span> + {dayTasks.length > 0 && <span className="text-ink-400">{dayTasks.length}</span>} + </button> + {dayTasks.length > 0 && ( + <div className="mt-0.5 space-y-0.5"> + {dayTasks.map((task, ti) => { + const isActive = iso === selectedIso && ti === activeIdx + return renderTaskRow(task, iso, { + isActive, + rowRef: isActive ? focusedTaskRef : null + }) + })} + </div> + )} + </div> + ) + })} + </div> + </div> + )} + {hover && ( <div className="fixed z-50 max-w-[260px] rounded-lg border border-paper-300/75 bg-paper-50 p-3 shadow-[0_12px_28px_-18px_rgb(var(--z-shadow)/0.8)]" diff --git a/packages/app-core/src/components/CommandPalette.tsx b/packages/app-core/src/components/CommandPalette.tsx index 8c94b2df..1df5281f 100644 --- a/packages/app-core/src/components/CommandPalette.tsx +++ b/packages/app-core/src/components/CommandPalette.tsx @@ -13,6 +13,7 @@ import { } from '../lib/command-history' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' import { canReturnToCommandList } from '../lib/command-palette-mode' import { THEMES, type ThemeFamily, type ThemeMode, type ThemeOption } from '../lib/themes' import { @@ -291,6 +292,8 @@ export function CommandPalette(): JSX.Element { placeholder={inputPlaceholder} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/CommentsPanel.tsx b/packages/app-core/src/components/CommentsPanel.tsx index 5675a1a8..73fe6b4b 100644 --- a/packages/app-core/src/components/CommentsPanel.tsx +++ b/packages/app-core/src/components/CommentsPanel.tsx @@ -10,6 +10,7 @@ import { import type { NoteComment, NoteContent } from '@shared/ipc' import { useStore } from '../store' import { commentQuote } from '../lib/comments' +import { renderMarkdown } from '../lib/markdown' import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' import { @@ -384,6 +385,9 @@ function CommentCard({ onDelete: () => void }): JSX.Element { const resolved = comment.resolvedAt != null + // Render the comment body as Markdown (sanitized). Cached by renderMarkdown, + // memoized per-body so card re-renders (hover/selection) don't re-parse. + const bodyHtml = useMemo(() => renderMarkdown(comment.body), [comment.body]) const showActionShortcuts = active && commentsFocused && !editing const handleCardClick = (event: MouseEvent<HTMLElement>): void => { const target = event.target as HTMLElement | null @@ -495,7 +499,10 @@ function CommentCard({ </div> </div> ) : ( - <p className="mt-3 whitespace-pre-wrap text-sm leading-5 text-ink-900">{comment.body}</p> + <div + className="comment-prose prose-zen mt-3 text-sm leading-5 text-ink-900" + dangerouslySetInnerHTML={{ __html: bodyHtml }} + /> )} </div> </div> diff --git a/packages/app-core/src/components/DatabaseBoardView.tsx b/packages/app-core/src/components/DatabaseBoardView.tsx index f3c5e3d8..32c63309 100644 --- a/packages/app-core/src/components/DatabaseBoardView.tsx +++ b/packages/app-core/src/components/DatabaseBoardView.tsx @@ -13,6 +13,7 @@ import { splitMultiSelect, isCheckboxTrue } from '../lib/database-cells' +import { isImeComposing } from '../lib/ime' import { PlusIcon, ArrowUpRightIcon } from './icons' import { IconButton } from './ui/Button' @@ -193,7 +194,7 @@ export function DatabaseBoardView({ csvPath, doc, view }: Props): JSX.Element { setAddingOption(false) }} onKeyDown={(e) => { - if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Enter' && !isImeComposing(e)) e.currentTarget.blur() else if (e.key === 'Escape') { setOptionDraft('') setAddingOption(false) diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index e658d118..6f94f391 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -30,6 +30,7 @@ import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { IconButton } from './ui/Button' import { MoreIcon, TrashIcon, PlusIcon, DocumentIcon, DocumentTextIcon, ArrowUpRightIcon } from './icons' import { focusEditorNormalMode } from '../lib/editor-focus' +import { isImeComposing } from '../lib/ime' const FIELD_TYPE_LABELS: Record<FieldType, string> = { text: 'Text', @@ -436,7 +437,7 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. gridRef.current?.focus({ preventScroll: true }) }} onKeyDown={(e) => { - if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Enter' && !isImeComposing(e)) e.currentTarget.blur() else if (e.key === 'Escape') { setRenamingField(null) gridRef.current?.focus({ preventScroll: true }) @@ -714,7 +715,7 @@ function Cell({ field, value, editing, onStartEdit, onEndEdit, onCommit }: CellP }} onKeyDown={(e) => { e.stopPropagation() - if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Enter' && !isImeComposing(e)) e.currentTarget.blur() else if (e.key === 'Escape') onEndEdit() }} className="w-full bg-paper-50 px-2 py-1.5 text-sm text-ink-900 outline-none ring-1 ring-inset ring-accent" @@ -724,7 +725,11 @@ function Cell({ field, value, editing, onStartEdit, onEndEdit, onCommit }: CellP return ( <button type="button" onClick={onStartEdit} className="block h-full w-full px-2 py-1.5 text-left"> - <span className="block truncate text-ink-900">{field.type === 'date' ? formatDate(value) : value}</span> + {/* min-h reserves one line so an empty cell keeps the same row height as a + filled one (otherwise a new/blank row collapses shorter). (#185) */} + <span className="block min-h-5 truncate text-ink-900"> + {field.type === 'date' ? formatDate(value) : value} + </span> </button> ) } @@ -840,7 +845,7 @@ function SelectCell({ field, value, editing, onStartEdit, onEndEdit, onCommit }: onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { e.stopPropagation() - if (e.key === 'Enter' && draft.trim()) { + if (e.key === 'Enter' && !isImeComposing(e) && draft.trim()) { toggle(draft.trim().replace(/,/g, ' ')) setDraft('') } else if (e.key === 'Escape') { diff --git a/packages/app-core/src/components/DatabaseView.tsx b/packages/app-core/src/components/DatabaseView.tsx index 81a92e1a..d8c3c340 100644 --- a/packages/app-core/src/components/DatabaseView.tsx +++ b/packages/app-core/src/components/DatabaseView.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { csvPathFromDatabaseTab } from '@shared/databases' +import { csvPathFromDatabaseTab, formDirFromCsvPath } from '@shared/databases' import { serializeRows } from '@shared/database-csv' import { useStore } from '../store' import { @@ -10,11 +10,12 @@ import { removeView, renameView } from '../lib/database-cells' +import { isImeComposing } from '../lib/ime' import { DatabaseTableView } from './DatabaseTableView' import { DatabaseBoardView } from './DatabaseBoardView' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { Button, IconButton } from './ui/Button' -import { DatabaseIcon, TableIcon, KanbanIcon, PlusIcon, PanelLeftIcon } from './icons' +import { DatabaseIcon, TableIcon, KanbanIcon, PlusIcon } from './icons' /** * Host for a CSV database tab: loads the database, renders the header @@ -33,11 +34,13 @@ export function DatabaseView({ const loadDatabase = useStore((s) => s.loadDatabase) const updateDatabaseRows = useStore((s) => s.updateDatabaseRows) const updateDatabaseSchema = useStore((s) => s.updateDatabaseSchema) - const sidebarOpen = useStore((s) => s.sidebarOpen) - const toggleSidebar = useStore((s) => s.toggleSidebar) + const renameDatabase = useStore((s) => s.renameDatabase) const [viewMenu, setViewMenu] = useState<{ viewId: string; x: number; y: number } | null>(null) const [renamingView, setRenamingView] = useState<string | null>(null) const [rawMode, setRawMode] = useState(false) + const [editingTitle, setEditingTitle] = useState(false) + // Only `.base` databases rename by title (a legacy loose `.csv` doesn't). + const canRenameTitle = !!csvPath && !!formDirFromCsvPath(csvPath) useEffect(() => { if (csvPath && !doc && !loading) void loadDatabase(csvPath) @@ -73,13 +76,40 @@ export function DatabaseView({ return ( <div className="flex min-h-0 flex-1 flex-col bg-paper-100 text-ink-900"> <header className="glass-header flex h-12 shrink-0 items-center gap-2 px-4"> - {!sidebarOpen && isActive && ( - <IconButton size="sm" title="Show sidebar (⌘1)" onClick={() => toggleSidebar()}> - <PanelLeftIcon className="h-4 w-4" /> - </IconButton> - )} <DatabaseIcon className="h-4 w-4 shrink-0 text-ink-500" /> - <h2 className="truncate text-sm font-semibold text-ink-900">{doc.title}</h2> + {editingTitle && canRenameTitle ? ( + <input + autoFocus + defaultValue={doc.title} + size={Math.max(doc.title.length + 1, 6)} + onFocus={(e) => e.currentTarget.select()} + onBlur={(e) => { + const next = e.currentTarget.value.trim() + setEditingTitle(false) + if (next && next !== doc.title && csvPath) void renameDatabase(csvPath, next) + }} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter' && !isImeComposing(e)) e.currentTarget.blur() + else if (e.key === 'Escape') { + e.currentTarget.value = doc.title + e.currentTarget.blur() + } + }} + className="max-w-full rounded border border-accent bg-paper-50 px-1.5 py-0.5 text-sm font-semibold text-ink-900 outline-none" + /> + ) : ( + <h2 + className={[ + 'truncate text-sm font-semibold text-ink-900', + canRenameTitle ? 'cursor-text rounded px-1 -mx-1 hover:bg-paper-200/60' : '' + ].join(' ')} + title={canRenameTitle ? 'Double-click to rename' : undefined} + onDoubleClick={() => canRenameTitle && setEditingTitle(true)} + > + {doc.title} + </h2> + )} <span className="shrink-0 text-xs text-ink-500">{doc.rows.length}</span> <div className="ml-2 flex items-center gap-0.5 rounded-md bg-paper-200/60 p-0.5"> @@ -98,7 +128,7 @@ export function DatabaseView({ setRenamingView(null) }} onKeyDown={(e) => { - if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Enter' && !isImeComposing(e)) e.currentTarget.blur() else if (e.key === 'Escape') setRenamingView(null) }} className="w-24 rounded border border-accent bg-paper-50 px-1.5 py-1 text-xs text-ink-900 outline-none" diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index c0f97a9b..1a24118e 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -20,9 +20,12 @@ import type { PaneLayout, PaneSplit } from '../lib/pane-layout' import { parseCreateNotePath, resolveWikilinkTarget, - suggestCreateNotePath + suggestCreateNotePath, + wikilinkHeadingAnchor } from '../lib/wikilinks' +import { openWikilinkHeading } from '../lib/wikilink-navigation' import { classifyLocalAssetHref, resolveAssetVaultRelativePath } from '../lib/local-assets' +import { externalLinkUrl, extractLinkAtCursor, resolveInternalNoteHref } from '../lib/internal-links' import { buildMoveNotePrompt, parseMoveNoteTarget, @@ -41,6 +44,7 @@ import { } from '../lib/keymaps' import { navigateActiveBuffer } from '../lib/buffer-navigation' import { applyVimInsertEscape } from '../lib/vim-insert-escape' +import { focusEditorNormalMode } from '../lib/editor-focus' let vimCommandsRegistered = false let syncedVimBindings: Partial<Record<KeymapId, string[]>> = {} @@ -282,37 +286,36 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { } } -function unwrapMdUrl(url: string): string { - // Markdown wraps URLs with spaces in angle brackets: `[x](<a b.pdf>)`. - const trimmed = url.trim() - if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1) - return trimmed -} +// `extractLinkAtCursor` lives in ../lib/internal-links so the editor, the +// preview, and the Cmd/Ctrl-click handler can all share it. -function extractLinkAtCursor(doc: string, pos: number): string | null { - const lineStart = doc.lastIndexOf('\n', pos - 1) + 1 - const lineEnd = doc.indexOf('\n', pos) - const line = doc.slice(lineStart, lineEnd === -1 ? undefined : lineEnd) - const col = pos - lineStart - const wikiRe = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g - let m: RegExpExecArray | null - while ((m = wikiRe.exec(line)) !== null) { - if (col >= m.index && col < m.index + m[0].length) return m[1] - } - // Angle-bracketed URLs can contain `)` so match them specifically first. - const mdAngleRe = /\[([^\]]*)\]\(<([^>]+)>\)/g - while ((m = mdAngleRe.exec(line)) !== null) { - if (col >= m.index && col < m.index + m[0].length) return m[2] - } - const mdRe = /\[([^\]]*)\]\(([^)]+)\)/g - while ((m = mdRe.exec(line)) !== null) { - if (col >= m.index && col < m.index + m[0].length) return unwrapMdUrl(m[2]) - } - const urlRe = /https?:\/\/[^\s)>\]]+/g - while ((m = urlRe.exec(line)) !== null) { - if (col >= m.index && col < m.index + m[0].length) return m[0] +/** + * Report an ex-command error as a non-blocking, in-editor notification — the + * same red bottom-of-editor message codemirror-vim uses for its own errors + * (e.g. an unknown `:command`). The previous native `window.alert` blurred + * CodeMirror, leaving Vim users unable to type until the editor was refocused. + * Falls back to an alert (then refocuses) if the editor notification is + * unavailable. (#173) + */ +function alertEditorError(message: string): void { + const view = useStore.getState().editorViewRef + const cm = view ? getCM(view) : null + const openNotification = ( + cm as unknown as { + openNotification?: (node: Node, opts: { bottom?: boolean; duration?: number }) => void + } | null + )?.openNotification + if (cm && typeof openNotification === 'function') { + const el = document.createElement('div') + el.className = 'cm-vim-message' + el.style.color = 'red' + el.style.whiteSpace = 'pre' + el.textContent = message + openNotification.call(cm, el, { bottom: true, duration: 4000 }) + return } - return null + window.alert(message) + focusEditorNormalMode() } function registerVimCommands(): void { @@ -474,8 +477,9 @@ function registerVimCommands(): void { const target = extractLinkAtCursor(doc, pos) if (!target) return - if (/^https?:\/\//i.test(target)) { - window.open(target, '_blank') + const external = externalLinkUrl(target) + if (external) { + window.open(external, '_blank') return } @@ -498,10 +502,33 @@ function registerVimCommands(): void { const notes = state.notes const resolved = resolveWikilinkTarget(notes, target) if (resolved) { - void state.selectNote(resolved.path).then(() => { + const focusEditorSoon = (): void => { + state.setFocusedPanel('editor') + requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) + } + const headingAnchor = wikilinkHeadingAnchor(target) + if (headingAnchor) { + void openWikilinkHeading(resolved.path, headingAnchor).then(focusEditorSoon) + } else { + void state.selectNote(resolved.path).then(focusEditorSoon) + } + return + } + + // A standard Markdown link whose href resolves relative to this note — + // e.g. `[text](../Projects/plan.md)` — that wikilink name matching can't + // reach. (#201) + const internal = resolveInternalNoteHref(state.selectedPath, target, notes) + if (internal) { + const focusEditorSoon = (): void => { state.setFocusedPanel('editor') requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) - }) + } + if (internal.heading) { + void openWikilinkHeading(internal.path, internal.heading).then(focusEditorSoon) + } else { + void state.selectNote(internal.path).then(focusEditorSoon) + } return } @@ -537,7 +564,7 @@ function registerVimCommands(): void { state.setFocusedPanel('editor') requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) } catch (err) { - window.alert((err as Error).message) + alertEditorError((err as Error).message) } }) }) @@ -602,7 +629,7 @@ function registerVimNoteCommands(): void { try { parsed = parseCreateNotePath(value) } catch (err) { - window.alert((err as Error).message) + alertEditorError((err as Error).message) return } const state = useStore.getState() @@ -663,7 +690,7 @@ function registerVimNoteCommands(): void { const error = validateMoveNoteTarget(target) if (error) { - window.alert(error) + alertEditorError(error) return } const dest = parseMoveNoteTarget(target) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index a7f8315c..2b5aacc7 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -39,7 +39,6 @@ import { import { Vim, getCM, vim } from '@replit/codemirror-vim' import type { AssetMeta, ImportedAsset, NoteComment, NoteFolder } from '@shared/ipc' import { - defaultKeymap, history, historyKeymap, indentWithTab, @@ -48,9 +47,13 @@ import { undo } from '@codemirror/commands' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { isImeComposing } from '../lib/ime' import { resolveCodeLanguage } from '../lib/cm-code-languages' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' import { completionNavKeymap } from '../lib/cm-completion-nav' +import { vimAwareDefaultKeymap } from '../lib/cm-vim-default-keymap' +import { setYankToClipboardEnabled } from '../lib/cm-vim-clipboard' +import { wireYankHighlight, yankHighlightExtension } from '../lib/cm-yank-highlight' import { frontmatterStyle } from '../lib/cm-frontmatter' import { codeBlockFontPlugin } from '../lib/cm-code-block-font' import { @@ -67,21 +70,41 @@ import type { LineNumberMode } from '../store' import type { PaneEdge, PaneLeaf } from '../lib/pane-layout' import { findLeaf, inferPaneDropEdge } from '../lib/pane-layout' import { livePreviewPlugin } from '../lib/cm-live-preview' +import { codeBlockFlairPlugin } from '../lib/cm-code-block-flair' +import { tablePlugin, tableVimEntry } from '../lib/cm-table' +import { wysiwygBlocksPlugin } from '../lib/cm-wysiwyg-blocks' +import { hashtagExtension } from '../lib/cm-hashtags' +import { wikilinkRenderExtension } from '../lib/cm-wikilink-render' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { dateShortcutSource } from '../lib/cm-date-shortcuts' -import { wikilinkSource } from '../lib/cm-wikilinks' +import { wikilinkSource, wikilinkHeadingSource } from '../lib/cm-wikilinks' +import { resolveWikilinkTarget, wikilinkHeadingAnchor } from '../lib/wikilinks' +import { openWikilinkHeading } from '../lib/wikilink-navigation' +import { + externalLinkUrl, + extractLinkAtCursor, + markdownLinkAt, + resolveInternalNoteHref +} from '../lib/internal-links' +import { setBlockType, toggleWrap, wrapLink } from '../lib/cm-format' +import { EditorSelectionToolbar } from './EditorSelectionToolbar' +import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { LazyDiagramTabView, LazyPreview as Preview } from './LazyPreview' import { ConnectionsPanel } from './ConnectionsPanel' import { OutlinePanel } from './OutlinePanel' import { CalendarPanel } from './CalendarPanel' import { CommentsPanel, type CommentDraft } from './CommentsPanel' import { ContextMenu, type ContextMenuItem } from './ContextMenu' +import { promptApp } from '../lib/prompt-requests' import { TasksView } from './TasksView' import { DatabaseView } from './DatabaseView' +import { LazyExcalidrawView } from './LazyExcalidrawView' +import { isExcalidrawPath } from '@shared/excalidraw' import { TagView } from './TagView' import { HelpView } from './HelpView' import { ArchiveView } from './ArchiveView' import { TrashView } from './TrashView' +import { AssetsView } from './AssetsView' import { QuickNotesView } from './QuickNotesView' import { isTasksTabPath } from '@shared/tasks' import { isDatabaseTabPath, databaseTitleFromTab, databaseTabPath, isDatabaseCsvPath } from '@shared/databases' @@ -89,6 +112,7 @@ import { isTagsTabPath } from '@shared/tags' import { isHelpTabPath } from '@shared/help' import { isArchiveTabPath } from '@shared/archive' import { isTrashTabPath } from '@shared/trash' +import { isAssetsViewTabPath } from '@shared/assets-view' import { isQuickNotesTabPath } from '@shared/quick-notes' import { hasZenAssetItem, @@ -110,7 +134,11 @@ import { type EditorHydrationState } from '../lib/editor-hydration' import { recordRendererPerf } from '../lib/perf' -import { rememberTabScroll, recallTabScroll } from '../lib/tab-scroll-memory' +import { + rememberTabScroll, + recallTabScroll, + type TabScrollPosition +} from '../lib/tab-scroll-memory' import { parseOutline } from '../lib/outline' import { findRenderedHeadingForOutlineLine, @@ -122,10 +150,13 @@ import { } from '../lib/preview-outline-jump' import { ArchiveIcon, + ArrowLeftIcon, + ArrowRightIcon, ArrowUpRightIcon, CalendarIcon, CheckSquareIcon, CloseIcon, + PaperclipIcon, DocumentIcon, FileDownIcon, FeedbackIcon, @@ -179,7 +210,7 @@ import { isDiagramTabPath } from '../lib/diagram-tabs' import { classifyLocalAssetHref } from '../lib/local-assets' -import { getKeymapDisplay, type KeymapId } from '../lib/keymaps' +import { formatKeyToken, getKeymapDisplay, type KeymapId } from '../lib/keymaps' import { isTabStripOverflowing } from '../lib/tab-strip-overflow' const MODE_OPTIONS: Array<{ @@ -202,6 +233,28 @@ const LARGE_DOC_LIVE_PREVIEW_DEFER_CHARS = 120_000 const LARGE_DOC_LIVE_PREVIEW_DEFER_MS = 3_000 const LARGE_DOC_EDITOR_HYDRATE_DELAY_MS = 180 +// The editor keymap depends on Vim mode: in Vim mode the macOS emacs-style +// chords are stripped from `defaultKeymap` so Vim's `<C-d>` & co. work (see +// cm-vim-default-keymap). Built behind a compartment and reconfigured on toggle. +function buildEditorKeymap(vimMode: boolean): Extension { + return keymap.of([ + { + key: 'Mod-f', + run: () => { + const state = useStore.getState() + if (state.vimMode) return false + state.setSearchOpen(true) + return true + } + }, + indentWithTab, + ...vimAwareDefaultKeymap(vimMode), + ...historyKeymap, + ...searchKeymap, + ...completionKeymap + ]) +} + function markdownEditingExtensions(): Extension[] { return [ markdown({ base: markdownLanguage, codeLanguages: resolveCodeLanguage, addKeymap: true }), @@ -220,6 +273,29 @@ function markdownSyntaxHighlightExtensions(): Extension[] { ] } +/** + * Live-preview ("WYSIWYG") rendering bundle: the base marker-hiding/inline + * plugin plus block-level renderers — tables, blockquote bars, list + * bullets, horizontal rules, fenced-code cards, hashtag chips, and + * wikilink rendering. Loaded by the livePreview compartment (gated by the + * `livePreview` setting); cleared to `[]` when off. + * + * Ported from the WYSIWYG work in PR #185 (author: songgnqing). That PR's + * frontmatter-properties panel is intentionally excluded — it depends on + * the PR's breaking database restructure. + */ +function wysiwygExtensions(): Extension[] { + return [ + livePreviewPlugin, + codeBlockFlairPlugin, + tablePlugin, + tableVimEntry, + wysiwygBlocksPlugin, + ...hashtagExtension, + ...wikilinkRenderExtension + ] +} + const paperHighlight = HighlightStyle.define([ // Markdown-level tokens { tag: t.heading1, class: 'tok-heading1' }, @@ -230,6 +306,7 @@ const paperHighlight = HighlightStyle.define([ { tag: t.heading6, class: 'tok-heading6' }, { tag: t.emphasis, class: 'tok-emphasis' }, { tag: t.strong, class: 'tok-strong' }, + { tag: t.strikethrough, class: 'tok-strikethrough' }, { tag: t.link, class: 'tok-link' }, { tag: t.url, class: 'tok-url' }, { tag: t.monospace, class: 'tok-monospace' }, @@ -455,8 +532,10 @@ function lineNumberExtension(mode: LineNumberMode): Extension { type TabDropIndicator = { path: string; position: 'before' | 'after' } | null type SelectionCommentAction = { x: number; y: number } | null -const COMMENT_ACTION_WIDTH = 34 -const COMMENT_ACTION_HEIGHT = 34 +// The selection bubble toolbar is centered over the selection (translateX -50%), +// so we only need a rough half-width to keep it on screen. +const SELECTION_TOOLBAR_HALF_WIDTH = 140 +const SELECTION_TOOLBAR_HEIGHT = 112 function clampViewport(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) @@ -478,43 +557,32 @@ function selectionEdgeCoords(view: EditorView): { ) } -function selectionEndCoords(view: EditorView): { - right: number - top: number - bottom: number -} | null { - const sel = view.state.selection.main - return ( - view.coordsAtPos(sel.to, -1) ?? - view.coordsAtPos(sel.to, 1) ?? - selectionEdgeCoords(view) - ) -} - function getSelectionCommentAction(view: EditorView): SelectionCommentAction { const sel = view.state.selection.main const active = document.activeElement - const hasFocus = view.hasFocus || (active instanceof Node && view.dom.contains(active)) + // Keep the toolbar up while the editor holds the selection OR while the user + // has tabbed into the toolbar itself (keyboard navigation). + const inToolbar = active instanceof Element && active.closest('[data-selection-toolbar]') != null + const hasFocus = + view.hasFocus || (active instanceof Node && view.dom.contains(active)) || inToolbar if (sel.empty || !hasFocus) return null - const coords = selectionEndCoords(view) - if (!coords) return null - const editorRect = view.scrollDOM.getBoundingClientRect() - const desiredX = coords.right + 10 - const maxX = Math.min( - editorRect.right - COMMENT_ACTION_WIDTH - 12, - window.innerWidth - COMMENT_ACTION_WIDTH - 8 - ) + const start = view.coordsAtPos(sel.from, 1) + const end = view.coordsAtPos(sel.to, -1) + if (!start || !end) return null + // Center the bubble horizontally over the selection; sit it just above the + // top of the selection, flipping below when there isn't room. + const centerX = (Math.min(start.left, end.left) + Math.max(start.right, end.right)) / 2 + const gap = 8 + const above = Math.min(start.top, end.top) - SELECTION_TOOLBAR_HEIGHT - gap + const below = Math.max(start.bottom, end.bottom) + gap + const y = above < 8 ? below : above return { x: clampViewport( - desiredX, - editorRect.left + 8, - Math.max(editorRect.left + 8, maxX) + centerX, + SELECTION_TOOLBAR_HALF_WIDTH + 8, + window.innerWidth - SELECTION_TOOLBAR_HALF_WIDTH - 8 ), - y: clampViewport( - coords.top + (coords.bottom - coords.top) / 2 - COMMENT_ACTION_HEIGHT / 2, - 8, - window.innerHeight - COMMENT_ACTION_HEIGHT - 8 - ) + y: clampViewport(y, 8, window.innerHeight - SELECTION_TOOLBAR_HEIGHT - 8) } } @@ -530,6 +598,39 @@ function getEditorContextMenuPosition(view: EditorView): { x: number; y: number } } +/** + * Follow a link target extracted from the editor (Cmd/Ctrl-click): an external + * URL opens in the browser; a Markdown link to another note or a `[[wikilink]]` + * navigates, scrolling to its `#heading` when present. Returns false when the + * target resolves to nothing (so the click falls through to normal behavior). (#201) + */ +function followEditorLink(target: string): boolean { + const external = externalLinkUrl(target) + if (external) { + window.open(external, '_blank') + return true + } + const state = useStore.getState() + const focusSoon = (): void => { + state.setFocusedPanel('editor') + requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) + } + const internal = resolveInternalNoteHref(state.selectedPath, target, state.notes) + if (internal) { + if (internal.heading) void openWikilinkHeading(internal.path, internal.heading).then(focusSoon) + else void state.selectNote(internal.path).then(focusSoon) + return true + } + const wikilink = resolveWikilinkTarget(state.notes, target) + if (wikilink) { + const heading = wikilinkHeadingAnchor(target) + if (heading) void openWikilinkHeading(wikilink.path, heading).then(focusSoon) + else void state.selectNote(wikilink.path).then(focusSoon) + return true + } + return false +} + export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const paneId = pane.id const isActive = useStore((s) => s.activePaneId === paneId) @@ -584,6 +685,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const pendingJumpLocation = useStore((s) => s.pendingJumpLocation) const clearPendingJumpLocation = useStore((s) => s.clearPendingJumpLocation) const vimMode = useStore((s) => s.vimMode) + const vimYankToClipboard = useStore((s) => s.vimYankToClipboard) const livePreview = useStore((s) => s.livePreview) const editorFontSize = useStore((s) => s.editorFontSize) const editorLineHeight = useStore((s) => s.editorLineHeight) @@ -591,6 +693,11 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const textFont = useStore((s) => s.textFont) const tabsEnabled = useStore((s) => s.tabsEnabled) const wrapTabs = useStore((s) => s.wrapTabs) + const jumpToPreviousNote = useStore((s) => s.jumpToPreviousNote) + const jumpToNextNote = useStore((s) => s.jumpToNextNote) + const canGoBack = useStore((s) => s.noteBackstack.length > 0) + const canGoForward = useStore((s) => s.noteForwardstack.length > 0) + const tabNavOverrides = useStore((s) => s.keymapOverrides) const workspaceMode = useStore((s) => s.workspaceMode) const wordWrap = useStore((s) => s.wordWrap) const systemFolderLabels = useStore((s) => s.systemFolderLabels) @@ -614,8 +721,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ) const calendarAvailable = useMemo(() => { const s = normalizeVaultSettings(vaultSettings) - return s.dailyNotes.enabled || s.weeklyNotes.enabled - }, [vaultSettings]) + if (!(s.dailyNotes.enabled || s.weeklyNotes.enabled)) return false + // The calendar navigates daily/weekly notes — it's meaningless in the Quick + // Notes scratchpad, and showing it there makes a quick note look like a + // calendar-linked daily note (a real source of confusion). Hide it there. + if (content?.folder === 'quick') return false + return true + }, [vaultSettings, content?.folder]) const [commentDraft, setCommentDraft] = useState<CommentDraft | null>(null) const [selectionCommentAction, setSelectionCommentAction] = useState<SelectionCommentAction>(null) @@ -653,6 +765,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const lastProgrammaticPreviewTopRef = useRef<number | null>(null) const lastRestoredPathRef = useRef<string | null>(null) const vimCompartmentRef = useRef<Compartment | null>(null) + const editorKeymapCompartmentRef = useRef<Compartment | null>(null) const markdownCompartmentRef = useRef<Compartment | null>(null) const markdownSyntaxCompartmentRef = useRef<Compartment | null>(null) const livePreviewCompartmentRef = useRef<Compartment | null>(null) @@ -737,6 +850,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { return () => window.removeEventListener('zen:toggle-connections', handler) }, [isActive, toggleConnectionsPanel]) + // Mirror `set clipboard=unnamed`: when enabled, Vim yank/delete/change also + // copy to the system clipboard. The patch is global, so any pane can drive it. + // Also install the highlight-on-yank handler (idempotent). (#144) + useEffect(() => { + setYankToClipboardEnabled(vimYankToClipboard) + wireYankHighlight() + }, [vimYankToClipboard]) + const toggleOutlinePanel = useCallback(() => { setOutlineOpen((open) => !open) }, []) @@ -1277,12 +1398,14 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } if (viewRef.current) return const vimCompartment = new Compartment() + const editorKeymapCompartment = new Compartment() const markdownCompartment = new Compartment() const markdownSyntaxCompartment = new Compartment() const livePreviewCompartment = new Compartment() const lineNumbersCompartment = new Compartment() const wordWrapCompartment = new Compartment() vimCompartmentRef.current = vimCompartment + editorKeymapCompartmentRef.current = editorKeymapCompartment markdownCompartmentRef.current = markdownCompartment markdownSyntaxCompartmentRef.current = markdownSyntaxCompartment livePreviewCompartmentRef.current = livePreviewCompartment @@ -1299,11 +1422,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const state = EditorState.create({ doc: initialBody, extensions: [ + appMarkdownSnippetExtension(), vimCompartment.of(s0.vimMode ? vim() : []), history(), drawSelection(), highlightActiveLine(), taskJumpHighlightField, + yankHighlightExtension, commentDecorationField, wordWrapCompartment.of(s0.wordWrap ? EditorView.lineWrapping : []), markdownCompartment.of(deferInitialRichMarkdown ? [] : markdownEditingExtensions()), @@ -1311,12 +1436,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { deferInitialRichMarkdown ? [] : markdownSyntaxHighlightExtensions() ), livePreviewCompartment.of( - s0.livePreview && !deferInitialRichMarkdown ? livePreviewPlugin : [] + s0.livePreview && !deferInitialRichMarkdown ? wysiwygExtensions() : [] ), lineNumbersCompartment.of(lineNumberExtension(s0.lineNumberMode)), tooltips({ parent: document.body }), autocompletion({ - override: [slashCommandSource, dateShortcutSource, wikilinkSource], + override: [slashCommandSource, dateShortcutSource, wikilinkSource, wikilinkHeadingSource], addToOptions: [{ render: slashCommandRender.render, position: 0 }], icons: false, optionClass: (completion) => @@ -1325,25 +1450,39 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { : 'slash-cmd-option' }), completionNavKeymap, - keymap.of([ - { - key: 'Mod-f', - run: () => { - const state = useStore.getState() - if (state.vimMode) return false - state.setSearchOpen(true) - return true - } - }, - indentWithTab, - ...defaultKeymap, - ...historyKeymap, - ...searchKeymap, - ...completionKeymap - ]), + editorKeymapCompartment.of(buildEditorKeymap(s0.vimMode)), EditorView.domEventHandlers({ - mousedown: (event) => { + mousedown: (event, view) => { const target = event.target as HTMLElement | null + // Follow a Markdown link in live preview. A plain click follows a + // *rendered* link (the cursor is outside it, so its `(url)` syntax + // is hidden) — mirroring how `[[wikilinks]]` behave; clicking a + // link the cursor is already inside edits it instead. Cmd/Ctrl-click + // always follows (and reaches wikilinks too), gated to the primary + // button so a macOS Ctrl+click right-click doesn't trigger it. (#201) + if (event.button === 0 && !event.altKey && !event.shiftKey) { + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }) + if (pos != null) { + const doc = view.state.doc.toString() + if (event.metaKey || event.ctrlKey) { + const linkTarget = extractLinkAtCursor(doc, pos) + if (linkTarget && followEditorLink(linkTarget)) { + event.preventDefault() + return true + } + } else { + const link = markdownLinkAt(doc, pos) + if (link) { + const sel = view.state.selection.main + const rendered = sel.to < link.from || sel.from > link.to + if (rendered && followEditorLink(link.href)) { + event.preventDefault() + return true + } + } + } + } + } const marker = target?.closest<HTMLElement>('.cm-comment-marker[data-comment-id]') const commentId = marker?.dataset.commentId if (!commentId) return false @@ -1451,7 +1590,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) ] if (useStore.getState().livePreview) { - restoreEffects.push(livePreviewCompartment.reconfigure(livePreviewPlugin)) + restoreEffects.push(livePreviewCompartment.reconfigure(wysiwygExtensions())) } view.dispatch({ effects: restoreEffects }) }, LARGE_DOC_LIVE_PREVIEW_DEFER_MS) @@ -1543,7 +1682,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) ) if (livePreviewEnabled && livePreviewCompartment) { - effects.push(livePreviewCompartment.reconfigure(livePreviewPlugin)) + effects.push(livePreviewCompartment.reconfigure(wysiwygExtensions())) } } const dispatchStartedAt = performance.now() @@ -1590,7 +1729,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) ] if (useStore.getState().livePreview && livePreviewCompartment) { - restoreEffects.push(livePreviewCompartment.reconfigure(livePreviewPlugin)) + restoreEffects.push(livePreviewCompartment.reconfigure(wysiwygExtensions())) } view.dispatch({ effects: restoreEffects @@ -1620,7 +1759,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const view = viewRef.current const comp = vimCompartmentRef.current if (!view || !comp) return - view.dispatch({ effects: comp.reconfigure(vimMode ? vim() : []) }) + const effects = [comp.reconfigure(vimMode ? vim() : [])] + const keymapComp = editorKeymapCompartmentRef.current + if (keymapComp) effects.push(keymapComp.reconfigure(buildEditorKeymap(vimMode))) + view.dispatch({ effects }) }, [vimMode]) useEffect(() => { const view = viewRef.current @@ -1642,12 +1784,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { markdownSyntaxCompartment.reconfigure(markdownSyntaxHighlightExtensions()) ) } - effects.push(comp.reconfigure(livePreviewPlugin)) + effects.push(comp.reconfigure(wysiwygExtensions())) view.dispatch({ effects }) } return } - view.dispatch({ effects: comp.reconfigure(livePreview ? livePreviewPlugin : []) }) + view.dispatch({ effects: comp.reconfigure(livePreview ? wysiwygExtensions() : []) }) }, [livePreview]) useEffect(() => { const view = viewRef.current @@ -2128,6 +2270,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isHelp: false, isArchive: false, isTrash: false, + isAssetsView: false, isAsset: false, isDiagram: false, isDatabase: false @@ -2174,6 +2317,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isTrash: true } } + if (isAssetsViewTabPath(path)) { + return { + ...base, + title: 'Assets', + isAssetsView: true + } + } if (isAssetTabPath(path)) { const assetPath = assetPathFromTab(path) return { @@ -2376,6 +2526,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isHelp: boolean isArchive: boolean isTrash: boolean + isAssetsView: boolean isAsset: boolean isDiagram: boolean }) => { @@ -2387,6 +2538,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { tab.isHelp || tab.isArchive || tab.isTrash || + tab.isAssetsView || tab.isAsset || tab.isDiagram return ( @@ -2453,6 +2605,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { e.preventDefault() setTabMenu({ x: e.clientX, y: e.clientY, path: tab.path }) }} + onMouseDown={(e) => { + if (e.button === 1) { + e.preventDefault() + } + }} + onAuxClick={(e) => { + if (e.button === 1) { + e.preventDefault() + void closeTabInPane(paneId, tab.path) + } + }} > {tabDropIndicator?.path === tab.path && ( <div @@ -2464,15 +2627,17 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { )} <div className={[ - 'group flex h-8 min-w-0 items-center gap-1 rounded-t-lg border border-b-0 px-1.5 text-sm transition-colors', + // Flat, full-height segmented tabs (VS Code-style): right-border + // separators, no rounded tops; the active tab is filled. (#185) + 'group relative flex h-full min-h-8 min-w-0 items-center gap-1.5 border-r border-paper-300/60 px-2 text-sm transition-colors', tab.pinned ? 'max-w-[140px]' : 'max-w-[220px]', active && isActive ? focusedPanel === 'tabs' - ? 'border-accent/70 bg-paper-100 text-ink-900 ring-1 ring-inset ring-accent/60' - : 'border-paper-300/80 bg-paper-100 text-ink-900' + ? 'bg-paper-200 font-medium text-ink-900 ring-1 ring-inset ring-accent/60' + : 'bg-paper-200 font-medium text-ink-900' : active - ? 'border-paper-300/60 bg-paper-100/70 text-ink-800' - : 'border-transparent bg-paper-200/45 text-ink-500 hover:bg-paper-200/70 hover:text-ink-900' + ? 'bg-paper-200/70 text-ink-700' + : 'text-ink-500 hover:bg-paper-200/40 hover:text-ink-900' ].join(' ')} > {tab.pinned && ( @@ -2512,6 +2677,9 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { {tab.isTrash && ( <TrashIcon width={13} height={13} className="shrink-0 text-accent" /> )} + {tab.isAssetsView && ( + <PaperclipIcon width={13} height={13} className="shrink-0 text-accent" /> + )} {tab.isAsset && ( <DocumentIcon width={13} height={13} className="shrink-0 text-accent" /> )} @@ -2527,10 +2695,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { aria-label={`Close ${tab.title}`} onClick={() => void closeTabInPane(paneId, tab.path)} className={[ - 'flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-colors', + // The active tab keeps its close affordance; inactive tabs + // reveal it on hover/focus (VS Code convention). (#185) + 'flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition', active ? 'text-ink-500 hover:bg-paper-200 hover:text-ink-900' - : 'hover:bg-paper-300/70' + : 'opacity-0 hover:bg-paper-300/70 group-hover:opacity-100 focus-visible:opacity-100' ].join(' ')} > <CloseIcon width={12} height={12} /> @@ -2565,47 +2735,55 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const toolbar = useMemo(() => { if (!content) return null const folder = content.folder + // Excalidraw drawings only get the file-level actions (archive/trash) — the + // Markdown-specific controls (edit/split/preview, connections, comments, + // outline, calendar, PDF export) don't apply to a canvas. + const isDrawing = isExcalidrawPath(content.path) return ( <div className="flex items-center gap-1 text-ink-500"> - <ToggleGroup mode={mode} onChange={applyPaneMode} /> - <div className="mx-2 h-4 w-px bg-paper-300" /> - <IconBtn - title={connectionsOpen ? 'Hide connections' : 'Show connections'} - active={connectionsOpen} - onClick={toggleConnectionsPanel} - > - <PanelRightIcon /> - </IconBtn> - <IconBtn - title={ - commentsOpen - ? 'Hide comments' - : `Show comments${openCommentCount > 0 ? ` (${openCommentCount})` : ''}` - } - active={commentsOpen} - onClick={toggleCommentsPanel} - > - <FeedbackIcon /> - </IconBtn> - <IconBtn - title={outlineOpen ? 'Hide outline' : 'Show outline'} - active={outlineOpen} - onClick={toggleOutlinePanel} - > - <ListTreeIcon /> - </IconBtn> - {calendarAvailable && ( - <IconBtn - title={calendarOpen ? 'Hide calendar' : 'Show calendar'} - active={calendarOpen} - onClick={toggleCalendarPanel} - > - <CalendarIcon /> - </IconBtn> + {!isDrawing && ( + <> + <ToggleGroup mode={mode} onChange={applyPaneMode} /> + <div className="mx-2 h-4 w-px bg-paper-300" /> + <IconBtn + title={connectionsOpen ? 'Hide connections' : 'Show connections'} + active={connectionsOpen} + onClick={toggleConnectionsPanel} + > + <PanelRightIcon /> + </IconBtn> + <IconBtn + title={ + commentsOpen + ? 'Hide comments' + : `Show comments${openCommentCount > 0 ? ` (${openCommentCount})` : ''}` + } + active={commentsOpen} + onClick={toggleCommentsPanel} + > + <FeedbackIcon /> + </IconBtn> + <IconBtn + title={outlineOpen ? 'Hide outline' : 'Show outline'} + active={outlineOpen} + onClick={toggleOutlinePanel} + > + <ListTreeIcon /> + </IconBtn> + {calendarAvailable && ( + <IconBtn + title={calendarOpen ? 'Hide calendar' : 'Show calendar'} + active={calendarOpen} + onClick={toggleCalendarPanel} + > + <CalendarIcon /> + </IconBtn> + )} + <IconBtn title="Export as PDF (⇧⌘E)" onClick={() => void exportActiveNotePdf()}> + <FileDownIcon /> + </IconBtn> + </> )} - <IconBtn title="Export as PDF (⇧⌘E)" onClick={() => void exportActiveNotePdf()}> - <FileDownIcon /> - </IconBtn> {folder === 'trash' ? ( <IconBtn title="Restore" onClick={() => void restoreActive()}> <ArrowUpRightIcon /> @@ -2689,11 +2867,24 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { } }, [hasTabs, wrapTabs, tabStripMeasureKey]) + // Keep the active tab scrolled into view in the horizontally-scrolling strip, + // so switching tabs (e.g. via the keyboard) never leaves it off-screen. (#185) + useEffect(() => { + if (!hasTabs || wrapTabs || !activeTab) return + const el = tabStripRef.current?.querySelector<HTMLElement>('[data-tab-active="true"]') + el?.scrollIntoView({ inline: 'nearest', block: 'nearest' }) + }, [activeTab, hasTabs, wrapTabs, tabStripMeasureKey]) + + // Outer header holds the back/forward nav buttons + the (flex-1) tab strip. + const tabStripHeaderClass = [ + 'glass-header flex shrink-0 items-stretch border-b border-paper-300/70 pl-1', + wrapTabs ? 'min-h-10' : 'h-10' + ].join(' ') const tabStripClass = [ - 'workspace-tab-strip glass-header flex shrink-0 items-start gap-1 border-b border-paper-300/70 px-3 pt-2', + 'workspace-tab-strip flex min-w-0 flex-1 items-stretch gap-0', wrapTabs ? 'min-h-10 flex-wrap content-start overflow-x-hidden overflow-y-visible' - : `${tabStripOverflowing ? 'h-14 overflow-x-auto' : 'h-10 overflow-x-hidden'} overflow-y-hidden` + : `h-10 ${tabStripOverflowing ? 'overflow-x-auto' : 'overflow-x-hidden'} overflow-y-hidden` ].join(' ') const deferEditorHydration = shouldDeferEditorHydration( showEditor, @@ -2789,11 +2980,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { ]) // Remember this tab's scroll position as the user scrolls, so switching - // away and back (e.g. opening a diagram in a tab) restores it instead of - // snapping to the top. `content` is only set for real notes — virtual - // tabs (tasks, diagrams, …) never reach here. Capture only; the restore - // happens in the activation layout effect below. - useEffect(() => { + // away and back (e.g. opening another note or a diagram in a tab) restores + // it instead of snapping to the top. This intentionally uses a layout + // effect: its cleanup runs before the next tab's doc-sync layout effect + // resets the shared CodeMirror scroller to 0, so the outgoing tab cannot be + // overwritten by that programmatic reset. + useLayoutEffect(() => { const path = content?.path if (!path) return const editorEl = editorReady ? viewRef.current?.scrollDOM ?? null : null @@ -2801,36 +2993,46 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if (!editorEl && !previewEl) return let frame = 0 - const capture = (): void => { + const captureNow = (): void => { + frame = 0 + const prev = recallTabScroll(path) + const view = viewRef.current + const selection = + view && viewPathRef.current === path ? view.state.selection.main : null + const next: TabScrollPosition = { + // Keep the other surface's value when one isn't mounted (e.g. + // preview-only mode has no editor scroller), so we don't clobber it. + editor: editorEl?.scrollTop ?? prev?.editor ?? 0, + preview: previewEl?.scrollTop ?? prev?.preview ?? 0 + } + if (selection) { + next.editorSelectionAnchor = selection.anchor + next.editorSelectionHead = selection.head + } + rememberTabScroll(path, next) + } + const scheduleCapture = (): void => { if (frame) return - frame = requestAnimationFrame(() => { - frame = 0 - const prev = recallTabScroll(path) - rememberTabScroll(path, { - // Keep the other surface's value when one isn't mounted (e.g. - // preview-only mode has no editor scroller), so we don't clobber it. - editor: editorEl?.scrollTop ?? prev?.editor ?? 0, - preview: previewEl?.scrollTop ?? prev?.preview ?? 0 - }) - }) + frame = requestAnimationFrame(captureNow) } - editorEl?.addEventListener('scroll', capture, { passive: true }) - previewEl?.addEventListener('scroll', capture, { passive: true }) + editorEl?.addEventListener('scroll', scheduleCapture, { passive: true }) + previewEl?.addEventListener('scroll', scheduleCapture, { passive: true }) return () => { if (frame) cancelAnimationFrame(frame) - editorEl?.removeEventListener('scroll', capture) - previewEl?.removeEventListener('scroll', capture) + captureNow() + editorEl?.removeEventListener('scroll', scheduleCapture) + previewEl?.removeEventListener('scroll', scheduleCapture) } }, [content?.path, mode, editorReady]) - // Restore a note tab's remembered scroll on (re)activation. Keyed on the + // Restore a note tab's remembered editor state on (re)activation. Keyed on the // active path, which flips note → (diagram tab) → note even though the // editor view's own `pathChanged` does not — that's why opening a diagram - // in a tab and returning otherwise snapped the note to the top. Runs as a - // layout effect (after the doc-sync effect dispatches the body) so the - // editor is restored before paint; the preview is restored best-effort now - // and again from `onRendered` once async diagrams settle. Explicit jumps - // own the scroll, so defer to a matching `pendingJumpLocation`. + // in a tab and returning otherwise snapped the note to the top. Runs after + // the doc-sync effect dispatches the body so the editor selection and scroll + // are restored before paint; the preview is restored best-effort now and + // again from `onRendered` once async diagrams settle. Explicit jumps own the + // scroll, so defer to a matching `pendingJumpLocation`. useLayoutEffect(() => { const path = content?.path ?? null // Only act on a genuine activation (path change). The `pendingJumpLocation` @@ -2846,10 +3048,30 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const applyEditor = (): void => { const view = viewRef.current - if (view) view.scrollDOM.scrollTop = remembered.editor + if (!view) return + if ( + remembered.editorSelectionAnchor != null && + remembered.editorSelectionHead != null + ) { + const docLength = view.state.doc.length + const anchor = Math.max( + 0, + Math.min(docLength, remembered.editorSelectionAnchor) + ) + const head = Math.max( + 0, + Math.min(docLength, remembered.editorSelectionHead) + ) + const current = view.state.selection.main + if (current.anchor !== anchor || current.head !== head) { + view.dispatch({ selection: { anchor, head } }) + } + } + view.scrollDOM.scrollTop = remembered.editor } applyEditor() const raf = requestAnimationFrame(applyEditor) + const postFocusTimeout = window.setTimeout(applyEditor, 0) if (remembered.preview > 0) { previewRestoreTargetRef.current = { path, top: remembered.preview } @@ -2859,7 +3081,10 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { lastProgrammaticPreviewTopRef.current = previewEl.scrollTop } } - return () => cancelAnimationFrame(raf) + return () => { + cancelAnimationFrame(raf) + window.clearTimeout(postFocusTimeout) + } }, [content?.path, pendingJumpLocation?.path]) const paneFrameClass = [ @@ -2882,13 +3107,47 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { }} > {hasTabs && ( - <div - ref={tabStripRef} - className={tabStripClass} - onDragOver={handleTabStripDragOver} - onDrop={handleTabStripDrop} - > - {tabItems.map((tab, i) => { + <div className={tabStripHeaderClass}> + <div className="flex shrink-0 items-center gap-0.5 self-center"> + {!sidebarOpen && ( + <IconBtn + title="Show sidebar (⌘1)" + onClick={toggleSidebar} + tooltipAlign="left" + > + <PanelLeftIcon width={16} height={16} /> + </IconBtn> + )} + <IconBtn + title={`Go back (${getKeymapDisplay( + tabNavOverrides, + vimMode ? 'vim.historyBack' : 'global.historyBack' + )})`} + onClick={() => void jumpToPreviousNote()} + disabled={!canGoBack} + tooltipAlign="left" + > + <ArrowLeftIcon width={16} height={16} /> + </IconBtn> + <IconBtn + title={`Go forward (${getKeymapDisplay( + tabNavOverrides, + vimMode ? 'vim.historyForward' : 'global.historyForward' + )})`} + onClick={() => void jumpToNextNote()} + disabled={!canGoForward} + tooltipAlign="left" + > + <ArrowRightIcon width={16} height={16} /> + </IconBtn> + </div> + <div + ref={tabStripRef} + className={tabStripClass} + onDragOver={handleTabStripDragOver} + onDrop={handleTabStripDrop} + > + {tabItems.map((tab, i) => { // Draw a subtle vertical separator between the last pinned // tab and the first unpinned one (VSCode convention). The // separator is a flex sibling, not a wrapper, so drag hit- @@ -2907,16 +3166,12 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { </Fragment> ) })} + </div> </div> )} {content && !zenMode && ( <header className="glass-header flex h-12 shrink-0 items-center justify-between gap-3 px-4"> <div className="flex min-w-0 flex-1 items-center gap-1"> - {!sidebarOpen && isActive && ( - <IconBtn title="Show sidebar (⌘1)" onClick={toggleSidebar}> - <PanelLeftIcon /> - </IconBtn> - )} <Breadcrumb note={content} autoFocus={isActive && pendingTitleFocusPath === content.path} @@ -2963,6 +3218,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { <ArchiveView /> ) : isTrashTabPath(activeTab) ? ( <TrashView /> + ) : isAssetsViewTabPath(activeTab) ? ( + <AssetsView /> ) : activeTab && isAssetTabPath(activeTab) ? ( isDatabaseCsvPath(assetPathFromTab(activeTab) ?? '') ? ( <DatabaseView @@ -2976,6 +3233,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { <LazyDiagramTabView diagram={diagramFromTabPath(activeTab)} /> ) : activeTab && isDatabaseTabPath(activeTab) ? ( <DatabaseView tabPath={activeTab} isActive={isActive} /> + ) : activeTab && isExcalidrawPath(activeTab) ? ( + <LazyExcalidrawView path={activeTab} /> ) : content ? ( <div className={[ @@ -3020,7 +3279,16 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { </div> )} {editorReady ? ( - <div ref={setContainerRef} className="min-h-0 min-w-0 flex-1" /> + <div + ref={setContainerRef} + className={[ + 'min-h-0 min-w-0 flex-1', + // WYSIWYG styling (code-block cards, etc.) is gated on + // the same `livePreview` condition that loads the + // wysiwyg plugins, so CSS and plugins stay in lockstep. + livePreview ? 'cm-wysiwyg' : '' + ].join(' ')} + /> ) : ( <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center text-sm text-ink-400"> Preparing editor… @@ -3090,23 +3358,24 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { selectionCommentAction && !commentDraft && !zenMode && ( - <button - type="button" - data-selection-comment-action - title="Add comment" - aria-label="Add comment to selected text" - onMouseDown={(event) => { - event.preventDefault() - event.stopPropagation() + <EditorSelectionToolbar + x={selectionCommentAction.x} + y={selectionCommentAction.y} + onWrap={(marker) => { + const view = viewRef.current + if (view) toggleWrap(view, marker) }} - onClick={() => { - captureCommentDraft() + onLink={() => { + const view = viewRef.current + if (view) wrapLink(view) }} - className="fixed z-50 flex h-[34px] w-[34px] items-center justify-center rounded-lg border border-paper-300/75 bg-paper-100/92 text-ink-700 shadow-[0_10px_24px_-18px_rgb(var(--z-shadow)/0.7),0_0_0_1px_rgb(var(--z-bg)/0.55)] backdrop-blur transition-colors hover:border-accent/45 hover:bg-paper-50 hover:text-accent" - style={{ left: selectionCommentAction.x, top: selectionCommentAction.y }} - > - <FeedbackIcon width={16} height={16} /> - </button> + onComment={() => captureCommentDraft()} + onBlockType={(type) => { + const view = viewRef.current + if (view) setBlockType(view, type) + }} + onDismiss={() => viewRef.current?.focus()} + /> )} {tabMenu && ( <ContextMenu @@ -3262,13 +3531,13 @@ function buildEditorContextItems( } }, { kind: 'separator' }, - { label: 'Cut', hint: '⌘X', disabled: !hasSelection, onSelect: cut }, - { label: 'Copy', hint: '⌘C', disabled: !hasSelection, onSelect: copy }, - { label: 'Paste', hint: '⌘V', onSelect: paste }, + { label: 'Cut', hint: formatKeyToken('Mod+X'), disabled: !hasSelection, onSelect: cut }, + { label: 'Copy', hint: formatKeyToken('Mod+C'), disabled: !hasSelection, onSelect: copy }, + { label: 'Paste', hint: formatKeyToken('Mod+V'), onSelect: paste }, { kind: 'separator' }, { label: 'Select All', - hint: '⌘A', + hint: formatKeyToken('Mod+A'), onSelect: async () => { selectAll(view) view.focus() @@ -3277,7 +3546,7 @@ function buildEditorContextItems( { kind: 'separator' }, { label: 'Undo', - hint: '⌘Z', + hint: formatKeyToken('Mod+Z'), onSelect: async () => { undo(view) view.focus() @@ -3285,7 +3554,7 @@ function buildEditorContextItems( }, { label: 'Redo', - hint: '⇧⌘Z', + hint: formatKeyToken('Mod+Shift+Z'), onSelect: async () => { redo(view) view.focus() @@ -3359,28 +3628,42 @@ function IconBtn({ children, onClick, title, - active = false + active = false, + disabled = false, + tooltipAlign = 'center' }: { children: JSX.Element onClick: () => void title: string active?: boolean + disabled?: boolean + /** 'left' anchors the tooltip to the button's left edge so it never spills + * off the left of the window (used by the leftmost toolbar buttons). */ + tooltipAlign?: 'center' | 'left' }): JSX.Element { return ( <button type="button" onClick={onClick} + disabled={disabled} aria-label={title} aria-pressed={active} className={[ 'group relative flex h-7 w-7 items-center justify-center rounded-md transition-colors', - active - ? 'bg-paper-200 text-ink-900' - : 'text-ink-500 hover:bg-paper-200 hover:text-ink-900' + disabled + ? 'cursor-default text-ink-500/40' + : active + ? 'bg-paper-200 text-ink-900' + : 'text-ink-500 hover:bg-paper-200 hover:text-ink-900' ].join(' ')} > <span className="pointer-events-none">{children}</span> - <span className="pointer-events-none absolute left-1/2 top-full z-30 mt-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-paper-300 bg-paper-50 px-2 py-1 text-xs font-medium text-ink-800 shadow-panel group-hover:block group-focus-visible:block"> + <span + className={[ + 'pointer-events-none absolute top-full z-30 mt-1.5 hidden whitespace-nowrap rounded-md border border-paper-300 bg-paper-50 px-2 py-1 text-xs font-medium text-ink-800 shadow-panel group-hover:block group-focus-visible:block', + tooltipAlign === 'left' ? 'left-0' : 'left-1/2 -translate-x-1/2' + ].join(' ')} + > {title} </span> </button> @@ -3436,6 +3719,12 @@ function Breadcrumb({ const setView = useStore((s) => s.setView) const systemFolderLabels = useStore((s) => s.systemFolderLabels) const vaultSettings = useStore((s) => s.vaultSettings) + const createAndOpen = useStore((s) => s.createAndOpen) + const createDrawingAndOpen = useStore((s) => s.createDrawingAndOpen) + const createFolder = useStore((s) => s.createFolder) + const [crumbMenu, setCrumbMenu] = useState<{ x: number; y: number; subpath: string } | null>( + null + ) const [editing, setEditing] = useState(false) const [value, setValue] = useState(note.title) const [warning, setWarning] = useState('') @@ -3461,10 +3750,11 @@ function Breadcrumb({ const segments = noteFolderSubpath(note, vaultSettings) .split('/') .filter(Boolean) - const ancestors: { label: string; onClick: () => void }[] = [] + const ancestors: { label: string; subpath: string; onClick: () => void }[] = [] if (!(topFolder === 'inbox' && isPrimaryNotesAtRoot(vaultSettings))) { ancestors.push({ label: getSystemFolderLabel(topFolder, systemFolderLabels), + subpath: '', onClick: () => setView({ kind: 'folder', folder: topFolder, subpath: '' }) }) } @@ -3474,6 +3764,7 @@ function Breadcrumb({ const subpath = acc ancestors.push({ label: seg, + subpath, onClick: () => setView({ kind: 'folder', folder: topFolder, subpath }) }) } @@ -3502,15 +3793,59 @@ function Breadcrumb({ {ancestors.map((c, i) => ( <span key={i} className="flex shrink-0 items-center gap-1"> <button + data-crumb-menu="" onClick={c.onClick} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + setCrumbMenu({ x: e.clientX, y: e.clientY, subpath: c.subpath }) + }} className="truncate rounded px-1 hover:bg-paper-200/70 hover:text-ink-800" - title={`Go to ${c.label}`} + title={`Go to ${c.label} — right-click (or m) to create here`} > {c.label} </button> <span className="text-ink-400">›</span> </span> ))} + {crumbMenu && ( + <ContextMenu + x={crumbMenu.x} + y={crumbMenu.y} + onClose={() => setCrumbMenu(null)} + items={[ + { + label: 'New note', + onSelect: () => + void createAndOpen(topFolder, crumbMenu.subpath, { focusTitle: true }) + }, + { + label: 'New drawing', + onSelect: () => void createDrawingAndOpen(topFolder, crumbMenu.subpath) + }, + { + label: 'New folder', + onSelect: async () => { + const name = await promptApp({ + title: 'New folder', + placeholder: 'Folder name', + okLabel: 'Create', + validate: (v) => (v.includes('/') ? 'Folder name cannot contain "/"' : null) + }) + if (!name) return + const clean = name.trim().replace(/^\/+|\/+$/g, '') + if (!clean) return + const next = crumbMenu.subpath ? `${crumbMenu.subpath}/${clean}` : clean + try { + await createFolder(topFolder, next) + } catch (err) { + window.alert((err as Error).message) + } + } + } + ]} + /> + )} {editingNow ? ( <input ref={inputRef} @@ -3526,6 +3861,9 @@ function Breadcrumb({ if (commitRename()) setEditing(false) }} onKeyDown={(e) => { + // While an IME composition is active, Enter confirms the conversion + // (and Escape cancels it) — don't commit/cancel the rename. (#183) + if (isImeComposing(e)) return if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/EditorSelectionToolbar.tsx b/packages/app-core/src/components/EditorSelectionToolbar.tsx new file mode 100644 index 00000000..eff3ba5c --- /dev/null +++ b/packages/app-core/src/components/EditorSelectionToolbar.tsx @@ -0,0 +1,345 @@ +import { useEffect, useRef, useState } from 'react' +import type { JSX, SVGProps } from 'react' +import type { BlockType } from '../lib/cm-format' +import { formatKeyToken } from '../lib/keymaps' +import { useStore } from '../store' +import { + BoldIcon, + CheckSquareIcon, + ChevronRightIcon, + CodeIcon, + FeedbackIcon, + HighlighterIcon, + ItalicIcon, + LinkIcon, + ListIcon, + SigmaIcon, + StrikethroughIcon +} from './icons' + +type IconCmp = (p: SVGProps<SVGSVGElement>) => JSX.Element + +interface Props { + x: number + y: number + /** Toggle a symmetric inline marker (`**`, `*`, `~~`, `` ` ``, `==`, `$`). */ + onWrap: (marker: string) => void + onLink: () => void + onComment: () => void + /** "Turn into" — re-type the selected line(s) as a block. */ + onBlockType: (type: BlockType) => void + /** Return focus to the editor (Escape / after acting via keyboard). */ + onDismiss: () => void +} + +// `binding` is in canonical modifier order so `formatKeyToken` shows it right. +const FORMATS: Array<{ label: string; marker: string; binding: string; Icon: IconCmp }> = [ + { label: 'Bold', marker: '**', binding: 'Mod+B', Icon: BoldIcon }, + { label: 'Italic', marker: '*', binding: 'Mod+I', Icon: ItalicIcon }, + { label: 'Strikethrough', marker: '~~', binding: 'Shift+Mod+S', Icon: StrikethroughIcon }, + { label: 'Highlight', marker: '==', binding: 'Shift+Mod+H', Icon: HighlighterIcon }, + { label: 'Code', marker: '`', binding: 'Mod+E', Icon: CodeIcon }, + { label: 'Math', marker: '$', binding: 'Shift+Mod+M', Icon: SigmaIcon } +] + +const BLOCKS: Array<{ type: BlockType; label: string; Icon?: IconCmp; glyph?: string }> = [ + { type: 'paragraph', label: 'Text', glyph: 'T' }, + { type: 'h1', label: 'Heading 1', glyph: 'H1' }, + { type: 'h2', label: 'Heading 2', glyph: 'H2' }, + { type: 'h3', label: 'Heading 3', glyph: 'H3' }, + { type: 'bullet', label: 'Bulleted list', Icon: ListIcon }, + { type: 'numbered', label: 'Numbered list', glyph: '1.' }, + { type: 'todo', label: 'To-do list', Icon: CheckSquareIcon }, + { type: 'quote', label: 'Quote', glyph: '“' }, + { type: 'code', label: 'Code', Icon: CodeIcon } +] + +const BTN = + 'flex h-8 w-8 items-center justify-center rounded-md text-ink-700 outline-none transition-colors hover:bg-paper-200/80 hover:text-accent focus-visible:bg-paper-200/90 focus-visible:text-accent' + +interface Hint { + label: string + binding?: string +} + +function BlockGlyph({ Icon, glyph }: { Icon?: IconCmp; glyph?: string }): JSX.Element { + if (Icon) return <Icon width={16} height={16} /> + return <span className="text-xs font-semibold tabular-nums">{glyph}</span> +} + +function KeyCap({ binding }: { binding: string }): JSX.Element { + return ( + <kbd className="rounded border border-paper-300 bg-paper-200/70 px-1.5 py-0.5 font-mono text-[11px] leading-none text-ink-600"> + {formatKeyToken(binding)} + </kbd> + ) +} + +/** + * A Notion-style floating "bubble" toolbar over a text selection: a "Turn into" + * block-type menu, quick inline formatting, a link, and a comment. The footer + * shows the focused/hovered action's keyboard shortcut. Fully keyboard-navigable + * (arrows / Enter / Esc) once focused via `Mod+/`. + */ +export function EditorSelectionToolbar({ + x, + y, + onWrap, + onLink, + onComment, + onBlockType, + onDismiss +}: Props): JSX.Element { + const [menuOpen, setMenuOpen] = useState(false) + const [menuIndex, setMenuIndex] = useState(0) + const [hint, setHint] = useState<Hint | null>(null) + // In Vim mode, h/j/k/l navigate the toolbar too (the controls aren't text + // inputs, so the letters are free to act as motions). + const vimMode = useStore((s) => s.vimMode) + // Roving focus over the toolbar's controls in render order: turn-into, + // formats…, link, comment. + const itemRefs = useRef<Array<HTMLButtonElement | null>>([]) + const menuRefs = useRef<Array<HTMLButtonElement | null>>([]) + const TURN_INTO_INDEX = 0 + const FORMAT_START = 1 // first control of the format row + // Remembered format-row column, so going Turn into → row → Turn into returns + // to where you were. + const lastFormatRef = useRef(FORMAT_START) + + useEffect(() => { + if (menuOpen) menuRefs.current[menuIndex]?.focus() + }, [menuOpen, menuIndex]) + + // Keep the editor's selection: never let a toolbar mousedown move focus/caret. + const keepSelection = (e: React.MouseEvent): void => { + e.preventDefault() + e.stopPropagation() + } + + const currentIndex = (): number => + itemRefs.current.findIndex((el) => el === document.activeElement) + + // h/l (← →): move across the format row; on the Turn-into row there's nothing + // to move to horizontally. + const moveHorizontal = (dir: 1 | -1): void => { + const els = itemRefs.current + const last = els.length - 1 + const cur = currentIndex() + if (cur < FORMAT_START) return + const count = last - FORMAT_START + 1 + if (count <= 0) return + const rel = (((cur - FORMAT_START + dir) % count) + count) % count + const next = FORMAT_START + rel + lastFormatRef.current = next + els[next]?.focus() + } + + // j/k (↓ ↑): move between the two rows — Turn into ↔ the format row, + // returning to the column you left. + const moveVertical = (): void => { + const els = itemRefs.current + const cur = currentIndex() + if (cur === TURN_INTO_INDEX) { + const col = Math.min(Math.max(lastFormatRef.current, FORMAT_START), els.length - 1) + els[col]?.focus() + } else { + if (cur >= FORMAT_START) lastFormatRef.current = cur + els[TURN_INTO_INDEX]?.focus() + } + } + + const onToolbarKeyDown = (e: React.KeyboardEvent): void => { + const k = e.key + if (menuOpen) { + const down = k === 'ArrowDown' || (vimMode && k === 'j') + const up = k === 'ArrowUp' || (vimMode && k === 'k') + const back = k === 'Escape' || (vimMode && k === 'h') + if (down) { + e.preventDefault() + setMenuIndex((i) => (i + 1) % BLOCKS.length) + } else if (up) { + e.preventDefault() + setMenuIndex((i) => (i - 1 + BLOCKS.length) % BLOCKS.length) + } else if (k === 'Enter' || k === ' ' || (vimMode && k === 'l')) { + e.preventDefault() + onBlockType(BLOCKS[menuIndex].type) + setMenuOpen(false) + onDismiss() + } else if (back) { + e.preventDefault() + e.stopPropagation() + setMenuOpen(false) + itemRefs.current[TURN_INTO_INDEX]?.focus() + } + return + } + const right = k === 'ArrowRight' || (vimMode && k === 'l') + const left = k === 'ArrowLeft' || (vimMode && k === 'h') + const down = k === 'ArrowDown' || (vimMode && k === 'j') + const up = k === 'ArrowUp' || (vimMode && k === 'k') + if (right) { + e.preventDefault() + // On the Turn-into row the `›` opens its submenu; elsewhere move across. + if (currentIndex() === TURN_INTO_INDEX) { + setMenuIndex(0) + setMenuOpen(true) + } else { + moveHorizontal(1) + } + } else if (left) { + e.preventDefault() + moveHorizontal(-1) + } else if (down || up) { + e.preventDefault() + moveVertical() + } else if (k === 'Escape') { + e.preventDefault() + e.stopPropagation() + onDismiss() + } + } + + // Assigns the next roving-order ref index as each control renders. + let idx = 0 + const ref = (): ((el: HTMLButtonElement | null) => void) => { + const at = idx++ + return (el) => { + itemRefs.current[at] = el + } + } + + return ( + <div + data-selection-toolbar + role="toolbar" + aria-label="Format selection" + onMouseDown={keepSelection} + onKeyDown={onToolbarKeyDown} + className="fixed z-50 flex w-[268px] flex-col rounded-xl bg-paper-100 p-1 text-ink-700 shadow-float ring-1 ring-paper-300" + style={{ left: x, top: y, transform: 'translateX(-50%)' }} + > + {/* Turn into */} + <button + ref={ref()} + data-toolbar-item + type="button" + className="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-sm text-ink-700 outline-none transition-colors hover:bg-paper-200/80 focus-visible:bg-paper-200/90" + onMouseDown={keepSelection} + onMouseEnter={() => setHint({ label: 'Turn into' })} + onFocus={() => setHint({ label: 'Turn into' })} + onClick={() => (menuOpen ? setMenuOpen(false) : (setMenuIndex(0), setMenuOpen(true)))} + aria-expanded={menuOpen} + aria-haspopup="menu" + > + <ListIcon width={15} height={15} className="shrink-0 text-ink-500" /> + <span className="flex-1">Turn into</span> + <ChevronRightIcon + width={14} + height={14} + className={`shrink-0 text-ink-500 transition-transform ${menuOpen ? 'rotate-90' : ''}`} + /> + </button> + + <div className="my-1 h-px bg-paper-300/70" aria-hidden /> + + {/* Inline formatting */} + <div className="flex items-center gap-0.5"> + {FORMATS.map(({ label, marker, binding, Icon }) => ( + <button + key={marker} + ref={ref()} + data-toolbar-item + type="button" + title={`${label} · ${formatKeyToken(binding)}`} + aria-label={`${label} (${formatKeyToken(binding)})`} + aria-keyshortcuts={binding} + className={BTN} + onMouseDown={keepSelection} + onMouseEnter={() => setHint({ label, binding })} + onFocus={() => setHint({ label, binding })} + onClick={() => onWrap(marker)} + > + <Icon width={16} height={16} /> + </button> + ))} + <button + ref={ref()} + data-toolbar-item + type="button" + title={`Link · ${formatKeyToken('Mod+K')}`} + aria-label={`Insert link (${formatKeyToken('Mod+K')})`} + aria-keyshortcuts="Mod+K" + className={BTN} + onMouseDown={keepSelection} + onMouseEnter={() => setHint({ label: 'Link', binding: 'Mod+K' })} + onFocus={() => setHint({ label: 'Link', binding: 'Mod+K' })} + onClick={onLink} + > + <LinkIcon width={16} height={16} /> + </button> + <span className="mx-0.5 h-5 w-px shrink-0 bg-paper-300/70" aria-hidden /> + <button + ref={ref()} + data-toolbar-item + type="button" + title="Comment" + aria-label="Add comment to selection" + className={BTN} + onMouseDown={keepSelection} + onMouseEnter={() => setHint({ label: 'Comment' })} + onFocus={() => setHint({ label: 'Comment' })} + onClick={onComment} + > + <FeedbackIcon width={16} height={16} /> + </button> + </div> + + {/* Footer: shows the active action's keyboard shortcut on the popup. */} + <div className="mt-1 border-t border-paper-300/70 pt-1"> + <div className="flex h-6 items-center justify-between px-1.5 text-xs text-ink-500"> + <span className="truncate">{hint?.label ?? 'Format selection'}</span> + {hint?.binding ? ( + <KeyCap binding={hint.binding} /> + ) : ( + <span className="font-mono text-[11px] text-ink-400">{formatKeyToken('Mod+/')} nav</span> + )} + </div> + </div> + + {menuOpen && ( + <div + role="menu" + aria-label="Turn into" + className="absolute left-1 top-full z-10 mt-1 w-[200px] overflow-hidden rounded-lg bg-paper-100 p-1 shadow-float ring-1 ring-paper-300" + onMouseDown={keepSelection} + > + {BLOCKS.map(({ type, label, Icon, glyph }, i) => ( + <button + key={type} + ref={(el) => { + menuRefs.current[i] = el + }} + role="menuitem" + type="button" + className={`flex h-8 w-full items-center gap-2.5 rounded-md px-2 text-left text-sm text-ink-700 outline-none transition-colors hover:bg-paper-200/80 hover:text-accent focus-visible:bg-paper-200/90 focus-visible:text-accent ${ + i === menuIndex ? 'bg-paper-200/80' : '' + }`} + onMouseEnter={() => setMenuIndex(i)} + onMouseDown={keepSelection} + onClick={() => { + onBlockType(type) + setMenuOpen(false) + onDismiss() + }} + > + <span className="flex h-5 w-5 shrink-0 items-center justify-center text-ink-500"> + <BlockGlyph Icon={Icon} glyph={glyph} /> + </span> + <span className="flex-1">{label}</span> + </button> + ))} + </div> + )} + </div> + ) +} diff --git a/packages/app-core/src/components/ExcalidrawView.tsx b/packages/app-core/src/components/ExcalidrawView.tsx new file mode 100644 index 00000000..83729a26 --- /dev/null +++ b/packages/app-core/src/components/ExcalidrawView.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from 'react' +import type { ComponentProps } from 'react' +import { Excalidraw, serializeAsJSON } from '@excalidraw/excalidraw' +import '@excalidraw/excalidraw/index.css' +import { parseExcalidrawDocument } from '@shared/excalidraw' +import { useStore } from '../store' +import { THEMES } from '../lib/themes' + +type InitialData = ComponentProps<typeof Excalidraw>['initialData'] + +/** + * The embedded Excalidraw drawing editor for a `.excalidraw` file. Loaded lazily + * (see LazyExcalidrawView) so the heavy bundle never touches startup. Reads the + * scene JSON from disk on open and debounce-saves it back on every change. + */ +export function ExcalidrawView({ path }: { path: string }): JSX.Element { + const [initialData, setInitialData] = useState<InitialData | undefined>(undefined) + const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null) + const lastSaved = useRef<string>('') + const pathRef = useRef(path) + pathRef.current = path + + // Match the app's light/dark theme. + const themeId = useStore((s) => s.themeId) + const excalidrawTheme = THEMES.find((t) => t.id === themeId)?.mode === 'dark' ? 'dark' : 'light' + + useEffect(() => { + let cancelled = false + setInitialData(undefined) + window.zen + .readNote(path) + .then((res) => { + if (cancelled) return + lastSaved.current = res?.body ?? '' + const doc = parseExcalidrawDocument(res?.body ?? '') + setInitialData({ + elements: doc.elements, + appState: doc.appState, + files: doc.files + } as InitialData) + }) + .catch(() => { + if (!cancelled) setInitialData({} as InitialData) + }) + return () => { + cancelled = true + } + }, [path]) + + useEffect( + () => () => { + if (saveTimer.current) clearTimeout(saveTimer.current) + }, + [] + ) + + if (initialData === undefined) { + return ( + <div className="flex min-h-0 flex-1 items-center justify-center text-sm text-ink-500"> + Loading drawing… + </div> + ) + } + + return ( + <div className="min-h-0 w-full flex-1" style={{ height: '100%' }}> + <Excalidraw + initialData={initialData} + theme={excalidrawTheme} + onChange={(elements, appState, files) => { + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => { + let json: string + try { + json = serializeAsJSON(elements, appState, files, 'local') + } catch { + return + } + // Skip no-op writes (Excalidraw fires onChange on load and on hover). + if (json === lastSaved.current) return + lastSaved.current = json + void window.zen.writeNote(pathRef.current, json) + }, 700) + }} + /> + </div> + ) +} diff --git a/packages/app-core/src/components/ExternalFileApp.tsx b/packages/app-core/src/components/ExternalFileApp.tsx index 23dc7545..d1e81c2c 100644 --- a/packages/app-core/src/components/ExternalFileApp.tsx +++ b/packages/app-core/src/components/ExternalFileApp.tsx @@ -14,11 +14,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Annotation, Compartment, EditorState, type Transaction } from '@codemirror/state' import { EditorView, drawSelection, highlightActiveLine, keymap } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { vimAwareDefaultKeymap } from '../lib/cm-vim-default-keymap' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { resolveCodeLanguage } from '../lib/cm-code-languages' import { applyVimInsertEscape } from '../lib/vim-insert-escape' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' +import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' import { searchKeymap } from '@codemirror/search' import type { ExternalFileContent } from '@shared/ipc' @@ -108,6 +110,7 @@ export function ExternalFileApp(): JSX.Element { // ref keeps the current text when the editor remounts on toggles. doc: bodyRef.current ?? '', extensions: [ + appMarkdownSnippetExtension(), new Compartment().of(prefs.vimMode ? vim() : []), history(), drawSelection(), @@ -120,7 +123,12 @@ export function ExternalFileApp(): JSX.Element { syntaxHighlighting(defaultHighlightStyle, { fallback: true }), prefs.livePreview ? livePreviewPlugin : [], lineNumberExtension(prefs.lineNumberMode), - keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]), + keymap.of([ + indentWithTab, + ...vimAwareDefaultKeymap(prefs.vimMode), + ...historyKeymap, + ...searchKeymap + ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return if (upd.transactions.some((tr: Transaction) => tr.annotation(programmatic))) return diff --git a/packages/app-core/src/components/FloatingNoteApp.tsx b/packages/app-core/src/components/FloatingNoteApp.tsx index befb8c63..530cfd91 100644 --- a/packages/app-core/src/components/FloatingNoteApp.tsx +++ b/packages/app-core/src/components/FloatingNoteApp.tsx @@ -26,11 +26,13 @@ import { lineNumbers } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { vimAwareDefaultKeymap } from '../lib/cm-vim-default-keymap' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { resolveCodeLanguage } from '../lib/cm-code-languages' import { applyVimInsertEscape } from '../lib/vim-insert-escape' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' +import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language' import { tags as t } from '@lezer/highlight' import { searchKeymap } from '@codemirror/search' @@ -62,6 +64,7 @@ export const paperHighlight = HighlightStyle.define([ { tag: t.heading6, class: 'tok-heading6' }, { tag: t.emphasis, class: 'tok-emphasis' }, { tag: t.strong, class: 'tok-strong' }, + { tag: t.strikethrough, class: 'tok-strikethrough' }, { tag: t.link, class: 'tok-link' }, { tag: t.url, class: 'tok-url' }, { tag: t.monospace, class: 'tok-monospace' }, @@ -290,6 +293,7 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element // toggles (the `content` closure would recreate it empty). doc: dirtyBodyRef.current ?? content?.body ?? '', extensions: [ + appMarkdownSnippetExtension(), new Compartment().of(prefs.vimMode ? vim() : []), history(), drawSelection(), @@ -302,7 +306,12 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element syntaxHighlighting(defaultHighlightStyle, { fallback: true }), prefs.livePreview ? livePreviewPlugin : [], lineNumberExtension(prefs.lineNumberMode), - keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]), + keymap.of([ + indentWithTab, + ...vimAwareDefaultKeymap(prefs.vimMode), + ...historyKeymap, + ...searchKeymap + ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return if (upd.transactions.some((tr: Transaction) => tr.annotation(programmatic))) return diff --git a/packages/app-core/src/components/FolderColorPickerModal.tsx b/packages/app-core/src/components/FolderColorPickerModal.tsx new file mode 100644 index 00000000..d0502cf2 --- /dev/null +++ b/packages/app-core/src/components/FolderColorPickerModal.tsx @@ -0,0 +1,63 @@ +import type { FolderColorId } from '@shared/ipc' +import { FOLDER_COLOR_OPTIONS } from './FolderColors' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' + +export function FolderColorPickerModal({ + targetLabel, + currentColorId, + onSelect, + onReset, + onCancel +}: { + targetLabel: string + currentColorId: FolderColorId | null + onSelect: (colorId: FolderColorId) => void + onReset: () => void + onCancel: () => void +}): JSX.Element { + return ( + <Modal size="md" layer="modal" onClose={onCancel}> + <Modal.Header + title="Choose color" + description={ + <> + Pick an accent color for{' '} + <span className="font-medium text-ink-700">{targetLabel}</span>. + </> + } + /> + <Modal.Body className="grid grid-cols-2 gap-2 sm:grid-cols-3"> + {FOLDER_COLOR_OPTIONS.map((option) => { + const active = option.id === currentColorId + return ( + <button + key={option.id} + type="button" + onClick={() => onSelect(option.id)} + className={[ + 'flex items-center gap-3 rounded-xl border px-3 py-2 text-left transition-colors', + active + ? 'border-accent bg-accent/10 text-accent' + : 'border-paper-300 bg-paper-50 text-ink-800 hover:border-paper-400 hover:bg-paper-200/70' + ].join(' ')} + > + <span className={`h-4 w-4 shrink-0 rounded-full ${option.swatchClass}`} /> + <span className="truncate text-sm font-medium">{option.label}</span> + </button> + ) + })} + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={onCancel}> + Cancel + </Button> + {currentColorId && ( + <Button variant="ghost" onClick={onReset}> + Reset color + </Button> + )} + </Modal.Footer> + </Modal> + ) +} diff --git a/packages/app-core/src/components/FolderColors.tsx b/packages/app-core/src/components/FolderColors.tsx new file mode 100644 index 00000000..c7a82235 --- /dev/null +++ b/packages/app-core/src/components/FolderColors.tsx @@ -0,0 +1,54 @@ +import type { FolderColorId, NoteFolder } from '@shared/ipc' +import { folderIconKey } from '../lib/vault-layout' + +export interface FolderColorOption { + id: FolderColorId + label: string + /** Solid swatch background, used in the picker. */ + swatchClass: string + /** Text color applied to the folder's sidebar glyph. */ + glyphClass: string +} + +// Preset palette. Tailwind palette colors at the 500 weight read well on both +// the light (paper) and dark sidebar. Class names are literal so the Tailwind +// content scanner keeps them. +export const FOLDER_COLOR_OPTIONS: readonly FolderColorOption[] = [ + { id: 'red', label: 'Red', swatchClass: 'bg-red-500', glyphClass: 'text-red-500' }, + { id: 'orange', label: 'Orange', swatchClass: 'bg-orange-500', glyphClass: 'text-orange-500' }, + { id: 'amber', label: 'Amber', swatchClass: 'bg-amber-500', glyphClass: 'text-amber-500' }, + { id: 'green', label: 'Green', swatchClass: 'bg-green-500', glyphClass: 'text-green-500' }, + { id: 'teal', label: 'Teal', swatchClass: 'bg-teal-500', glyphClass: 'text-teal-500' }, + { id: 'sky', label: 'Sky', swatchClass: 'bg-sky-500', glyphClass: 'text-sky-500' }, + { id: 'blue', label: 'Blue', swatchClass: 'bg-blue-500', glyphClass: 'text-blue-500' }, + { id: 'indigo', label: 'Indigo', swatchClass: 'bg-indigo-500', glyphClass: 'text-indigo-500' }, + { id: 'violet', label: 'Violet', swatchClass: 'bg-violet-500', glyphClass: 'text-violet-500' }, + { id: 'pink', label: 'Pink', swatchClass: 'bg-pink-500', glyphClass: 'text-pink-500' } +] as const + +const FOLDER_COLOR_LOOKUP = new Map(FOLDER_COLOR_OPTIONS.map((option) => [option.id, option])) + +/** The glyph text-color class for a color id (any keyed entry — folder, note, + * database…), or null when there's no color. */ +export function colorGlyphClassById(id: FolderColorId | null | undefined): string | null { + return id ? FOLDER_COLOR_LOOKUP.get(id)?.glyphClass ?? null : null +} + +/** The chosen color for a folder, or null when none is set (default tint). */ +export function resolveFolderColorId( + folder: NoteFolder, + subpath: string, + folderColors: Record<string, FolderColorId> +): FolderColorId | null { + return folderColors[folderIconKey(folder, subpath)] ?? null +} + +/** The glyph text-color class for a folder, or null when no color is set. */ +export function resolveFolderColorGlyphClass( + folder: NoteFolder, + subpath: string, + folderColors: Record<string, FolderColorId> +): string | null { + const id = resolveFolderColorId(folder, subpath, folderColors) + return id ? FOLDER_COLOR_LOOKUP.get(id)?.glyphClass ?? null : null +} diff --git a/packages/app-core/src/components/FolderIconPickerModal.tsx b/packages/app-core/src/components/FolderIconPickerModal.tsx index 4786f90f..db6fa50f 100644 --- a/packages/app-core/src/components/FolderIconPickerModal.tsx +++ b/packages/app-core/src/components/FolderIconPickerModal.tsx @@ -7,11 +7,13 @@ export function FolderIconPickerModal({ targetLabel, currentIconId, onSelect, + onReset, onCancel }: { targetLabel: string - currentIconId: FolderIconId + currentIconId: FolderIconId | null onSelect: (iconId: FolderIconId) => void + onReset: () => void onCancel: () => void }): JSX.Element { return ( @@ -50,6 +52,11 @@ export function FolderIconPickerModal({ <Button variant="secondary" onClick={onCancel}> Cancel </Button> + {currentIconId && ( + <Button variant="ghost" onClick={onReset}> + Reset icon + </Button> + )} </Modal.Footer> </Modal> ) diff --git a/packages/app-core/src/components/HintOverlay.tsx b/packages/app-core/src/components/HintOverlay.tsx index 99df3c18..6db7769b 100644 --- a/packages/app-core/src/components/HintOverlay.tsx +++ b/packages/app-core/src/components/HintOverlay.tsx @@ -200,7 +200,7 @@ export function HintOverlay({ if (targets.length === 0) return null return createPortal( - <div className="vim-hint-overlay" style={{ position: 'fixed', inset: 0, zIndex: 9999, pointerEvents: 'none' }}> + <div data-vim-hint-overlay className="vim-hint-overlay" style={{ position: 'fixed', inset: 0, zIndex: 9999, pointerEvents: 'none' }}> {targets.map((t) => { const isMatch = t.label.startsWith(buffer) const matchedPart = buffer diff --git a/packages/app-core/src/components/LazyExcalidrawView.tsx b/packages/app-core/src/components/LazyExcalidrawView.tsx new file mode 100644 index 00000000..90e3f3a0 --- /dev/null +++ b/packages/app-core/src/components/LazyExcalidrawView.tsx @@ -0,0 +1,20 @@ +import { lazy, Suspense } from 'react' + +const ExcalidrawViewImpl = lazy(() => + import('./ExcalidrawView').then((mod) => ({ default: mod.ExcalidrawView })) +) + +/** Lazy boundary for the Excalidraw editor so its heavy bundle loads on demand. */ +export function LazyExcalidrawView({ path }: { path: string }): JSX.Element { + return ( + <Suspense + fallback={ + <div className="flex min-h-0 flex-1 items-center justify-center text-sm text-ink-500"> + Loading drawing… + </div> + } + > + <ExcalidrawViewImpl path={path} /> + </Suspense> + ) +} diff --git a/packages/app-core/src/components/NoteList.tsx b/packages/app-core/src/components/NoteList.tsx index fda2ab8a..d06dc64a 100644 --- a/packages/app-core/src/components/NoteList.tsx +++ b/packages/app-core/src/components/NoteList.tsx @@ -14,6 +14,7 @@ import { ResizeHandle } from './ResizeHandle' import { Button, IconButton } from './ui/Button' import { confirmMoveToTrash } from '../lib/confirm-trash' import { buildMoveNotePrompt, parseMoveNoteTarget } from '../lib/move-note' +import { naturalCompare } from '../lib/natural-sort' import { extractTags } from '../lib/tags' import { setDragPayload } from '../lib/dnd' import { promptApp } from '../lib/prompt-requests' @@ -489,11 +490,9 @@ export function NoteList(): JSX.Element { case 'created-asc': return (a: NoteMeta, b: NoteMeta) => a.createdAt - b.createdAt case 'name-asc': - return (a: NoteMeta, b: NoteMeta) => - a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) + return (a: NoteMeta, b: NoteMeta) => naturalCompare(a.title, b.title) case 'name-desc': - return (a: NoteMeta, b: NoteMeta) => - b.title.localeCompare(a.title, undefined, { sensitivity: 'base' }) + return (a: NoteMeta, b: NoteMeta) => naturalCompare(b.title, a.title) case 'updated-desc': default: return (a: NoteMeta, b: NoteMeta) => b.updatedAt - a.updatedAt @@ -512,11 +511,9 @@ export function NoteList(): JSX.Element { case 'created-asc': return (a: AssetMeta, b: AssetMeta) => a.updatedAt - b.updatedAt case 'name-asc': - return (a: AssetMeta, b: AssetMeta) => - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + return (a: AssetMeta, b: AssetMeta) => naturalCompare(a.name, b.name) case 'name-desc': - return (a: AssetMeta, b: AssetMeta) => - b.name.localeCompare(a.name, undefined, { sensitivity: 'base' }) + return (a: AssetMeta, b: AssetMeta) => naturalCompare(b.name, a.name) default: return (a: AssetMeta, b: AssetMeta) => b.updatedAt - a.updatedAt } @@ -761,7 +758,7 @@ export function NoteList(): JSX.Element { <IconButton size="sm" title="New note" - onClick={() => void createAndOpen(newTarget.folder, newTarget.subpath)} + onClick={() => void createAndOpen(newTarget.folder, newTarget.subpath, { focusTitle: true })} > <PlusIcon /> </IconButton> diff --git a/packages/app-core/src/components/OnboardingWizard.tsx b/packages/app-core/src/components/OnboardingWizard.tsx index 6ba7eb5e..5e622f93 100644 --- a/packages/app-core/src/components/OnboardingWizard.tsx +++ b/packages/app-core/src/components/OnboardingWizard.tsx @@ -798,6 +798,7 @@ function LayoutStep({ ...current, primaryNotesLocation: patch.primaryNotesLocation ?? current.primaryNotesLocation, dailyNotes: { + ...current.dailyNotes, enabled: patch.dailyEnabled ?? current.dailyNotes.enabled, directory: patch.dailyDirectory !== undefined diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index f7346f75..d2499953 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' import { parseOutline, type OutlineItem } from '../lib/outline' import { isHelpTabPath } from '@shared/help' import { isArchiveTabPath } from '@shared/archive' @@ -87,6 +88,8 @@ export function OutlinePalette(): JSX.Element { placeholder="Jump to heading…" onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index 9b294384..e68eb91a 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -26,7 +26,8 @@ import { tooltips } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { vimAwareDefaultKeymap } from '../lib/cm-vim-default-keymap' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { resolveCodeLanguage } from '../lib/cm-code-languages' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' @@ -40,7 +41,7 @@ import { livePreviewPlugin } from '../lib/cm-live-preview' import { headingFolding } from '../lib/cm-heading-fold' import { slashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { dateShortcutSource } from '../lib/cm-date-shortcuts' -import { wikilinkSource } from '../lib/cm-wikilinks' +import { wikilinkSource, wikilinkHeadingSource } from '../lib/cm-wikilinks' import { completionNavKeymap } from '../lib/cm-completion-nav' import { classifyLocalAssetHref, type LocalAssetKind } from '../lib/local-assets' import { LazyPreview as Preview } from './LazyPreview' @@ -60,6 +61,7 @@ const paperHighlight = HighlightStyle.define([ { tag: t.heading6, class: 'tok-heading6' }, { tag: t.emphasis, class: 'tok-emphasis' }, { tag: t.strong, class: 'tok-strong' }, + { tag: t.strikethrough, class: 'tok-strikethrough' }, { tag: t.link, class: 'tok-link' }, { tag: t.url, class: 'tok-url' }, { tag: t.monospace, class: 'tok-monospace' }, @@ -194,7 +196,7 @@ export function PinnedReferencePane(): JSX.Element | null { lineNumbersCompartment.of(lineNumberExtension(s0.lineNumberMode)), tooltips({ parent: document.body }), autocompletion({ - override: [slashCommandSource, dateShortcutSource, wikilinkSource], + override: [slashCommandSource, dateShortcutSource, wikilinkSource, wikilinkHeadingSource], addToOptions: [{ render: slashCommandRender.render, position: 0 }], icons: false, optionClass: (completion) => @@ -214,7 +216,7 @@ export function PinnedReferencePane(): JSX.Element | null { } }, indentWithTab, - ...defaultKeymap, + ...vimAwareDefaultKeymap(s0.vimMode), ...historyKeymap, ...searchKeymap, ...completionKeymap diff --git a/packages/app-core/src/components/Preview.tsx b/packages/app-core/src/components/Preview.tsx index 4b5d1bb0..48568baf 100644 --- a/packages/app-core/src/components/Preview.tsx +++ b/packages/app-core/src/components/Preview.tsx @@ -4,7 +4,9 @@ import type { NoteMeta } from "@shared/ipc"; import { renderMarkdown } from "../lib/markdown"; import { useStore } from "../store"; import { resolveAuto, THEMES } from "../lib/themes"; -import { resolveWikilinkTarget } from "../lib/wikilinks"; +import { resolveWikilinkTarget, wikilinkHeadingAnchor } from "../lib/wikilinks"; +import { openWikilinkHeading } from "../lib/wikilink-navigation"; +import { externalLinkUrl, resolveInternalNoteHref } from "../lib/internal-links"; import { toggleTaskAtIndex } from "../lib/tasklists"; import { enhanceLocalAssetNodes, @@ -535,7 +537,12 @@ export const Preview = memo(function Preview({ if (anchor.classList.contains("wikilink")) { e.preventDefault(); const path = anchor.dataset.resolvedPath; - if (path) void selectNoteRef.current(path); + if (path) { + // Scroll to the #heading when the link carries one. (#196) + const headingAnchor = wikilinkHeadingAnchor(anchor.dataset.wikilink ?? ""); + if (headingAnchor) void openWikilinkHeading(path, headingAnchor); + else void selectNoteRef.current(path); + } return; } if (anchor.classList.contains("hashtag")) { @@ -544,6 +551,34 @@ export const Preview = memo(function Preview({ if (tag) void useStore.getState().openTagView(tag); return; } + // A standard Markdown link to another note — `[text](path/to/Note.md)` — + // navigates like a wikilink, resolved relative to this note. Checked + // before the asset branch: `enhanceLocalAssetNodes` may have tagged a + // relative link and rewritten its href, keeping the original in + // `data-local-asset-href`. (#201) + const linkHref = + anchor.dataset.localAssetHref || anchor.getAttribute("href") || ""; + const internalNote = resolveInternalNoteHref( + notePathRef.current, + linkHref, + notesRef.current, + ); + if (internalNote) { + e.preventDefault(); + if (internalNote.heading) + void openWikilinkHeading(internalNote.path, internalNote.heading); + else void selectNoteRef.current(internalNote.path); + return; + } + // An external web link — `[site](https://…)` or a bare `[site](google.com)` + // a user typed without a scheme — opens in the browser. Checked before the + // asset branch since a scheme-less domain looks like a relative path. (#201) + const external = externalLinkUrl(linkHref); + if (external) { + e.preventDefault(); + window.open(external, "_blank"); + return; + } const localAssetUrl = anchor.dataset.localAssetUrl; if (localAssetUrl) { e.preventDefault(); diff --git a/packages/app-core/src/components/PromptModal.tsx b/packages/app-core/src/components/PromptModal.tsx index 2ce6312a..96d3494e 100644 --- a/packages/app-core/src/components/PromptModal.tsx +++ b/packages/app-core/src/components/PromptModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { isImeComposing } from '../lib/ime' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -151,6 +152,9 @@ export function PromptModal({ setDismissed(false) }} onKeyDown={(e) => { + // Let the IME own Enter/Tab/Arrows while composing (e.g. confirming + // a Japanese conversion) instead of submitting the prompt. (#183) + if (isImeComposing(e)) return if (e.key === 'Tab' && filteredSuggestions.length > 0) { e.preventDefault() moveSuggestion(e.shiftKey ? -1 : 1) diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index cfd4b368..1d910253 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -40,10 +40,12 @@ import { placeholder } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { vimAwareDefaultKeymap } from '../lib/cm-vim-default-keymap' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { resolveCodeLanguage } from '../lib/cm-code-languages' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' +import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language' import { tags as t } from '@lezer/highlight' import { searchKeymap } from '@codemirror/search' @@ -62,6 +64,7 @@ import { import { deriveTitleFromBody, planQuickCaptureSave } from '../lib/quick-capture-save' import { applyVimInsertEscape } from '../lib/vim-insert-escape' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' import { PinIcon } from './icons' const PREFS_KEY = 'zen:prefs:v2' @@ -113,6 +116,7 @@ const captureHighlight = HighlightStyle.define([ { tag: t.heading3, class: 'tok-heading3' }, { tag: t.emphasis, class: 'tok-emphasis' }, { tag: t.strong, class: 'tok-strong' }, + { tag: t.strikethrough, class: 'tok-strikethrough' }, { tag: t.link, class: 'tok-link' }, { tag: t.url, class: 'tok-url' }, { tag: t.monospace, class: 'tok-monospace' }, @@ -228,6 +232,11 @@ export function QuickCaptureApp(): JSX.Element { const [overlay, setOverlay] = useState<'none' | 'search' | 'command'>('none') const editorRef = useRef<EditorView | null>(null) + // Set a different title for the quick capture window. + useEffect(() => { + document.title = 'ZenNotes Quick Capture' + }, []) + // Apply theme + font CSS vars before paint. useEffect(() => { applyTheme(prefs) @@ -405,6 +414,7 @@ export function QuickCaptureApp(): JSX.Element { const state = EditorState.create({ doc: '', extensions: [ + appMarkdownSnippetExtension(), new Compartment().of(prefs.vimMode ? vim() : []), history(), drawSelection(), @@ -415,7 +425,12 @@ export function QuickCaptureApp(): JSX.Element { syntaxHighlighting(captureHighlight), syntaxHighlighting(defaultHighlightStyle, { fallback: true }), placeholder('Start writing…'), - keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]), + keymap.of([ + indentWithTab, + ...vimAwareDefaultKeymap(prefs.vimMode), + ...historyKeymap, + ...searchKeymap + ]), EditorView.updateListener.of((upd) => { if (!upd.docChanged) return const doc = upd.state.doc.toString() @@ -695,6 +710,8 @@ function NotePickerOverlay({ notes, onPick, onCancel }: NotePickerOverlayProps): useEffect(() => setActive(0), [query]) const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() setActive((i) => Math.min(results.length - 1, i + 1)) @@ -820,6 +837,8 @@ function CommandOverlay({ modKey, mode, onAction, onCancel }: CommandOverlayProp useEffect(() => setActive(0), [query]) const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() setActive((i) => Math.min(results.length - 1, i + 1)) diff --git a/packages/app-core/src/components/QuickNotesView.tsx b/packages/app-core/src/components/QuickNotesView.tsx index 1b67ec79..221e026b 100644 --- a/packages/app-core/src/components/QuickNotesView.tsx +++ b/packages/app-core/src/components/QuickNotesView.tsx @@ -5,6 +5,7 @@ import { CollectionViewHeader } from './CollectionViewHeader' import { resolveQuickNoteTitle } from '../lib/quick-note-title' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { getSystemFolderLabel } from '../lib/system-folder-labels' +import { isAppOverlayOpen } from '../lib/overlay-open' function formatDate(ms: number): string { const d = new Date(ms) @@ -30,6 +31,7 @@ export function QuickNotesView(): JSX.Element { const quickNoteDateTitle = useStore((s) => s.quickNoteDateTitle) const quickNoteTitlePrefix = useStore((s) => s.quickNoteTitlePrefix) const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) const setFocusedPanel = useStore((s) => s.setFocusedPanel) const systemFolderLabels = useStore((s) => s.systemFolderLabels) const amActive = useStore(isQuickNotesViewActive) @@ -97,6 +99,9 @@ export function QuickNotesView(): JSX.Element { useEffect(() => { if (!amActive) return const handler = (e: KeyboardEvent): void => { + // A modal/menu owns the keyboard while open — don't fire list shortcuts + // through it. (songgenqing report) + if (isAppOverlayOpen()) return const active = document.activeElement as HTMLElement | null if (active) { const tag = active.tagName @@ -106,6 +111,10 @@ export function QuickNotesView(): JSX.Element { const key = e.key const overrides = keymapOverrides + // When Vim mode is off, the single-key Vim shortcuts (j/k/gg/G/o//…) are + // disabled — only arrows/Enter/Escape navigate. (songgenqing report) + const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => + vimMode && matchesSequenceToken(e, overrides, id) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -122,35 +131,36 @@ export function QuickNotesView(): JSX.Element { return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (seq('nav.filter')) { consume() filterRef.current?.focus() filterRef.current?.select() return } - if (matchesSequenceToken(e, overrides, 'nav.newQuickNote')) { + if (seq('nav.newQuickNote')) { consume() void createQuickNote() return } - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (seq('nav.moveDown') || key === 'ArrowDown') { consume() setCursorIndex((i) => Math.max(0, Math.min(filtered.length - 1, i + 1))) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (seq('nav.moveUp') || key === 'ArrowUp') { consume() setCursorIndex((i) => Math.max(0, Math.min(filtered.length - 1, i - 1))) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (seq('nav.jumpBottom')) { consume() setCursorIndex(filtered.length - 1) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -163,7 +173,7 @@ export function QuickNotesView(): JSX.Element { ) { return } - if ((key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openResult')) && current) { + if ((key === 'Enter' || seq('nav.openResult')) && current) { consume() void openNote(current.path) } @@ -174,7 +184,7 @@ export function QuickNotesView(): JSX.Element { if (gTimer.current) clearTimeout(gTimer.current) window.removeEventListener('keydown', handler, true) } - }, [amActive, closeActiveNote, createQuickNote, current, filter, filtered.length, keymapOverrides, openNote]) + }, [amActive, closeActiveNote, createQuickNote, current, filter, filtered.length, keymapOverrides, vimMode, openNote]) return ( <div diff --git a/packages/app-core/src/components/RemoteWorkspaceProfileModal.tsx b/packages/app-core/src/components/RemoteWorkspaceProfileModal.tsx index 3a7bc9cf..cdeb1dad 100644 --- a/packages/app-core/src/components/RemoteWorkspaceProfileModal.tsx +++ b/packages/app-core/src/components/RemoteWorkspaceProfileModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { isImeComposing } from '../lib/ime' import type { RemoteWorkspaceProfileInput } from '@shared/ipc' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -78,6 +79,8 @@ export function RemoteWorkspaceProfileModal({ // Esc handled by Modal; we keep Enter (→ submit) here. closeOnEsc stays true. useEffect(() => { const handler = (e: KeyboardEvent): void => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/SearchPalette.tsx b/packages/app-core/src/components/SearchPalette.tsx index 4004fe68..30fff881 100644 --- a/packages/app-core/src/components/SearchPalette.tsx +++ b/packages/app-core/src/components/SearchPalette.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import type { NoteMeta } from '@shared/ipc' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' import { buildNoteSearchIndex, parseNoteSearchQuery, @@ -62,6 +63,8 @@ export function SearchPalette(): JSX.Element { placeholder="Search notes… · use #tag to filter" onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/ServerDirectoryPickerModal.tsx b/packages/app-core/src/components/ServerDirectoryPickerModal.tsx index a5192b44..071c453d 100644 --- a/packages/app-core/src/components/ServerDirectoryPickerModal.tsx +++ b/packages/app-core/src/components/ServerDirectoryPickerModal.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { isImeComposing } from '../lib/ime' import type { DirectoryBrowseEntry, DirectoryBrowseShortcut } from '@shared/ipc' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -196,6 +197,8 @@ export function ServerDirectoryPickerModal({ setSubmitError(null) }} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (e.key === 'Enter') { e.preventDefault() void loadDirectory(draftPath.trim()) diff --git a/packages/app-core/src/components/SettingsModal.test.ts b/packages/app-core/src/components/SettingsModal.test.ts new file mode 100644 index 00000000..8b31fba6 --- /dev/null +++ b/packages/app-core/src/components/SettingsModal.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SettingsModal } from './SettingsModal' + +const mocks = vi.hoisted(() => { + const state = new Proxy( + { + autoCalendarPanel: true, + calendarShowWeekNumbers: true, + calendarWeekStart: 'monday', + customTemplates: [], + darkSidebar: false, + editorFontSize: 16, + editorLineHeight: 1.6, + fzfBinaryPath: null, + hideBuiltinTemplates: false, + interfaceFont: null, + keymapOverrides: {}, + lineNumberMode: 'off', + monoFont: null, + previewMaxWidth: 760, + quickNoteTitlePrefix: null, + remoteWorkspaceInfo: null, + remoteWorkspaceProfiles: [], + ripgrepBinaryPath: null, + setSettingsOpen: vi.fn(), + setVaultSettings: vi.fn(), + showSidebarChevrons: true, + systemFolderLabels: {}, + textFont: null, + themeFamily: 'apple', + themeId: 'apple-light', + themeMode: 'light', + vault: { root: '/tmp/zennotes-test-vault', name: 'Test Vault' }, + vaultSettings: { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: true, directory: 'Daily Not' }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {} + }, + vaultTextSearchBackend: 'auto', + vimInsertEscape: '', + vimMode: false, + whichKeyHintMode: 'timed', + whichKeyHintTimeoutMs: 1200, + whichKeyHints: true, + workspaceMode: 'local' + }, + { + get(target, property: string) { + if (property in target) return target[property as keyof typeof target] + return vi.fn() + } + } + ) + + return { + state, + setSettingsOpen: state.setSettingsOpen, + setVaultSettings: state.setVaultSettings + } +}) + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) + +vi.mock('../lib/system-fonts', () => ({ + hasSystemFontAccess: () => false, + listSystemFonts: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../lib/app-update-state', () => ({ + useAppUpdateState: () => ({ phase: 'idle', message: 'Manual check' }) +})) + +vi.mock('@zennotes/bridge-contract/bridge', () => ({ + getZenBridge: () => ({ + getAppInfo: () => ({ + runtime: 'desktop', + version: '2.4.0', + description: 'ZenNotes', + homepage: 'https://github.com/ZenNotes/zennotes/releases/latest' + }), + getCapabilities: () => ({ + supportsCustomTemplates: true, + supportsRemoteWorkspace: false + }) + }) +})) + +function changeInput(input: HTMLInputElement, value: string): void { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + setter?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function blurInput(input: HTMLInputElement): void { + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) +} + +describe('SettingsModal date note directories', () => { + let root: Root + let host: HTMLDivElement + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getVaultTextSearchCapabilities: vi.fn().mockResolvedValue({ ripgrep: false, fzf: false }), + checkForAppUpdates: vi.fn().mockResolvedValue({ phase: 'idle', message: 'Manual check' }) + } + }) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + }) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + }) + + it('does not restore the default daily directory while the field is being cleared', async () => { + await act(async () => { + root.render(createElement(SettingsModal)) + }) + + const search = [...host.querySelectorAll<HTMLInputElement>('input')].find( + (input) => input.placeholder === 'Search settings…' + ) + expect(search).toBeTruthy() + + await act(async () => { + changeInput(search!, 'daily notes directory') + }) + + const dailyDirectory = [...host.querySelectorAll<HTMLInputElement>('input')].find( + (input) => input.value === 'Daily Not' + ) + expect(dailyDirectory).toBeTruthy() + + await act(async () => { + changeInput(dailyDirectory!, '') + }) + + expect(mocks.setVaultSettings).not.toHaveBeenCalled() + }) + + it('saves the daily directory when the edit is committed', async () => { + await act(async () => { + root.render(createElement(SettingsModal)) + }) + + const search = [...host.querySelectorAll<HTMLInputElement>('input')].find( + (input) => input.placeholder === 'Search settings…' + ) + expect(search).toBeTruthy() + + await act(async () => { + changeInput(search!, 'daily notes directory') + }) + + const dailyDirectory = [...host.querySelectorAll<HTMLInputElement>('input')].find( + (input) => input.value === 'Daily Not' + ) + expect(dailyDirectory).toBeTruthy() + + await act(async () => { + changeInput(dailyDirectory!, 'inbox/Journal') + }) + + expect(mocks.setVaultSettings).not.toHaveBeenCalled() + + await act(async () => { + blurInput(dailyDirectory!) + }) + + expect(mocks.setVaultSettings).toHaveBeenCalledWith({ + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: true, directory: 'inbox/Journal' }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {} + }) + }) +}) diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 346064cc..d20b765b 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -1,6 +1,13 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { DEFAULT_DAILY_NOTES_DIRECTORY, DEFAULT_WEEKLY_NOTES_DIRECTORY } from '@shared/ipc' +import { + DEFAULT_DAILY_NOTE_LOCALE, + DEFAULT_DAILY_NOTE_TITLE_PATTERN, + DEFAULT_DAILY_NOTES_DIRECTORY, + DEFAULT_WEEKLY_NOTE_LOCALE, + DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, + DEFAULT_WEEKLY_NOTES_DIRECTORY +} from '@shared/ipc' import type { AppUpdateState, CliInstallStatus, @@ -36,7 +43,14 @@ import { DEFAULT_SYSTEM_FOLDER_LABELS, getSystemFolderLabel } from '../lib/system-folder-labels' -import { normalizeDailyNotesDirectory, normalizeWeeklyNotesDirectory } from '../lib/vault-layout' +import { + normalizeDailyNoteLocale, + normalizeDailyNotesDirectory, + normalizeDailyNoteTitlePattern, + normalizeWeeklyNoteLocale, + normalizeWeeklyNoteTitlePattern, + normalizeWeeklyNotesDirectory +} from '../lib/vault-layout' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' import { composeTemplateFile, mergeTemplates } from '@shared/template-files' import { TemplateEditorModal } from './TemplateEditorModal' @@ -49,6 +63,7 @@ import { useAppUpdateState } from '../lib/app-update-state' import { getZenBridge } from '@zennotes/bridge-contract/bridge' import companyLogo from '../assets/lumary-labs-logo.svg' import { confirmApp } from '../lib/confirm-requests' +import { isImeComposing } from '../lib/ime' import { RemoteWorkspaceProfileModal } from './RemoteWorkspaceProfileModal' import { Button } from './ui/Button' @@ -201,6 +216,8 @@ export function SettingsModal(): JSX.Element { const setVimMode = useStore((s) => s.setVimMode) const vimInsertEscape = useStore((s) => s.vimInsertEscape) const setVimInsertEscape = useStore((s) => s.setVimInsertEscape) + const vimYankToClipboard = useStore((s) => s.vimYankToClipboard) + const setVimYankToClipboard = useStore((s) => s.setVimYankToClipboard) const keymapOverrides = useStore((s) => s.keymapOverrides) const setKeymapBinding = useStore((s) => s.setKeymapBinding) const resetAllKeymaps = useStore((s) => s.resetAllKeymaps) @@ -218,6 +235,8 @@ export function SettingsModal(): JSX.Element { const setFzfBinaryPath = useStore((s) => s.setFzfBinaryPath) const livePreview = useStore((s) => s.livePreview) const setLivePreview = useStore((s) => s.setLivePreview) + const markdownSnippets = useStore((s) => s.markdownSnippets) + const setMarkdownSnippets = useStore((s) => s.setMarkdownSnippets) const tabsEnabled = useStore((s) => s.tabsEnabled) const setTabsEnabled = useStore((s) => s.setTabsEnabled) const wrapTabs = useStore((s) => s.wrapTabs) @@ -917,6 +936,12 @@ export function SettingsModal(): JSX.Element { description: 'Hide markdown syntax on lines you are not editing.', keywords: ['preview', 'markdown'] }, + { + id: 'markdown-snippets', + title: 'Markdown snippets', + description: 'Auto-close markdown delimiters as you type (** then Space, ``` then Enter).', + keywords: ['snippets', 'auto close', 'autoclose', 'auto-pair', 'brackets', 'markdown', 'completion'] + }, { id: 'note-tabs', title: 'Note tabs', @@ -989,6 +1014,13 @@ export function SettingsModal(): JSX.Element { settingId="vim-insert-escape" onChange={(next) => setVimInsertEscape(next ?? '')} /> + <ToggleRow + label="Yank to system clipboard" + description="Copy yanked, deleted, and changed text to the system clipboard (like Vim's clipboard=unnamed), so y/d/c/x are available to paste in other apps." + value={vimYankToClipboard} + settingId="vim-yank-to-clipboard" + onChange={setVimYankToClipboard} + /> <ToggleRow label="Leader key hints" description="Show a which-key style guide after pressing the Leader key so the next available actions stay visible." @@ -1091,6 +1123,13 @@ export function SettingsModal(): JSX.Element { settingId="live-preview" onChange={setLivePreview} /> + <ToggleRow + label="Markdown snippets" + description="Auto-close markdown as you type: ** / __ / ~~ / ` / == / [[ / %% then Space wrap the cursor, and ``` / ~~~ / $$ then Enter expand a fenced block. In Vim mode this only applies in insert mode." + value={markdownSnippets} + settingId="markdown-snippets" + onChange={setMarkdownSnippets} + /> <ToggleRow label="Note tabs" description="Open notes in tabs and allow split-friendly tab workflows. Turn off to keep the simpler single-note behavior." @@ -1381,9 +1420,33 @@ export function SettingsModal(): JSX.Element { }, { id: 'daily-notes-directory', - title: 'Daily notes directory', + title: 'Daily notes directory pattern', description: 'Stored inside your primary notes area.', - keywords: ['daily notes', 'directory', 'folder'] + keywords: ['daily notes', 'directory', 'folder', 'pattern', 'date'] + }, + { + id: 'daily-note-title-pattern', + title: 'Daily note naming pattern', + description: 'Used as the daily note title and filename.', + keywords: ['daily notes', 'title', 'filename', 'pattern', 'date'] + }, + { + id: 'daily-note-locale', + title: 'Daily note locale', + description: 'Used for localized month and weekday names in daily note patterns.', + keywords: ['daily notes', 'locale', 'month', 'weekday', 'pattern'] + }, + { + id: 'daily-note-pattern-support', + title: 'Supported date note pattern tokens', + description: 'Reference for supported date tokens, quoted literals, and example outputs.', + keywords: ['daily notes', 'weekly notes', 'pattern', 'tokens', 'format', 'yyyy', 'mmm', 'weekday', 'week'] + }, + { + id: 'daily-note-pattern-reset', + title: 'Reset daily note patterns to defaults', + description: 'Restore the daily directory, naming, and locale patterns to their defaults.', + keywords: ['daily notes', 'reset', 'default', 'defaults', 'restore', 'pattern'] }, { id: 'open-todays-daily-note', @@ -1405,9 +1468,33 @@ export function SettingsModal(): JSX.Element { }, { id: 'weekly-notes-directory', - title: 'Weekly notes directory', + title: 'Weekly notes directory pattern', description: 'Stored inside your primary notes area.', - keywords: ['weekly notes', 'directory', 'folder'] + keywords: ['weekly notes', 'directory', 'folder', 'pattern', 'date', 'week'] + }, + { + id: 'weekly-note-title-pattern', + title: 'Weekly note naming pattern', + description: 'Used as the weekly note title and filename.', + keywords: ['weekly notes', 'title', 'filename', 'pattern', 'date', 'week'] + }, + { + id: 'weekly-note-locale', + title: 'Weekly note locale', + description: 'Used for localized month and weekday names in weekly note patterns.', + keywords: ['weekly notes', 'locale', 'month', 'weekday', 'pattern'] + }, + { + id: 'weekly-note-pattern-support', + title: 'Supported date note pattern tokens', + description: 'Reference for supported date tokens, ISO week tokens, quoted literals, and example outputs.', + keywords: ['weekly notes', 'pattern', 'tokens', 'format', 'yyyy', 'ww', 'iso week'] + }, + { + id: 'weekly-note-pattern-reset', + title: 'Reset weekly note patterns to defaults', + description: 'Restore the weekly directory, naming, and locale patterns to their defaults.', + keywords: ['weekly notes', 'reset', 'default', 'defaults', 'restore', 'pattern'] }, { id: 'weekly-notes-template', @@ -1648,11 +1735,12 @@ export function SettingsModal(): JSX.Element { } /> <TextInputRow - label="Daily notes directory" + label="Daily notes directory pattern" description="Stored inside your primary notes area. The default is `Daily Notes`." value={vaultSettings.dailyNotes.directory} placeholder={DEFAULT_DAILY_NOTES_DIRECTORY} settingId="daily-notes-directory" + commitOnBlur onChange={(next) => void persistVaultSettings({ ...vaultSettings, @@ -1663,6 +1751,63 @@ export function SettingsModal(): JSX.Element { }) } /> + <TextInputRow + label="Daily note naming pattern" + description="Used as the daily note title and filename. The default is `yyyy-MM-dd`." + value={vaultSettings.dailyNotes.titlePattern ?? DEFAULT_DAILY_NOTE_TITLE_PATTERN} + placeholder={DEFAULT_DAILY_NOTE_TITLE_PATTERN} + settingId="daily-note-title-pattern" + commitOnBlur + onChange={(next) => + void persistVaultSettings({ + ...vaultSettings, + dailyNotes: { + ...vaultSettings.dailyNotes, + titlePattern: normalizeDailyNoteTitlePattern(next) + } + }) + } + /> + <TextInputRow + label="Daily note locale" + description="Used for localized month and weekday names. Use `system`, `en-US`, or `pt-BR`." + value={vaultSettings.dailyNotes.locale ?? DEFAULT_DAILY_NOTE_LOCALE} + placeholder={DEFAULT_DAILY_NOTE_LOCALE} + settingId="daily-note-locale" + commitOnBlur + onChange={(next) => + void persistVaultSettings({ + ...vaultSettings, + dailyNotes: { + ...vaultSettings.dailyNotes, + locale: normalizeDailyNoteLocale(next) + } + }) + } + /> + <DateNotePatternResetRow + kind="daily" + settingId="daily-note-pattern-reset" + isDefault={ + vaultSettings.dailyNotes.directory === DEFAULT_DAILY_NOTES_DIRECTORY && + (vaultSettings.dailyNotes.titlePattern ?? DEFAULT_DAILY_NOTE_TITLE_PATTERN) === + DEFAULT_DAILY_NOTE_TITLE_PATTERN && + (vaultSettings.dailyNotes.locale ?? DEFAULT_DAILY_NOTE_LOCALE) === + DEFAULT_DAILY_NOTE_LOCALE + } + onReset={() => + void persistVaultSettings({ + ...vaultSettings, + dailyNotes: { + ...vaultSettings.dailyNotes, + directory: DEFAULT_DAILY_NOTES_DIRECTORY, + titlePattern: DEFAULT_DAILY_NOTE_TITLE_PATTERN, + locale: DEFAULT_DAILY_NOTE_LOCALE + } + }) + } + /> + <DateNotePatternSupportRow kind="daily" settingId="daily-note-pattern-support" /> <TemplateSelectRow label="Daily note template" description="Applied when a daily note is created. None creates a blank note." @@ -1676,6 +1821,30 @@ export function SettingsModal(): JSX.Element { }) } /> + <ToggleRow + label="Tasks are due on the note's date" + description="A task written inside a daily note appears on the calendar for that day automatically — no need to type a due date. An explicit `due:YYYY-MM-DD` still wins." + value={vaultSettings.dailyNotes.tasksDueOnNoteDate !== false} + settingId="daily-notes-tasks-due-on-date" + onChange={(on) => + void persistVaultSettings({ + ...vaultSettings, + dailyNotes: { ...vaultSettings.dailyNotes, tasksDueOnNoteDate: on } + }) + } + /> + <ToggleRow + label="Roll over unfinished tasks to today" + description="When today's daily note opens, move every unchecked task from previous daily notes into it. Checked tasks stay where they are." + value={vaultSettings.dailyNotes.rolloverUnfinishedTasks === true} + settingId="daily-notes-rollover" + onChange={(on) => + void persistVaultSettings({ + ...vaultSettings, + dailyNotes: { ...vaultSettings.dailyNotes, rolloverUnfinishedTasks: on } + }) + } + /> <div className="flex items-center justify-between gap-4 px-5 py-4" {...settingsSearchTargetProps('open-todays-daily-note')} @@ -1704,7 +1873,7 @@ export function SettingsModal(): JSX.Element { <Section title="Weekly Notes" - description="Create one note per ISO week with a YYYY-Www title and keep them in a dedicated directory." + description="Create one note per ISO week with a configurable title and keep it in a dedicated directory." > <ToggleRow label="Enable weekly notes" @@ -1722,11 +1891,12 @@ export function SettingsModal(): JSX.Element { } /> <TextInputRow - label="Weekly notes directory" + label="Weekly notes directory pattern" description="Stored inside your primary notes area. The default is `Weekly Notes`." value={vaultSettings.weeklyNotes.directory} placeholder={DEFAULT_WEEKLY_NOTES_DIRECTORY} settingId="weekly-notes-directory" + commitOnBlur onChange={(next) => void persistVaultSettings({ ...vaultSettings, @@ -1737,6 +1907,63 @@ export function SettingsModal(): JSX.Element { }) } /> + <TextInputRow + label="Weekly note naming pattern" + description="Used as the weekly note title and filename. The default is `yyyy-'W'ww`." + value={vaultSettings.weeklyNotes.titlePattern ?? DEFAULT_WEEKLY_NOTE_TITLE_PATTERN} + placeholder={DEFAULT_WEEKLY_NOTE_TITLE_PATTERN} + settingId="weekly-note-title-pattern" + commitOnBlur + onChange={(next) => + void persistVaultSettings({ + ...vaultSettings, + weeklyNotes: { + ...vaultSettings.weeklyNotes, + titlePattern: normalizeWeeklyNoteTitlePattern(next) + } + }) + } + /> + <TextInputRow + label="Weekly note locale" + description="Used for localized month and weekday names. Use `system`, `en-US`, or `pt-BR`." + value={vaultSettings.weeklyNotes.locale ?? DEFAULT_WEEKLY_NOTE_LOCALE} + placeholder={DEFAULT_WEEKLY_NOTE_LOCALE} + settingId="weekly-note-locale" + commitOnBlur + onChange={(next) => + void persistVaultSettings({ + ...vaultSettings, + weeklyNotes: { + ...vaultSettings.weeklyNotes, + locale: normalizeWeeklyNoteLocale(next) + } + }) + } + /> + <DateNotePatternResetRow + kind="weekly" + settingId="weekly-note-pattern-reset" + isDefault={ + vaultSettings.weeklyNotes.directory === DEFAULT_WEEKLY_NOTES_DIRECTORY && + (vaultSettings.weeklyNotes.titlePattern ?? DEFAULT_WEEKLY_NOTE_TITLE_PATTERN) === + DEFAULT_WEEKLY_NOTE_TITLE_PATTERN && + (vaultSettings.weeklyNotes.locale ?? DEFAULT_WEEKLY_NOTE_LOCALE) === + DEFAULT_WEEKLY_NOTE_LOCALE + } + onReset={() => + void persistVaultSettings({ + ...vaultSettings, + weeklyNotes: { + ...vaultSettings.weeklyNotes, + directory: DEFAULT_WEEKLY_NOTES_DIRECTORY, + titlePattern: DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, + locale: DEFAULT_WEEKLY_NOTE_LOCALE + } + }) + } + /> + <DateNotePatternSupportRow kind="weekly" settingId="weekly-note-pattern-support" /> <TemplateSelectRow label="Weekly note template" description="Applied when a weekly note is created. None creates a blank note." @@ -1757,7 +1984,7 @@ export function SettingsModal(): JSX.Element { <div className="min-w-0"> <div className="text-sm font-medium text-ink-900">Open this week's note</div> <div className="mt-1 text-xs leading-5 text-ink-500"> - Opens this week's note if it exists, otherwise creates it with a YYYY-Www title. + Opens this week's note if it exists, otherwise creates it with the configured weekly title pattern. </div> </div> <button @@ -2743,6 +2970,119 @@ function InlineNote({ children }: { children: React.ReactNode }): JSX.Element { return <div className="px-5 py-4 text-xs leading-5 text-ink-500">{children}</div> } +const DATE_NOTE_PATTERN_TOKENS = [ + { token: 'yyyy', output: '2026', meaning: 'year; ISO week-year for weekly notes' }, + { token: 'yy', output: '26', meaning: '2-digit year' }, + { token: 'M', output: '6', meaning: 'month' }, + { token: 'MM', output: '06', meaning: 'padded month' }, + { token: 'MMM', output: 'Jun', meaning: 'short month name' }, + { token: 'MMMM', output: 'June', meaning: 'full month name' }, + { token: 'd', output: '9', meaning: 'day of month' }, + { token: 'dd', output: '09', meaning: 'padded day of month' }, + { token: 'EEE', output: 'Tue', meaning: 'short weekday name' }, + { token: 'EEEE', output: 'Tuesday', meaning: 'full weekday name' }, + { token: 'w', output: '24', meaning: 'ISO week number' }, + { token: 'ww', output: '24', meaning: 'padded ISO week number' } +] + +function DateNotePatternSupportRow({ + kind, + settingId +}: { + kind: 'daily' | 'weekly' + settingId?: string +}): JSX.Element { + const example = + kind === 'daily' ? ( + <> + <code className="font-mono text-ink-700">yyyy/MM-MMM</code> +{' '} + <code className="font-mono text-ink-700">yyyy-MM-dd-EEE</code> creates{' '} + <code className="font-mono text-ink-700">2026/06-Jun/2026-06-09-Tue.md</code>. + </> + ) : ( + <> + <code className="font-mono text-ink-700">yyyy/MM-MMM</code> +{' '} + <code className="font-mono text-ink-700">yyyy-'W'ww-EEE</code> creates{' '} + <code className="font-mono text-ink-700">2026/06-Jun/2026-W24-Mon.md</code>. + </> + ) + + return ( + <div className="px-5 py-4" {...settingsSearchTargetProps(settingId)}> + <div className="text-sm font-medium text-ink-900">Supported pattern tokens</div> + <div className="mt-1 text-xs leading-5 text-ink-500"> + Directory and naming patterns support these tokens. Weekly notes render date tokens from + the ISO week’s Monday. Wrap literal words in single quotes, for example{' '} + <code className="font-mono text-ink-700">'Daily Notes'/yyyy/MM-MMM</code>. + </div> + <dl className="mt-4 grid grid-cols-1 gap-x-5 gap-y-2 text-xs sm:grid-cols-2"> + {DATE_NOTE_PATTERN_TOKENS.map((item) => ( + <div key={item.token} className="grid grid-cols-[5rem_4.5rem_1fr] items-baseline gap-3"> + <dt className="font-mono text-ink-900">{item.token}</dt> + <dd className="font-mono text-ink-600">{item.output}</dd> + <dd className="text-ink-500">{item.meaning}</dd> + </div> + ))} + </dl> + <div className="mt-4 space-y-1 text-xs leading-5 text-ink-500"> + <div> + <span className="font-medium text-ink-700">Example:</span> {example} + </div> + <div> + Localized names use the note locale:{' '} + <code className="font-mono text-ink-700">system</code>,{' '} + <code className="font-mono text-ink-700">en-US</code>,{' '} + <code className="font-mono text-ink-700">pt-BR</code>, or another BCP 47 locale. + </div> + </div> + </div> + ) +} + +function DateNotePatternResetRow({ + kind, + isDefault, + onReset, + settingId +}: { + kind: 'daily' | 'weekly' + isDefault: boolean + onReset: () => void + settingId?: string +}): JSX.Element { + const directory = + kind === 'daily' ? DEFAULT_DAILY_NOTES_DIRECTORY : DEFAULT_WEEKLY_NOTES_DIRECTORY + const naming = + kind === 'daily' ? DEFAULT_DAILY_NOTE_TITLE_PATTERN : DEFAULT_WEEKLY_NOTE_TITLE_PATTERN + const locale = kind === 'daily' ? DEFAULT_DAILY_NOTE_LOCALE : DEFAULT_WEEKLY_NOTE_LOCALE + return ( + <div + className="flex items-center justify-between gap-4 px-5 py-4" + {...settingsSearchTargetProps(settingId)} + > + <div className="min-w-0"> + <div className="text-sm font-medium text-ink-900">Reset to defaults</div> + <div className="mt-1 text-xs leading-5 text-ink-500"> + Restore the directory, naming, and locale to{' '} + <code className="font-mono text-ink-700">{directory}</code>,{' '} + <code className="font-mono text-ink-700">{naming}</code>, and{' '} + <code className="font-mono text-ink-700">{locale}</code>. Notes created with the + current pattern stay recognized. + </div> + </div> + <Button + variant="secondary" + size="sm" + disabled={isDefault} + onClick={onReset} + className="shrink-0" + > + Reset to defaults + </Button> + </div> + ) +} + const DEFAULT_QUICK_CAPTURE_HOTKEY = 'CommandOrControl+Shift+Space' function toElectronAccelerator(binding: string): string { @@ -2917,6 +3257,7 @@ function TextInputRow({ value, placeholder, settingId, + commitOnBlur = false, onChange }: { label: string @@ -2924,8 +3265,22 @@ function TextInputRow({ value: string placeholder?: string settingId?: string + commitOnBlur?: boolean onChange: (next: string | null) => void }): JSX.Element { + const [draft, setDraft] = useState(value) + const [focused, setFocused] = useState(false) + + useEffect(() => { + if (!commitOnBlur || !focused) setDraft(value) + }, [commitOnBlur, focused, value]) + + const commit = (raw: string): void => { + const next = raw.trim() + if (commitOnBlur && next === value) return + onChange(next ? next : null) + } + return ( <div className="flex items-center justify-between gap-5 px-5 py-4" @@ -2936,10 +3291,27 @@ function TextInputRow({ {description && <div className="mt-1 text-xs leading-5 text-ink-500">{description}</div>} </div> <input - value={value} + value={commitOnBlur ? draft : value} + onFocus={() => setFocused(true)} onChange={(e) => { - const next = e.target.value.trim() - onChange(next ? next : null) + if (commitOnBlur) { + setDraft(e.target.value) + return + } + commit(e.target.value) + }} + onBlur={(e) => { + if (!commitOnBlur) return + setFocused(false) + commit(e.target.value) + }} + onKeyDown={(e) => { + if (!commitOnBlur) return + if (e.key === 'Enter' && !isImeComposing(e)) e.currentTarget.blur() + if (e.key === 'Escape') { + setDraft(value) + e.currentTarget.blur() + } }} placeholder={placeholder} className="w-[23rem] max-w-[50vw] rounded-xl border border-paper-300/70 bg-paper-100/80 px-3 py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400 focus:border-accent/45" @@ -3102,6 +3474,8 @@ function FontRow({ } const handleKey = (e: React.KeyboardEvent<HTMLInputElement>): void => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (e.key === 'ArrowDown') { e.preventDefault() setActiveIdx((i) => (i + 1 >= items.length ? items.length - 1 : i + 1)) diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index bada9929..97d8e69f 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { isArchiveViewActive, + isAssetsViewActive, isHelpViewActive, isQuickNotesViewActive, isTagsViewActive, @@ -8,10 +9,11 @@ import { isTrashViewActive, useStore, } from "../store"; +import { Button } from "./ui/Button"; import { confirmMoveToTrash } from "../lib/confirm-trash"; import { buildMoveNotePrompt, parseMoveNoteTarget } from "../lib/move-note"; import { extractTags } from "../lib/tags"; -import type { AssetMeta, FolderEntry, FolderIconId, NoteFolder, NoteMeta } from "@shared/ipc"; +import type { AssetMeta, FolderColorId, FolderEntry, FolderIconId, NoteFolder, NoteMeta } from "@shared/ipc"; import type { NoteSortOrder } from "../store"; import { isArchiveTabPath } from "@shared/archive"; import { isTrashTabPath } from "@shared/trash"; @@ -23,8 +25,11 @@ import { ChevronRightIcon, CheckSquareIcon, CloseIcon, + DatabaseIcon, DocumentIcon, + ExcalidrawIcon, ExpandAllIcon, + PaperclipIcon, FolderPlusIcon, NotePlusIcon, PanelLeftIcon, @@ -40,15 +45,23 @@ import { ResizeHandle } from "./ResizeHandle"; import { VaultBadge } from "./VaultBadge"; import { confirmApp } from '../lib/confirm-requests' import { promptApp } from '../lib/prompt-requests' +import { naturalCompare } from '../lib/natural-sort' import { resolveQuickNoteTitle } from "../lib/quick-note-title"; import { recordRendererPerf } from "../lib/perf"; +import { DEFAULT_DAILY_NOTES_DIRECTORY, DEFAULT_WEEKLY_NOTES_DIRECTORY } from "@shared/ipc"; import { assetFolderSubpath, + classifyDateNote, + dateNoteFolderMayBelongToDatePattern, + dateNoteDirectoryDisplayLabel, + favoriteFolderKey, folderIconKey, + isFavoriteFolderKey, isPrimaryNotesAtRoot, folderForVaultRelativePath, normalizeVaultSettings, noteFolderSubpath, + parseFavoriteFolderKey, } from "../lib/vault-layout"; import { hasZenItem, @@ -58,12 +71,21 @@ import { } from "../lib/dnd"; import { resolveSystemFolderLabels } from "../lib/system-folder-labels"; import { assetTabPath } from "../lib/asset-tabs"; +import { + csvPathForFormDir, + FORM_DIR_SUFFIX, + formTitleFromDir, + isFormDirName, +} from "@shared/databases"; +import { isExcalidrawPath } from "@shared/excalidraw"; import { FolderGlyphIcon, - resolveFolderIconId, + iconOptionById, resolveFolderIconOption, } from "./FolderIcons"; import { FolderIconPickerModal } from "./FolderIconPickerModal"; +import { colorGlyphClassById, resolveFolderColorGlyphClass } from "./FolderColors"; +import { FolderColorPickerModal } from "./FolderColorPickerModal"; import { getSidebarEdgePrefetchPaths, getSidebarEntryLimitIncludingIndex, @@ -78,6 +100,7 @@ import { } from "../lib/sidebar-scroll"; import { buildVaultSwitcherEntries } from "../lib/vault-switcher"; import { appUpdateBadgeLabel, useAppUpdateState } from "../lib/app-update-state"; +import { getISOWeekYear } from "../lib/template-render"; const ACTIVE_TAG_PARSE_DELAY_MS = 220; const ACTIVE_TAG_PARSE_LARGE_BODY_CHARS = 120_000; @@ -138,17 +161,26 @@ function remoteWorkspaceLabel(baseUrl: string | null): string { function SidebarGlyph({ active, rowActive, + colorClass, children, }: { active: boolean; rowActive: boolean; + /** Custom resting tint (folder color); ignored while active/selected. */ + colorClass?: string; children: JSX.Element; }): JSX.Element { return ( <span className={[ "flex h-5 w-5 shrink-0 items-center justify-center transition-colors", - active ? "text-white" : rowActive ? "text-accent" : "text-ink-500 group-hover:text-ink-800", + colorClass + ? colorClass + : active + ? "text-ink-900" + : rowActive + ? "text-accent" + : "text-ink-400 group-hover:text-ink-700", ].join(" ")} > {children} @@ -219,6 +251,11 @@ type SidebarSelectionItem = | { kind: "note"; path: string } | { kind: "folder"; folder: NoteFolder; subpath: string }; +/** A favorite resolved to a live note or folder for rendering. */ +type FavoriteItem = + | { kind: "note"; key: string; path: string; title: string; isDrawing: boolean } + | { kind: "folder"; key: string; folder: NoteFolder; subpath: string; label: string }; + function noteSelectionKey(path: string): string { return `note:${encodeURIComponent(path)}`; } @@ -349,6 +386,7 @@ export function Sidebar(): JSX.Element { const activeNote = useStore((s) => s.activeNote); const activeDirty = useStore((s) => s.activeDirty); const vaultSettings = useStore((s) => s.vaultSettings); + const rootContentHiddenByInboxMode = useStore((s) => s.rootContentHiddenByInboxMode); const view = useStore((s) => s.view); const assetFiles = useStore((s) => s.assetFiles); const setView = useStore((s) => s.setView); @@ -362,11 +400,16 @@ export function Sidebar(): JSX.Element { const archiveViewActive = useStore(isArchiveViewActive); const openTrashView = useStore((s) => s.openTrashView); const trashViewActive = useStore(isTrashViewActive); + const openAssetsView = useStore((s) => s.openAssetsView); + const assetsViewActive = useStore(isAssetsViewActive); + const assetCount = useStore((s) => s.assetFiles.length); const openTagView = useStore((s) => s.openTagView); const selectedTags = useStore((s) => s.selectedTags); const tagsViewActive = useStore(isTagsViewActive); const setSearchOpen = useStore((s) => s.setSearchOpen); const createAndOpen = useStore((s) => s.createAndOpen); + const createDrawingAndOpen = useStore((s) => s.createDrawingAndOpen); + const toggleFavorite = useStore((s) => s.toggleFavorite); const createDatabase = useStore((s) => s.createDatabase); const createNoteInChosenFolder = useStore((s) => s.createNoteInChosenFolder); const openTemplatePaletteForFolder = useStore((s) => s.openTemplatePaletteForFolder); @@ -486,6 +529,15 @@ export function Sidebar(): JSX.Element { }), [localVaults, remoteWorkspaceInfo, remoteWorkspaceProfiles, vault, workspaceMode], ); + // Name the active vault in the header exactly as the switcher does. In remote + // mode vault.name is the server-side vault folder (e.g. "workspace"), not the + // connection the user named (e.g. "Home"); the current switcher entry already + // resolves that profile name, so reuse it to keep the header label and badge + // in sync with the switcher (#153). + const headerVaultName = + vaultSwitcherEntries.find((entry) => entry.current)?.name ?? + vault?.name ?? + "ZenNotes"; const primaryNotesAtRoot = useMemo( () => isPrimaryNotesAtRoot(vaultSettings), [vaultSettings], @@ -823,9 +875,16 @@ export function Sidebar(): JSX.Element { y: number; path: string; } | null>(null); - const [folderIconPicker, setFolderIconPicker] = useState<{ - folder: NoteFolder; - subpath: string; + // Icon/color customization targets — keyed by an arbitrary string so it works + // for folders (`folder:subpath`), notes/databases (vault-relative path), and + // anything else. Folder keys contain ':'; note paths never do, so they coexist + // in the same folderIcons/folderColors maps without colliding. + const [iconPicker, setIconPicker] = useState<{ + key: string; + label: string; + } | null>(null); + const [colorPicker, setColorPicker] = useState<{ + key: string; label: string; } | null>(null); const [sortMenu, setSortMenu] = useState<{ x: number; y: number } | null>( @@ -862,34 +921,30 @@ export function Sidebar(): JSX.Element { ], ); - const openFolderIconPicker = useCallback( - (folder: NoteFolder, subpath: string, label: string) => { - setFolderMenu(null); - setFolderIconPicker({ folder, subpath, label }); - }, - [], - ); + // The context menu closes itself on select (ContextMenu calls onClose first), + // so these just open the picker. + const openIconPicker = useCallback((key: string, label: string) => { + setIconPicker({ key, label }); + }, []); - const saveFolderIcon = useCallback( - async (folder: NoteFolder, subpath: string, iconId: FolderIconId) => { - const key = folderIconKey(folder, subpath); + const saveIcon = useCallback( + async (key: string, iconId: FolderIconId) => { const nextSettings = normalizeVaultSettings({ ...vaultSettings, - folderIcons: { - ...vaultSettings.folderIcons, - [key]: iconId, - }, + folderIcons: { ...vaultSettings.folderIcons, [key]: iconId }, }); await setVaultSettings(nextSettings); - setFolderIconPicker(null); + setIconPicker(null); }, [setVaultSettings, vaultSettings], ); - const resetFolderIcon = useCallback( - async (folder: NoteFolder, subpath: string) => { - const key = folderIconKey(folder, subpath); - if (!(key in vaultSettings.folderIcons)) return; + const resetIcon = useCallback( + async (key: string) => { + if (!(key in vaultSettings.folderIcons)) { + setIconPicker(null); + return; + } const nextIcons = { ...vaultSettings.folderIcons }; delete nextIcons[key]; const nextSettings = normalizeVaultSettings({ @@ -897,7 +952,41 @@ export function Sidebar(): JSX.Element { folderIcons: nextIcons, }); await setVaultSettings(nextSettings); - setFolderIconPicker(null); + setIconPicker(null); + }, + [setVaultSettings, vaultSettings], + ); + + const openColorPicker = useCallback((key: string, label: string) => { + setColorPicker({ key, label }); + }, []); + + const saveColor = useCallback( + async (key: string, colorId: FolderColorId) => { + const nextSettings = normalizeVaultSettings({ + ...vaultSettings, + folderColors: { ...vaultSettings.folderColors, [key]: colorId }, + }); + await setVaultSettings(nextSettings); + setColorPicker(null); + }, + [setVaultSettings, vaultSettings], + ); + + const resetColor = useCallback( + async (key: string) => { + if (!(key in vaultSettings.folderColors)) { + setColorPicker(null); + return; + } + const nextColors = { ...vaultSettings.folderColors }; + delete nextColors[key]; + const nextSettings = normalizeVaultSettings({ + ...vaultSettings, + folderColors: nextColors, + }); + await setVaultSettings(nextSettings); + setColorPicker(null); }, [setVaultSettings, vaultSettings], ); @@ -934,6 +1023,23 @@ export function Sidebar(): JSX.Element { // separately. const trees = useMemo(() => { const startedAt = performance.now(); + const ds = normalizeVaultSettings(vaultSettings); + const dateNotePaths = new Set<string>(); + const dateFolderSubpaths = new Set<string>(); + if (ds.dailyNotes.enabled || ds.weeklyNotes.enabled) { + for (const note of notes) { + if (note.folder !== "inbox") continue; + const info = classifyDateNote(note, ds); + if (!info) continue; + dateNotePaths.add(note.path); + addSubpathAndAncestors(dateFolderSubpaths, noteFolderSubpath(note, ds)); + } + for (const folder of allFolders) { + if (folder.folder !== "inbox") continue; + if (!dateNoteFolderMayBelongToDatePattern(folder.subpath, ds)) continue; + addSubpathAndAncestors(dateFolderSubpaths, folder.subpath); + } + } const next = { quick: buildTree( notes.filter((n) => n.folder === "quick"), @@ -945,7 +1051,7 @@ export function Sidebar(): JSX.Element { vaultSettings, ), inbox: buildTree( - notes.filter((n) => n.folder === "inbox"), + notes.filter((n) => n.folder === "inbox" && !dateNotePaths.has(n.path)), assetFiles.filter( (asset) => folderForVaultRelativePath(asset.path, vaultSettings) === "inbox", ), @@ -972,17 +1078,13 @@ export function Sidebar(): JSX.Element { vaultSettings, ), }; - // Daily/Weekly directories are surfaced in their own pinned, date-grouped - // section above NOTES (see DateNotesNav), so drop them from the inbox tree - // to avoid showing them twice. - const ds = normalizeVaultSettings(vaultSettings); - const hideSubpaths = new Set<string>(); - if (ds.dailyNotes.enabled) hideSubpaths.add(ds.dailyNotes.directory); - if (ds.weeklyNotes.enabled) hideSubpaths.add(ds.weeklyNotes.directory); - if (hideSubpaths.size) { + // Daily/weekly notes are surfaced in their own pinned, date-grouped section + // above NOTES. Remove only the empty folder spine left behind by those date + // notes so unrelated files under the same year/month folders remain visible. + if (dateFolderSubpaths.size) { next.inbox = { ...next.inbox, - children: next.inbox.children.filter((c) => !hideSubpaths.has(c.subpath)), + children: pruneEmptyDateNoteFolders(next.inbox.children, dateFolderSubpaths), }; } recordRendererPerf("sidebar.tree-build", performance.now() - startedAt, { @@ -993,6 +1095,41 @@ export function Sidebar(): JSX.Element { return next; }, [notes, allFolders, assetFiles, vaultSettings]); + // Resolve favorite keys to live notes/folders. Keys whose target no longer + // exists (renamed away, deleted, trashed) are silently skipped — the Favorites + // section never shows a broken row. Order follows the stored favorites list. + const favoriteItems = useMemo<FavoriteItem[]>(() => { + const out: FavoriteItem[] = []; + for (const key of vaultSettings.favorites) { + if (isFavoriteFolderKey(key)) { + const parsed = parseFavoriteFolderKey(key); + if (!parsed || !parsed.subpath) continue; + const exists = allFolders.some( + (f) => f.folder === parsed.folder && f.subpath === parsed.subpath, + ); + if (!exists) continue; + out.push({ + kind: "folder", + key, + folder: parsed.folder, + subpath: parsed.subpath, + label: parsed.subpath.split("/").slice(-1)[0], + }); + } else { + const note = notes.find((n) => n.path === key); + if (!note || note.folder === "trash") continue; + out.push({ + kind: "note", + key, + path: note.path, + title: note.title, + isDrawing: isExcalidrawPath(note.path), + }); + } + } + return out; + }, [vaultSettings.favorites, notes, allFolders]); + // Daily/weekly notes grouped for the pinned date-nav: daily by year → month → // day, weekly by year → week, all newest-first. const dateNav = useMemo(() => { @@ -1002,16 +1139,17 @@ export function Sidebar(): JSX.Element { const daily: { year: number; total: number; months: { month: number; notes: NoteMeta[] }[] }[] = []; const weekly: { year: number; notes: NoteMeta[] }[] = []; + const dailyTimes = new Map<string, number>(); + const weeklyTimes = new Map<string, number>(); if (s.dailyNotes.enabled) { const byYear = new Map<number, Map<number, NoteMeta[]>>(); for (const n of notes) { - if (n.folder !== "inbox") continue; - if (noteFolderSubpath(n, vaultSettings) !== dailyDir) continue; - const m = /^(\d{4})-(\d{2})-\d{2}$/.exec(n.title); - if (!m) continue; - const year = Number(m[1]); - const month = Number(m[2]) - 1; + const info = classifyDateNote(n, s); + if (info?.kind !== "daily") continue; + dailyTimes.set(n.path, info.date.getTime()); + const year = info.date.getFullYear(); + const month = info.date.getMonth(); let months = byYear.get(year); if (!months) byYear.set(year, (months = new Map())); (months.get(month) ?? months.set(month, []).get(month)!).push(n); @@ -1021,7 +1159,11 @@ export function Sidebar(): JSX.Element { const mlist = [...months.entries()] .sort((a, b) => b[0] - a[0]) .map(([month, ns]) => { - ns.sort((a, b) => b.title.localeCompare(a.title)); + ns.sort( + (a, b) => + (dailyTimes.get(b.path) ?? 0) - (dailyTimes.get(a.path) ?? 0) || + b.title.localeCompare(a.title), + ); total += ns.length; return { month, notes: ns }; }); @@ -1032,14 +1174,18 @@ export function Sidebar(): JSX.Element { if (s.weeklyNotes.enabled) { const byYear = new Map<number, NoteMeta[]>(); for (const n of notes) { - if (n.folder !== "inbox") continue; - if (noteFolderSubpath(n, vaultSettings) !== weeklyDir) continue; - if (!/^\d{4}-W\d{2}$/.test(n.title)) continue; - const year = Number(n.title.slice(0, 4)); + const info = classifyDateNote(n, s); + if (info?.kind !== "weekly") continue; + weeklyTimes.set(n.path, info.date.getTime()); + const year = getISOWeekYear(info.date); (byYear.get(year) ?? byYear.set(year, []).get(year)!).push(n); } for (const [year, ns] of [...byYear.entries()].sort((a, b) => b[0] - a[0])) { - ns.sort((a, b) => b.title.localeCompare(a.title)); + ns.sort( + (a, b) => + (weeklyTimes.get(b.path) ?? 0) - (weeklyTimes.get(a.path) ?? 0) || + b.title.localeCompare(a.title), + ); weekly.push({ year, notes: ns }); } } @@ -1049,8 +1195,8 @@ export function Sidebar(): JSX.Element { weeklyEnabled: s.weeklyNotes.enabled, dailyDir, weeklyDir, - dailyLabel: dailyDir.split("/").pop() || dailyDir, - weeklyLabel: weeklyDir.split("/").pop() || weeklyDir, + dailyLabel: dateNoteDirectoryDisplayLabel(dailyDir, DEFAULT_DAILY_NOTES_DIRECTORY), + weeklyLabel: dateNoteDirectoryDisplayLabel(weeklyDir, DEFAULT_WEEKLY_NOTES_DIRECTORY), daily, weekly, dailyTotal: daily.reduce((sum, y) => sum + y.total, 0), @@ -1082,11 +1228,9 @@ export function Sidebar(): JSX.Element { case "created-asc": return (a: NoteMeta, b: NoteMeta) => a.createdAt - b.createdAt; case "name-asc": - return (a: NoteMeta, b: NoteMeta) => - a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); + return (a: NoteMeta, b: NoteMeta) => naturalCompare(a.title, b.title); case "name-desc": - return (a: NoteMeta, b: NoteMeta) => - b.title.localeCompare(a.title, undefined, { sensitivity: "base" }); + return (a: NoteMeta, b: NoteMeta) => naturalCompare(b.title, a.title); case "updated-desc": default: return (a: NoteMeta, b: NoteMeta) => b.updatedAt - a.updatedAt; @@ -1517,22 +1661,18 @@ export function Sidebar(): JSX.Element { const label = isTop ? folderLabels[folder] : subpath.split("/").slice(-1)[0]; const trashCount = notes.filter((note) => note.folder === "trash").length; const iconKey = folderIconKey(folder, subpath); - const hasCustomIcon = Object.prototype.hasOwnProperty.call( - vaultSettings.folderIcons, - iconKey, - ); + // Reset lives inside each picker now (shown when a custom value is set). const iconItems: ContextMenuItem[] = [ { label: "Change icon…", onSelect: async () => { - openFolderIconPicker(folder, subpath, label); + openIconPicker(iconKey, label); }, }, { - label: "Reset icon", - disabled: !hasCustomIcon, + label: "Change color…", onSelect: async () => { - await resetFolderIcon(folder, subpath); + openColorPicker(iconKey, label); }, }, ]; @@ -1566,7 +1706,13 @@ export function Sidebar(): JSX.Element { { label: "New note", onSelect: async () => { - await createAndOpen(folder, subpath); + await createAndOpen(folder, subpath, { focusTitle: true }); + }, + }, + { + label: "New drawing", + onSelect: async () => { + await createDrawingAndOpen(folder, subpath); }, }, { @@ -1627,6 +1773,14 @@ export function Sidebar(): JSX.Element { if (!isTop) { items.push({ kind: "separator" }); + const favKey = favoriteFolderKey(folder, subpath); + const isFav = vaultSettings.favorites.includes(favKey); + items.push({ + label: isFav ? "Remove from Favorites" : "Add to Favorites", + onSelect: async () => { + await toggleFavorite(favKey); + }, + }); items.push({ label: "Duplicate", onSelect: async () => { @@ -1702,13 +1856,17 @@ export function Sidebar(): JSX.Element { if (!isTop) { items.push({ kind: "separator" }); + const leafName = subpath.split("/").slice(-1)[0]; + const isDb = isFormDirName(leafName); items.push({ label: "Rename…", onSelect: async () => { - const leaf = subpath.split("/").slice(-1)[0]; + const leaf = leafName; + // A database folder renames by its title; the `.base` suffix is part of + // the folder name and must be preserved. (#185) const next = await promptApp({ - title: "Rename folder", - initialValue: leaf, + title: isDb ? "Rename database" : "Rename folder", + initialValue: isDb ? formTitleFromDir(leaf) : leaf, okLabel: "Rename", validate: (v) => { if (v.includes("/")) return "Use only a leaf name"; @@ -1717,9 +1875,11 @@ export function Sidebar(): JSX.Element { }); if (!next) return; const clean = next.trim().replace(/^\/+|\/+$/g, ""); - if (!clean || clean === leaf) return; + if (!clean) return; + const nextLeaf = isDb ? `${clean}${FORM_DIR_SUFFIX}` : clean; + if (nextLeaf === leaf) return; const parent = subpath.split("/").slice(0, -1).join("/"); - const nextSubpath = parent ? `${parent}/${clean}` : clean; + const nextSubpath = parent ? `${parent}/${nextLeaf}` : nextLeaf; try { await renameFolderAction(folder, subpath, nextSubpath); } catch (err) { @@ -1728,13 +1888,16 @@ export function Sidebar(): JSX.Element { }, }); items.push({ - label: "Delete folder…", + label: isDb ? "Delete database…" : "Delete folder…", danger: true, onSelect: async () => { + const label = isDb ? formTitleFromDir(leafName) : subpath; const ok = await confirmApp({ - title: `Delete "${subpath}" and everything inside it?`, + title: isDb + ? `Delete the "${label}" database and all its records?` + : `Delete "${label}" and everything inside it?`, description: "This cannot be undone.", - confirmLabel: "Delete folder", + confirmLabel: isDb ? "Delete database" : "Delete folder", danger: true, }); if (!ok) return; @@ -1754,6 +1917,7 @@ export function Sidebar(): JSX.Element { allFolders, vault, createAndOpen, + createDrawingAndOpen, createDatabase, openTemplatePaletteForFolder, openArchiveView, @@ -1771,8 +1935,9 @@ export function Sidebar(): JSX.Element { vaultSettings.folderIcons, vaultSettings, primaryNotesAtRoot, - openFolderIconPicker, - resetFolderIcon, + openIconPicker, + openColorPicker, + toggleFavorite, bulkSelectionMenuItems, selectedSidebarKeys, ]); @@ -1783,7 +1948,13 @@ export function Sidebar(): JSX.Element { { label: "New note", onSelect: async () => { - await createAndOpen("inbox", ""); + await createAndOpen("inbox", "", { focusTitle: true }); + }, + }, + { + label: "New drawing", + onSelect: async () => { + await createDrawingAndOpen("inbox", ""); }, }, { @@ -1817,7 +1988,7 @@ export function Sidebar(): JSX.Element { }, }, ], - [createAndOpen, createDatabase, openTemplatePaletteForFolder, createFolderAction], + [createAndOpen, createDrawingAndOpen, createDatabase, openTemplatePaletteForFolder, createFolderAction], ); const noteMenuItems = useMemo<ContextMenuItem[]>(() => { @@ -1847,6 +2018,13 @@ export function Sidebar(): JSX.Element { }); } if (n.folder !== "trash") { + const isFav = vaultSettings.favorites.includes(n.path); + items.push({ + label: isFav ? "Remove from Favorites" : "Add to Favorites", + onSelect: async () => { + await toggleFavorite(n.path); + }, + }); items.push({ label: "Rename…", onSelect: async () => { @@ -1880,6 +2058,19 @@ export function Sidebar(): JSX.Element { await selectNote(meta.path); }, }); + items.push({ kind: "separator" }); + items.push({ + label: "Change icon…", + onSelect: async () => { + openIconPicker(n.path, n.title); + }, + }); + items.push({ + label: "Change color…", + onSelect: async () => { + openColorPicker(n.path, n.title); + }, + }); } items.push({ label: "Copy as Wikilink", @@ -2007,6 +2198,10 @@ export function Sidebar(): JSX.Element { absolutePathLabel, tabsEnabled, openNoteInTab, + toggleFavorite, + openIconPicker, + openColorPicker, + vaultSettings.favorites, bulkSelectionMenuItems, selectedSidebarKeys, folderLabels.archive, @@ -2510,10 +2705,10 @@ export function Sidebar(): JSX.Element { data-sidebar-idx={vaultHeaderIdx} data-sidebar-type="vault" > - <VaultBadge name={vault?.name ?? "ZenNotes"} size={28} /> + <VaultBadge name={headerVaultName} size={28} /> <div className="min-w-0 flex-1"> <div className="truncate text-sm font-medium text-ink-800"> - {vault?.name ?? "ZenNotes"} + {headerVaultName} </div> {workspaceMode === "remote" && ( <div className="mt-0.5 flex items-center gap-1.5 text-xs text-ink-500"> @@ -2529,10 +2724,10 @@ export function Sidebar(): JSX.Element { </button> ) : ( <div className="flex min-w-0 items-center gap-2"> - <VaultBadge name={vault?.name ?? "ZenNotes"} size={28} /> + <VaultBadge name={headerVaultName} size={28} /> <div className="min-w-0"> <div className="truncate text-sm font-medium text-ink-800"> - {vault?.name ?? "ZenNotes"} + {headerVaultName} </div> {workspaceMode === "remote" && ( <div className="mt-0.5 flex items-center gap-1.5 text-xs text-ink-500"> @@ -2548,8 +2743,11 @@ export function Sidebar(): JSX.Element { )} <div className="flex items-center gap-0.5"> <IconBtn - title="New note (choose folder)" - onClick={() => void createNoteInChosenFolder()} + title="Create… (note, drawing, folder, database)" + onClick={(e) => { + const r = e.currentTarget.getBoundingClientRect(); + setRootMenu({ x: r.left, y: r.bottom + 4 }); + }} > <PlusIcon /> </IconBtn> @@ -2651,6 +2849,29 @@ export function Sidebar(): JSX.Element { </div> </div> + {rootContentHiddenByInboxMode && ( + <div className="mx-3 mt-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5"> + <p className="text-xs font-semibold text-ink-900">Notes at your vault root aren’t shown</p> + <p className="mt-1 text-xs leading-5 text-ink-600"> + This vault opened in <span className="font-medium text-ink-800">Inbox</span> mode, so + top-level files and folders are hidden. Switch to{" "} + <span className="font-medium text-ink-800">Vault root</span> to see them — the + Obsidian-style flat layout. + </p> + <div className="mt-2"> + <Button + variant="primary" + size="sm" + onClick={() => + void setVaultSettings({ ...vaultSettings, primaryNotesLocation: "root" }) + } + > + Switch to Vault root + </Button> + </div> + </div> + )} + {/* Main scrollable tree area */} <div ref={sidebarScrollRef} @@ -2676,6 +2897,82 @@ export function Sidebar(): JSX.Element { } }} > + {favoriteItems.length > 0 && ( + <> + <SidebarSectionHeading label="Favorites" /> + {favoriteItems.map((item) => { + const idx = idxCounter.current.value++; + const vimHighlight = vimCursor === idx; + if (item.kind === "note") { + return ( + <FavoriteRow + key={item.key} + label={item.title || "Untitled"} + icon={ + item.isDrawing ? ( + <ExcalidrawIcon width={13} height={13} /> + ) : ( + <DocumentIcon width={13} height={13} /> + ) + } + active={selectedPath === item.path} + onClick={() => { + setFocusedPanel("editor"); + handleSelectNote(item.path); + }} + onContextMenu={(e) => { + const note = notes.find((n) => n.path === item.path); + if (note) openNoteMenu(e, note); + }} + sidebarIdx={idx} + vimHighlight={vimHighlight} + sidebarFocused={isSidebarFocused} + dataAttrs={{ + "data-sidebar-type": "note", + "data-sidebar-path": item.path, + }} + /> + ); + } + return ( + <FavoriteRow + key={item.key} + label={item.label} + icon={ + resolveFolderIconOption( + item.folder, + item.subpath, + vaultSettings.folderIcons, + ).icon + } + active={isFolderActive(item.folder, item.subpath)} + onClick={() => { + setFocusedPanel("editor"); + setView({ + kind: "folder", + folder: item.folder, + subpath: item.subpath, + }); + }} + onContextMenu={(e) => + openFolderMenu(e, item.folder, item.subpath) + } + sidebarIdx={idx} + vimHighlight={vimHighlight} + sidebarFocused={isSidebarFocused} + dataAttrs={{ + "data-sidebar-type": "folder", + "data-sidebar-folder": item.folder, + "data-sidebar-subpath": item.subpath, + }} + /> + ); + })} + </> + )} + + <SidebarSectionHeading label="Quick access" /> + <TaskSidebarRow active={tasksViewActive} onClick={() => void openTasksView()} @@ -2751,42 +3048,10 @@ export function Sidebar(): JSX.Element { onNoteContextMenu={openNoteMenu} dragPayloadForItem={dragPayloadForItem} onRootContextMenu={(e, subpath) => openFolderMenu(e, "inbox", subpath)} + idxCounter={idxCounter.current} + vimCursor={vimCursor} /> - <div className="mt-1"> - <ArchiveSidebarRow - label={folderLabels.archive} - icon={resolveFolderIconOption("archive", "", vaultSettings.folderIcons).icon} - count={countNotesInTree(trees.archive)} - active={ - archiveViewActive || - (view.kind === "folder" && view.folder === "archive") || - !!selectedPath?.startsWith("archive/") - } - onClick={() => { - void openArchiveView(); - }} - onContextMenu={(e) => openFolderMenu(e, "archive", "")} - sidebarIdx={idxCounter.current.value++} - vimHighlight={vimCursor === idxCounter.current.value - 1} - sidebarFocused={isSidebarFocused} - /> - - <TrashSidebarRow - label={folderLabels.trash} - icon={resolveFolderIconOption("trash", "", vaultSettings.folderIcons).icon} - count={countNotesInTree(trees.trash)} - active={trashViewActive || !!selectedPath?.startsWith("trash/")} - onClick={() => { - void openTrashView(); - }} - onContextMenu={(e) => openFolderMenu(e, "trash", "")} - sidebarIdx={idxCounter.current.value++} - vimHighlight={vimCursor === idxCounter.current.value - 1} - sidebarFocused={isSidebarFocused} - /> - </div> - <SidebarSectionHeading label="Notes" onDropPayload={ @@ -2863,10 +3128,9 @@ export function Sidebar(): JSX.Element { /> )} - {/* Tag pills — pushed to the bottom of the tree area, just above - the footer, via mt-auto (the wrapper is min-h-full flex-col). */} + {/* Tags flow with the content, right after the notes tree. */} {tags.length > 0 && ( - <div className="mt-auto pt-5"> + <div className="pt-4"> <button type="button" onClick={() => setTagsCollapsed(!tagsCollapsed)} @@ -2874,19 +3138,6 @@ export function Sidebar(): JSX.Element { aria-expanded={!tagsCollapsed} className="flex w-full items-center gap-1 rounded px-2 pb-2 text-xs font-medium uppercase tracking-wide text-ink-500 transition-colors hover:text-ink-800" > - {showSidebarChevrons && ( - <span - aria-hidden - className="inline-block transition-transform" - style={{ - transform: tagsCollapsed - ? "rotate(-90deg)" - : "rotate(0deg)", - }} - > - ▾ - </span> - )} <span>Tags</span> <span className="ml-1 text-ink-500 normal-case tracking-normal"> {tags.length} @@ -2916,10 +3167,10 @@ export function Sidebar(): JSX.Element { "rounded-full px-2.5 py-1 text-xs transition-colors", active ? isVimHighlight - ? "vim-cursor-on-active bg-accent text-white" + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" : isSidebarFocused ? "text-accent" - : "bg-accent text-white" + : "bg-paper-300/70 text-ink-900 font-medium" : isVimHighlight ? "vim-cursor" : "bg-paper-200 text-ink-800 hover:bg-paper-300", @@ -2933,7 +3184,7 @@ export function Sidebar(): JSX.Element { className={[ "ml-1 text-2xs", active && !isSidebarFocused - ? "text-white/80" + ? "text-ink-700" : "text-ink-500", ].join(" ")} > @@ -2945,7 +3196,51 @@ export function Sidebar(): JSX.Element { </div> )} </div> - )} + )} + + {/* System (Archive / Trash / Assets) pinned to the bottom. */} + <div className="mt-auto pt-4"> + <SidebarSectionHeading label="System" /> + <SystemRow + icon={resolveFolderIconOption("archive", "", vaultSettings.folderIcons).icon} + label={folderLabels.archive} + count={countNotesInTree(trees.archive)} + active={ + archiveViewActive || + (view.kind === "folder" && view.folder === "archive") || + !!selectedPath?.startsWith("archive/") + } + onClick={() => void openArchiveView()} + onContextMenu={(e) => openFolderMenu(e, "archive", "")} + sidebarIdx={idxCounter.current.value++} + vimHighlight={vimCursor === idxCounter.current.value - 1} + sidebarFocused={isSidebarFocused} + sidebarType="archive" + /> + <SystemRow + icon={resolveFolderIconOption("trash", "", vaultSettings.folderIcons).icon} + label={folderLabels.trash} + count={countNotesInTree(trees.trash)} + active={trashViewActive || !!selectedPath?.startsWith("trash/")} + onClick={() => void openTrashView()} + onContextMenu={(e) => openFolderMenu(e, "trash", "")} + sidebarIdx={idxCounter.current.value++} + vimHighlight={vimCursor === idxCounter.current.value - 1} + sidebarFocused={isSidebarFocused} + sidebarType="trash" + /> + <SystemRow + icon={<PaperclipIcon width={16} height={16} />} + label="Assets" + count={assetCount} + active={assetsViewActive} + onClick={() => void openAssetsView()} + sidebarIdx={idxCounter.current.value++} + vimHighlight={vimCursor === idxCounter.current.value - 1} + sidebarFocused={isSidebarFocused} + sidebarType="assets" + /> + </div> </div> </div> @@ -2966,7 +3261,7 @@ export function Sidebar(): JSX.Element { sidebarIdx={idxCounter.current.value++} vimHighlight={vimCursor === idxCounter.current.value - 1} sidebarFocused={isSidebarFocused} - sidebarData={{ type: "assets" }} + sidebarData={{ type: "files" }} /> )} {(!hasAssetsDir || !canRevealInFileManager) && <div />} @@ -3041,22 +3336,22 @@ export function Sidebar(): JSX.Element { onClose={() => setAssetMenu(null)} /> )} - {folderIconPicker && ( + {iconPicker && ( <FolderIconPickerModal - targetLabel={folderIconPicker.label} - currentIconId={resolveFolderIconId( - folderIconPicker.folder, - folderIconPicker.subpath, - vaultSettings.folderIcons, - )} - onSelect={(iconId) => - void saveFolderIcon( - folderIconPicker.folder, - folderIconPicker.subpath, - iconId, - ) - } - onCancel={() => setFolderIconPicker(null)} + targetLabel={iconPicker.label} + currentIconId={vaultSettings.folderIcons[iconPicker.key] ?? null} + onSelect={(iconId) => void saveIcon(iconPicker.key, iconId)} + onReset={() => void resetIcon(iconPicker.key)} + onCancel={() => setIconPicker(null)} + /> + )} + {colorPicker && ( + <FolderColorPickerModal + targetLabel={colorPicker.label} + currentColorId={vaultSettings.folderColors[colorPicker.key] ?? null} + onSelect={(colorId) => void saveColor(colorPicker.key, colorId)} + onReset={() => void resetColor(colorPicker.key)} + onCancel={() => setColorPicker(null)} /> )} {sortMenu && ( @@ -3287,19 +3582,52 @@ function buildTree( return root; } +function addSubpathAndAncestors(target: Set<string>, subpath: string): void { + const parts = subpath.split("/").filter(Boolean); + let acc = ""; + for (const part of parts) { + acc = acc ? `${acc}/${part}` : part; + target.add(acc); + } +} + +function pruneEmptyDateNoteFolders( + children: TreeNode[], + dateFolderSubpaths: Set<string>, +): TreeNode[] { + return children + .map((child) => ({ + ...child, + children: pruneEmptyDateNoteFolders(child.children, dateFolderSubpaths), + })) + .filter((child) => { + if (!dateFolderSubpaths.has(child.subpath)) return true; + return child.notes.length > 0 || child.assets.length > 0 || child.children.length > 0; + }); +} + +// Folders sort by name, numeric-aware so "2 Foo" comes before "10 Foo" — the +// way users order them with leading numbers/letters. Applied at render time so +// new folders land in place immediately, regardless of on-disk read order. (#168) +function compareFolderNodes(a: TreeNode, b: TreeNode): number { + return naturalCompare(a.name, b.name); +} + function getTreeRenderEntries( node: TreeNode, showNotes: boolean, sortComparator: ((a: NoteMeta, b: NoteMeta) => number) | null, groupByKind: boolean, ): TreeRenderEntry[] { + const sortedChildren = node.children.slice().sort(compareFolderNodes); + if (!showNotes) { - return node.children.map((child) => ({ type: "folder", node: child })); + return sortedChildren.map((child) => ({ type: "folder", node: child })); } if (sortComparator || groupByKind) { return [ - ...node.children.map( + ...sortedChildren.map( (child) => ({ type: "folder", node: child }) as const, ), ...node.notes @@ -3712,6 +4040,13 @@ function SubTree({ node.subpath, vaultSettings.folderIcons, ); + const folderColorClass = + resolveFolderColorGlyphClass(folder, node.subpath, vaultSettings.folderColors) ?? + undefined; + // A `<Name>.base` folder is a database: render it with a database icon and a + // title without the suffix; clicking the row opens the grid, while the + // chevron still expands to reveal its record-page notes. (#185) + const isDatabase = isFormDirName(node.name); const entries = useMemo( () => getTreeRenderEntries(node, showNotes, sortComparator, groupByKind), [node, showNotes, sortComparator, groupByKind], @@ -3747,6 +4082,15 @@ function SubTree({ const handleSelect = ( e: React.MouseEvent | React.KeyboardEvent, ): void => { + if (isDatabase) { + const csvPath = csvPathForFormDir( + vaultRelativeFolderPath(folder, node.subpath, vaultSettings), + ); + onSelectItem(e, { kind: "folder", folder, subpath: node.subpath }, () => { + void useStore.getState().openDatabase(csvPath); + }); + return; + } onSelectItem(e, { kind: "folder", folder, subpath: node.subpath }, () => { setView({ kind: "folder", folder, subpath: node.subpath }); if (hasChildren) { @@ -3759,8 +4103,19 @@ function SubTree({ return ( <div className="flex flex-col"> <TreeRow - icon={iconOption.id === "folder" ? <FolderGlyphIcon open={!isCollapsed && hasChildren} /> : iconOption.icon} - label={node.name} + icon={ + // A database shows its DB glyph unless a custom icon was chosen for it + // (iconOption.id !== "folder" means the user picked one). + isDatabase && iconOption.id === "folder" ? ( + <DatabaseIcon /> + ) : iconOption.id === "folder" ? ( + <FolderGlyphIcon open={!isCollapsed && hasChildren} /> + ) : ( + iconOption.icon + ) + } + glyphColorClass={folderColorClass} + label={isDatabase ? formTitleFromDir(node.name) : node.name} isSymlink={node.isSymlink} count={countNotesInTree(node)} active={isFolderActive(folder, node.subpath)} @@ -3914,7 +4269,6 @@ interface NoteLeafProps { const NoteLeaf = memo(function NoteLeaf({ note, depth, - showSidebarChevrons, active, selected, sidebarFocused, @@ -3952,6 +4306,12 @@ const NoteLeaf = memo(function NoteLeaf({ }, [dragPayloadForItem, note.path], ); + // Custom icon / color set via the note's right-click menu (keyed by path). + // Read directly from the store so the row updates when they change — the + // selector returns a primitive, so it only re-renders for this note. + const customIconId = useStore((s) => s.vaultSettings.folderIcons[note.path]); + const customColorId = useStore((s) => s.vaultSettings.folderColors[note.path]); + const colorClass = colorGlyphClassById(customColorId); return ( <button @@ -3961,13 +4321,15 @@ const NoteLeaf = memo(function NoteLeaf({ draggable onDragStart={handleDragStart} className={[ - "group flex h-8 w-full items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", + "group flex h-9 w-full items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", active - ? vimHighlight - ? "vim-cursor-on-active bg-accent text-white" - : sidebarFocused - ? "text-accent" - : "bg-accent text-white" + ? colorClass + ? `bg-accent/20 ring-1 ring-inset ring-accent/60${vimHighlight ? " vim-cursor-on-active" : ""}` + : vimHighlight + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" + : sidebarFocused + ? "text-accent" + : "bg-paper-300/70 text-ink-900 font-medium" : selected ? "bg-accent/[0.09] text-ink-900" : vimHighlight @@ -3984,23 +4346,34 @@ const NoteLeaf = memo(function NoteLeaf({ } : {})} > - {showSidebarChevrons && <span className="h-5 w-5 shrink-0" />} - <SidebarGlyph active={strongActive} rowActive={active || selected}> - <svg - width="14" - height="14" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="1.75" - strokeLinecap="round" - strokeLinejoin="round" - > - <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9Z" /> - <path d="M14 3v6h6" /> - </svg> + <SidebarGlyph + active={strongActive} + rowActive={active || selected} + colorClass={colorClass ?? undefined} + > + {customIconId ? ( + iconOptionById(customIconId).icon + ) : isExcalidrawPath(note.path) ? ( + <ExcalidrawIcon width={14} height={14} /> + ) : ( + <svg + width="14" + height="14" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="1.75" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9Z" /> + <path d="M14 3v6h6" /> + </svg> + )} </SidebarGlyph> - <span className="flex-1 truncate">{note.title}</span> + <span className={["flex-1 truncate", colorClass].filter(Boolean).join(" ")}> + {note.title} + </span> {note.isSymlink && ( <span aria-label="Symlinked note" @@ -4010,7 +4383,7 @@ const NoteLeaf = memo(function NoteLeaf({ active ? sidebarFocused && !vimHighlight ? "text-accent/70" - : "text-white/70" + : "text-ink-600" : selected ? "text-accent/75" : "text-ink-500", @@ -4040,7 +4413,7 @@ const NoteLeaf = memo(function NoteLeaf({ active ? sidebarFocused && !vimHighlight ? "text-accent/70" - : "text-white/70" + : "text-ink-600" : selected ? "text-accent/75" : "text-ink-500", @@ -4091,7 +4464,6 @@ function AssetLeaf({ asset, vaultRoot, depth, - showSidebarChevrons, onOpen, onContextMenu, sidebarFocused, @@ -4127,7 +4499,7 @@ function AssetLeaf({ draggable onDragStart={handleDragStart} className={[ - "group flex h-8 w-full items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", + "group flex h-9 w-full items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", vimHighlight ? "vim-cursor" : "text-ink-700 hover:bg-paper-200/70", ].join(" ")} style={{ paddingLeft: 4 + depth * 14 }} @@ -4139,7 +4511,6 @@ function AssetLeaf({ } : {})} > - {showSidebarChevrons && <span className="h-5 w-5 shrink-0" />} <SidebarGlyph active={false} rowActive={false}> <svg width="14" @@ -4197,8 +4568,8 @@ function TreeRow({ selectionKey, trailing, isSymlink = false, - reserveLeadingSlot = true, showExpandChevron = true, + glyphColorClass, }: { icon: JSX.Element; label: string; @@ -4226,10 +4597,13 @@ function TreeRow({ trailing?: JSX.Element; /** Show a symlink indicator when this row's directory entry is a link. */ isSymlink?: boolean; - /** Keep a blank chevron column for non-expandable rows when alignment matters. */ + /** Obsolete (no leading chevron gutter anymore); accepted for compatibility. */ reserveLeadingSlot?: boolean; - /** Hide the chevron affordance while keeping row-click toggle behavior. */ + /** Reveal the hover disclosure chevron on the folder icon. When false the + * folder shows only its icon and toggles via row-click. */ showExpandChevron?: boolean; + /** Custom resting tint for the leading glyph (folder color). */ + glyphColorClass?: string; }): JSX.Element { const strongActive = active && (!sidebarFocused || !!vimHighlight); @@ -4251,13 +4625,18 @@ function TreeRow({ onDragLeave={onDragLeave} onDrop={onDrop} className={[ - "group flex h-8 w-full items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", + "group flex h-9 w-full items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", active - ? vimHighlight - ? "vim-cursor-on-active bg-accent text-white" - : sidebarFocused - ? "text-accent" - : "bg-accent text-white" + ? glyphColorClass + ? // Colored folder: a saturated accent fill would put same-hue text on + // top (orange-on-orange). Use a faint tint + ring so the folder color + // stays readable while still reading as the active row. + `bg-accent/20 ring-1 ring-inset ring-accent/60${vimHighlight ? " vim-cursor-on-active" : ""}` + : vimHighlight + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" + : sidebarFocused + ? "text-accent" + : "bg-paper-300/70 text-ink-900 font-medium" : dropTarget ? "bg-accent/20 text-ink-900 ring-1 ring-accent/60" : selected @@ -4281,48 +4660,68 @@ function TreeRow({ style={{ paddingLeft: 4 + depth * 14 }} > {expandable && showExpandChevron ? ( - <button - onClick={(e) => { - e.stopPropagation(); - onToggle(); - }} + // Notion-style disclosure: the folder icon turns into a chevron while the + // row is hovered — click it to expand/collapse. No separate chevron gutter. + <span + className="relative flex h-5 w-5 shrink-0 items-center justify-center" data-vim-hint-ignore - className={[ - "flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors", - strongActive - ? "text-white/80 hover:bg-white/15" - : "text-ink-500 hover:bg-paper-300/60", - ].join(" ")} - aria-label={collapsed ? "Expand" : "Collapse"} > - <svg - width="10" - height="10" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="3" - strokeLinecap="round" - strokeLinejoin="round" - className={`transition-transform ${collapsed ? "" : "rotate-90"}`} + <span className="flex h-5 w-5 items-center justify-center group-hover:hidden"> + <SidebarGlyph + active={strongActive} + rowActive={active || selected} + colorClass={glyphColorClass} + > + {icon} + </SidebarGlyph> + </span> + <button + onClick={(e) => { + e.stopPropagation(); + onToggle(); + }} + aria-label={collapsed ? "Expand" : "Collapse"} + className={[ + "absolute inset-0 hidden items-center justify-center rounded transition-colors group-hover:flex", + strongActive ? "text-ink-900" : "text-ink-500 hover:text-ink-900", + ].join(" ")} > - <path d="m9 6 6 6-6 6" /> - </svg> - </button> - ) : reserveLeadingSlot ? ( - <span className="h-5 w-5 shrink-0" /> - ) : null} - <SidebarGlyph active={strongActive} rowActive={active || selected}> - {icon} - </SidebarGlyph> - <span className="flex-1 truncate">{label}</span> + <svg + width="11" + height="11" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="3" + strokeLinecap="round" + strokeLinejoin="round" + className={`transition-transform ${collapsed ? "" : "rotate-90"}`} + > + <path d="m9 6 6 6-6 6" /> + </svg> + </button> + </span> + ) : ( + <SidebarGlyph + active={strongActive} + rowActive={active || selected} + colorClass={glyphColorClass} + > + {icon} + </SidebarGlyph> + )} + <span + className={["flex-1 truncate", glyphColorClass].filter(Boolean).join(" ")} + > + {label} + </span> {isSymlink && ( <span aria-label="Symlinked folder" title="Symlinked into this vault" className={[ "shrink-0", - strongActive ? "text-white/70" : selected ? "text-accent/75" : "text-ink-500", + strongActive ? "text-ink-600" : selected ? "text-accent/75" : "text-ink-500", ].join(" ")} > <svg @@ -4352,7 +4751,7 @@ function TreeRow({ <span className={[ "shrink-0 pr-2 text-xs", - strongActive ? "text-white/80" : selected ? "text-accent/75" : "text-ink-500", + strongActive ? "text-ink-700" : selected ? "text-accent/75" : "text-ink-500", ].join(" ")} > {count} @@ -4390,13 +4789,13 @@ function TaskSidebarRow({ } }} className={[ - "group flex h-8 items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", + "group flex h-9 items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", active ? vimHighlight - ? "vim-cursor-on-active bg-accent text-white" + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" : sidebarFocused ? "text-accent" - : "bg-accent text-white" + : "bg-paper-300/70 text-ink-900 font-medium" : vimHighlight ? "vim-cursor" : "text-ink-800 hover:bg-paper-200/70", @@ -4417,29 +4816,31 @@ function TaskSidebarRow({ ); } -function ArchiveSidebarRow({ +// A single favorited note/folder row. Sets the same data-sidebar-* attributes +// as a regular note/folder row so the shared Vim activation (Enter) and the +// `m` context-menu key work without special-casing. +function FavoriteRow({ label, icon, - count, active, onClick, onContextMenu, sidebarIdx, vimHighlight, sidebarFocused = false, + dataAttrs, }: { label: string; icon: JSX.Element; - count: number; active: boolean; onClick: () => void; onContextMenu?: (e: React.MouseEvent) => void; sidebarIdx?: number; vimHighlight?: boolean; sidebarFocused?: boolean; + dataAttrs: Record<string, string | number>; }): JSX.Element { const strongActive = active && (!sidebarFocused || !!vimHighlight); - return ( <div role="button" @@ -4453,49 +4854,36 @@ function ArchiveSidebarRow({ }} onContextMenu={onContextMenu} className={[ - "group select-none flex h-8 items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", + "group select-none flex h-9 items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", active ? vimHighlight - ? "vim-cursor-on-active bg-accent text-white" + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" : sidebarFocused ? "text-accent" - : "bg-accent text-white" + : "bg-paper-300/70 text-ink-900 font-medium" : vimHighlight ? "vim-cursor" : "text-ink-800 hover:bg-paper-200/70", ].join(" ")} style={{ paddingLeft: 4 }} - {...(sidebarIdx != null - ? { - "data-sidebar-idx": sidebarIdx, - "data-sidebar-type": "archive", - } - : { - "data-sidebar-type": "archive", - })} + {...(sidebarIdx != null ? { "data-sidebar-idx": sidebarIdx } : {})} + {...dataAttrs} > <SidebarGlyph active={strongActive} rowActive={active}> {icon} </SidebarGlyph> <span className="flex-1 truncate">{label}</span> {sidebarFocused && vimHighlight && ( - <RowKeyHint active={active} keyLabel="m" compact={count > 0} /> - )} - {count > 0 && ( - <span - className={[ - "shrink-0 pr-2 text-xs", - strongActive ? "text-white/80" : "text-ink-500", - ].join(" ")} - > - {count} - </span> + <RowKeyHint active={active} keyLabel="m" compact /> )} </div> ); } -function TrashSidebarRow({ +// A System row (Archive / Trash / Assets): a full-width vertical row with a +// leading glyph, label, and trailing count. Vim-navigable and activatable via +// its data-sidebar-type. +function SystemRow({ label, icon, count, @@ -4505,6 +4893,7 @@ function TrashSidebarRow({ sidebarIdx, vimHighlight, sidebarFocused = false, + sidebarType, }: { label: string; icon: JSX.Element; @@ -4515,6 +4904,7 @@ function TrashSidebarRow({ sidebarIdx?: number; vimHighlight?: boolean; sidebarFocused?: boolean; + sidebarType: string; }): JSX.Element { const strongActive = active && (!sidebarFocused || !!vimHighlight); @@ -4531,26 +4921,21 @@ function TrashSidebarRow({ }} onContextMenu={onContextMenu} className={[ - "group select-none flex h-8 items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", + "group select-none flex h-9 items-center gap-1.5 rounded-lg px-1 text-left text-sm outline-none transition-colors focus:outline-none", active ? vimHighlight - ? "vim-cursor-on-active bg-accent text-white" + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" : sidebarFocused ? "text-accent" - : "bg-accent text-white" + : "bg-paper-300/70 text-ink-900 font-medium" : vimHighlight ? "vim-cursor" : "text-ink-800 hover:bg-paper-200/70", ].join(" ")} style={{ paddingLeft: 4 }} {...(sidebarIdx != null - ? { - "data-sidebar-idx": sidebarIdx, - "data-sidebar-type": "trash", - } - : { - "data-sidebar-type": "trash", - })} + ? { "data-sidebar-idx": sidebarIdx, "data-sidebar-type": sidebarType } + : { "data-sidebar-type": sidebarType })} > <SidebarGlyph active={strongActive} rowActive={active}> {icon} @@ -4563,7 +4948,7 @@ function TrashSidebarRow({ <span className={[ "shrink-0 pr-2 text-xs", - strongActive ? "text-white/80" : "text-ink-500", + strongActive ? "text-ink-700" : "text-ink-500", ].join(" ")} > {count} @@ -4602,13 +4987,13 @@ function SidebarRow({ <button onClick={onClick} className={[ - "group flex h-8 items-center gap-2 rounded-lg px-2 text-sm outline-none transition-colors focus:outline-none", + "group flex h-9 items-center gap-2 rounded-lg px-2 text-sm outline-none transition-colors focus:outline-none", active ? vimHighlight - ? "vim-cursor-on-active bg-accent text-white" + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" : sidebarFocused ? "text-accent" - : "bg-accent text-white" + : "bg-paper-300/70 text-ink-900 font-medium" : vimHighlight ? "vim-cursor" : "text-ink-800 hover:bg-paper-200/70", @@ -4622,7 +5007,7 @@ function SidebarRow({ > <span className={ - strongActive ? "text-white" : "text-ink-500 group-hover:text-ink-800" + strongActive ? "text-ink-900" : "text-ink-500 group-hover:text-ink-800" } > {icon} @@ -4639,7 +5024,7 @@ function SidebarRow({ <span className={[ "text-xs", - strongActive ? "text-white/80" : "text-ink-500", + strongActive ? "text-ink-700" : "text-ink-500", ].join(" ")} > {count} @@ -4690,10 +5075,10 @@ function SidebarFooterAction({ "inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium leading-none transition-colors whitespace-nowrap", active ? vimHighlight - ? "vim-cursor-on-active bg-accent text-white" + ? "vim-cursor-on-active bg-paper-300/70 text-ink-900 font-medium" : sidebarFocused ? "text-accent" - : "bg-accent text-white" + : "bg-paper-300/70 text-ink-900 font-medium" : vimHighlight ? "vim-cursor" : "text-ink-500 hover:bg-paper-200/70 hover:text-ink-900", @@ -4706,7 +5091,7 @@ function SidebarFooterAction({ : {})} > <span - className={["shrink-0", strongActive ? "text-white" : ""].join(" ")} + className={["shrink-0", strongActive ? "text-ink-900" : ""].join(" ")} > {icon} </span> @@ -4716,7 +5101,7 @@ function SidebarFooterAction({ className={[ "rounded-full px-1.5 py-0.5 text-2xs", strongActive - ? "bg-white/12 text-white/80" + ? "bg-ink-900/10 text-ink-700" : "bg-paper-200/80 text-ink-500", ].join(" ")} > @@ -4728,7 +5113,7 @@ function SidebarFooterAction({ className={[ "rounded-full px-1.5 py-0.5 text-2xs font-semibold", strongActive - ? "bg-white/16 text-white" + ? "bg-accent/20 text-accent" : "bg-accent/12 text-accent", ].join(" ")} > @@ -4754,7 +5139,6 @@ function IconBtn({ <button type="button" onClick={onClick} - title={title} aria-label={title} className={[ "group relative flex h-7 w-7 items-center justify-center rounded-md transition-colors", @@ -4806,6 +5190,8 @@ function DateNotesNav({ onNoteContextMenu, dragPayloadForItem, onRootContextMenu, + idxCounter, + vimCursor, }: { dateNav: DateNavData; expanded: Set<string>; @@ -4826,6 +5212,8 @@ function DateNotesNav({ onNoteContextMenu: (e: React.MouseEvent, note: NoteMeta) => void; dragPayloadForItem: (item: SidebarSelectionItem) => DragPayload; onRootContextMenu?: (e: React.MouseEvent, subpath: string) => void; + idxCounter: { value: number }; + vimCursor: number; }): JSX.Element | null { const rows: JSX.Element[] = []; const monthName = (year: number, month: number): string => @@ -4839,39 +5227,58 @@ function DateNotesNav({ icon: JSX.Element, active: boolean, chevron: boolean, + // The date directory this group belongs to, so Vim Enter navigates there. + sidebarSubpath: string, onContextMenu?: (e: React.MouseEvent) => void, - ): JSX.Element => ( - <TreeRow - key={key} - icon={icon} - label={label} - count={count} - active={active} - expandable - collapsed={!expanded.has(key)} - depth={depth} - onToggle={() => onToggle(key)} - onSelect={onSelect} - onContextMenu={onContextMenu} - reserveLeadingSlot={chevron} - showExpandChevron={chevron} - /> - ); - const leaf = (note: NoteMeta, depth: number): JSX.Element => ( - <NoteLeaf - key={note.path} - note={note} - depth={depth} - showSidebarChevrons={showSidebarChevrons} - active={note.path === selectedPath} - selected={selectedKeys.has(noteSelectionKey(note.path))} - sidebarFocused={sidebarFocused} - onSelectItem={onSelectItem} - onSelectNote={onSelectNote} - onContextMenuNote={onNoteContextMenu} - dragPayloadForItem={dragPayloadForItem} - /> - ); + ): JSX.Element => { + const idx = idxCounter.value++; + return ( + <TreeRow + key={key} + icon={icon} + label={label} + count={count} + active={active} + expandable + collapsed={!expanded.has(key)} + depth={depth} + onToggle={() => onToggle(key)} + onSelect={onSelect} + onContextMenu={onContextMenu} + reserveLeadingSlot={chevron} + showExpandChevron={chevron} + sidebarIdx={idx} + vimHighlight={vimCursor === idx} + sidebarFocused={sidebarFocused} + sidebarData={{ + type: "folder", + folder: "inbox", + subpath: sidebarSubpath, + key: "", + }} + /> + ); + }; + const leaf = (note: NoteMeta, depth: number): JSX.Element => { + const idx = idxCounter.value++; + return ( + <NoteLeaf + key={note.path} + note={note} + depth={depth} + showSidebarChevrons={showSidebarChevrons} + active={note.path === selectedPath} + selected={selectedKeys.has(noteSelectionKey(note.path))} + sidebarFocused={sidebarFocused} + onSelectItem={onSelectItem} + onSelectNote={onSelectNote} + onContextMenuNote={onNoteContextMenu} + dragPayloadForItem={dragPayloadForItem} + sidebarIdx={idx} + vimHighlight={vimCursor === idx} + /> + ); + }; if (dateNav.dailyEnabled && dateNav.dailyTotal > 0) { rows.push( @@ -4884,6 +5291,7 @@ function DateNotesNav({ dailyIcon, isFolderActive("inbox", dateNav.dailyDir), false, + dateNav.dailyDir, onRootContextMenu ? (e) => onRootContextMenu(e, dateNav.dailyDir) : undefined, ), ); @@ -4900,6 +5308,7 @@ function DateNotesNav({ <FolderGlyphIcon open={expanded.has(yKey)} />, false, showSidebarChevrons, + dateNav.dailyDir, ), ); if (expanded.has(yKey)) { @@ -4915,6 +5324,7 @@ function DateNotesNav({ <FolderGlyphIcon open={expanded.has(mKey)} />, false, showSidebarChevrons, + dateNav.dailyDir, ), ); if (expanded.has(mKey)) for (const n of mg.notes) rows.push(leaf(n, 3)); @@ -4935,6 +5345,7 @@ function DateNotesNav({ weeklyIcon, isFolderActive("inbox", dateNav.weeklyDir), false, + dateNav.weeklyDir, onRootContextMenu ? (e) => onRootContextMenu(e, dateNav.weeklyDir) : undefined, ), ); @@ -4951,6 +5362,7 @@ function DateNotesNav({ <FolderGlyphIcon open={expanded.has(yKey)} />, false, showSidebarChevrons, + dateNav.weeklyDir, ), ); if (expanded.has(yKey)) for (const n of yg.notes) rows.push(leaf(n, 2)); @@ -4959,7 +5371,7 @@ function DateNotesNav({ } if (rows.length === 0) return null; - return <div className="mt-1 flex flex-col">{rows}</div>; + return <div className="flex flex-col">{rows}</div>; } function RowKeyHint({ @@ -4978,7 +5390,7 @@ function RowKeyHint({ className={[ "pointer-events-none shrink-0 rounded-md border px-1.5 py-0.5 text-2xs leading-none", active - ? "border-white/25 bg-white/12 text-white/80" + ? "border-ink-900/20 bg-ink-900/10 text-ink-700" : "border-paper-300/70 bg-paper-100/75 text-ink-500", ].join(" ")} > diff --git a/packages/app-core/src/components/TagView.tsx b/packages/app-core/src/components/TagView.tsx index e908e8d8..2397f853 100644 --- a/packages/app-core/src/components/TagView.tsx +++ b/packages/app-core/src/components/TagView.tsx @@ -5,6 +5,8 @@ import { extractTags } from '../lib/tags' import { TagIcon, CloseIcon, DocumentIcon } from './icons' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { isPrimaryNotesAtRoot, noteFolderSubpath } from '../lib/vault-layout' +import { isImeComposing } from '../lib/ime' +import { isAppOverlayOpen } from '../lib/overlay-open' function formatDate(ms: number): string { const d = new Date(ms) @@ -34,6 +36,7 @@ export function TagView(): JSX.Element { const closeTagView = useStore((s) => s.closeTagView) const selectNote = useStore((s) => s.selectNote) const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) const amActive = useStore(isTagsViewActive) const [filter, setFilter] = useState('') @@ -190,6 +193,12 @@ export function TagView(): JSX.Element { useEffect(() => { if (!amActive) return const handler = (e: KeyboardEvent): void => { + // A modal/menu owns the keyboard while open — don't fire list shortcuts + // through it. (songgenqing report) + if (isAppOverlayOpen()) return + // While the Vim hint overlay is open it owns the keyboard; don't let + // tag navigation (or Esc closing the view) steal its keys. (#151) + if (document.querySelector('[data-vim-hint-overlay]')) return const focused = document.activeElement as HTMLElement | null if (focused) { const t = focused.tagName @@ -199,50 +208,53 @@ export function TagView(): JSX.Element { const k = e.key const overrides = keymapOverrides + // When Vim mode is off, the single-key Vim shortcuts (j/k/gg/G/o//…) are + // disabled — only arrows/Enter/Escape navigate. (songgenqing report) + const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => + vimMode && matchesSequenceToken(e, overrides, id) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() } if (k === 'Escape') { - if (filter) { - consume() - setFilter('') - return - } + // Tags is a tab like a note tab — Esc clears an active filter but must + // never close the tab (other tabs don't close on Esc). Close with :q, + // the header ✕, or ⌘W. (#151) consume() - closeTagView() + if (filter) setFilter('') return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (seq('nav.filter')) { consume() filterRef.current?.focus() filterRef.current?.select() return } - if (matchesSequenceToken(e, overrides, 'nav.localEx')) { + if (seq('nav.localEx')) { consume() setExValue('') setExOpen(true) requestAnimationFrame(() => exRef.current?.focus()) return } - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || k === 'ArrowDown') { + if (seq('nav.moveDown') || k === 'ArrowDown') { consume() moveCursor(1) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || k === 'ArrowUp') { + if (seq('nav.moveUp') || k === 'ArrowUp') { consume() moveCursor(-1) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (seq('nav.jumpBottom')) { consume() setCursorIndex(filtered.length - 1) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -255,7 +267,7 @@ export function TagView(): JSX.Element { ) { return } - if ((k === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openResult')) && current) { + if ((k === 'Enter' || seq('nav.openResult')) && current) { consume() void openCurrent() } @@ -269,6 +281,7 @@ export function TagView(): JSX.Element { filtered.length, current, keymapOverrides, + vimMode, openCurrent, closeTagView ]) @@ -297,7 +310,7 @@ export function TagView(): JSX.Element { if (filter) setFilter('') else e.currentTarget.blur() } - if (e.key === 'Enter') e.currentTarget.blur() + if (e.key === 'Enter' && !isImeComposing(e)) e.currentTarget.blur() }} className="w-56 rounded-md border border-current/15 bg-current/5 px-2 py-1 text-xs outline-none focus:border-current/30" /> @@ -453,7 +466,7 @@ export function TagView(): JSX.Element { </form> ) : ( <div className="border-t border-current/10 px-4 py-1.5 text-xs text-current/40"> - j/k move · Enter/o open · click chips to toggle · / filter · : command · Esc close + j/k move · Enter/o open · click chips to toggle · / filter · : command · :q close </div> )} </div> diff --git a/packages/app-core/src/components/TasksCalendar.tsx b/packages/app-core/src/components/TasksCalendar.tsx index 2fc06fc5..968ab9b3 100644 --- a/packages/app-core/src/components/TasksCalendar.tsx +++ b/packages/app-core/src/components/TasksCalendar.tsx @@ -23,12 +23,22 @@ import { import { useStore } from '../store' import { ChevronLeftIcon, ChevronRightIcon } from './icons' import { InlineMarkdown } from '../lib/inline-markdown' +import { ContextMenu, type ContextMenuItem } from './ContextMenu' interface Props { tasks: VaultTask[] today: Date onOpenTask: (task: VaultTask) => void onToggleTask: (task: VaultTask) => void + /** Set a task's due date (keyboard reschedule + the drop "set due" choice). */ + onRescheduleTask: (task: VaultTask, dueIso: string) => void + /** Physically move a task into the daily note for the given date. */ + onMoveTask: (task: VaultTask, dateIso: string) => void + /** Add a `- [ ] …` task to the daily note for the given date. */ + onAddTask: (dateIso: string, text: string) => void | Promise<void> + /** Whether daily notes are enabled — gates the quick-add box, which writes + * into the selected day's daily note. */ + dailyNotesEnabled: boolean } /** Sunday-first; matches how most US/Western calendars read. The month @@ -55,6 +65,14 @@ function parseIsoLocal(iso: string): Date { return new Date(y, m - 1, dd) } +const TASK_PREFIX_RE = /^\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[[ xX]\]\s?/ + +/** Text after the checkbox (content + any tokens) — prefill for inline edit. */ +function taskTail(task: VaultTask): string { + const m = task.rawText.match(TASK_PREFIX_RE) + return m ? task.rawText.slice(m[0].length) : task.content +} + function buildMonthGrid(anchor: Date): Date[] { const first = firstOfMonth(anchor) // Walk back to the most recent Sunday (could be in the previous month). @@ -66,12 +84,53 @@ function formatMonthLabel(d: Date): string { return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) } -export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props): JSX.Element { +export function TasksCalendar({ + tasks, + today, + onOpenTask, + onToggleTask, + onRescheduleTask, + onMoveTask, + onAddTask, + dailyNotesEnabled +}: Props): JSX.Element { const monthAnchorIso = useStore((s) => s.tasksCalendarMonthAnchor) const setMonthAnchor = useStore((s) => s.setTasksCalendarMonthAnchor) const selectedDateIso = useStore((s) => s.tasksCalendarSelectedDate) const setSelectedDate = useStore((s) => s.setTasksCalendarSelectedDate) + const applyTaskMutation = useStore((s) => s.applyTaskMutation) + const deleteTaskFromList = useStore((s) => s.deleteTaskFromList) const rootRef = useRef<HTMLDivElement>(null) + // Pointer-based drag-to-reschedule. Native HTML5 DnD is flaky for these rows + // inside the pane, so (like the Kanban) we grab on pointerdown, float a ghost + // while moving, detect the day under the cursor via a `data-cal-day` attribute, + // and offer the move / set-due choice on release. + const pointerDragRef = useRef<{ + task: VaultTask + startX: number + startY: number + dragging: boolean + } | null>(null) + const suppressClickRef = useRef(false) + const [ghost, setGhost] = useState<{ x: number; y: number; label: string } | null>(null) + const [dragOverIso, setDragOverIso] = useState<string | null>(null) + // Shared context menu — the drop "move vs set due" choice and the right-click + // task actions both feed it. + const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) + // Inline task editing (right-click → Edit). + const [editingTaskId, setEditingTaskId] = useState<string | null>(null) + const [editValue, setEditValue] = useState('') + const cancelEditRef = useRef(false) + // Keyboard reschedule: which task in the selected day's list is "active". + const [activeTaskIndex, setActiveTaskIndex] = useState(0) + // Keyboard "grab & place": the task picked up with `m`. While set, grid + // navigation chooses a target day; Enter places it (move/set-due choice). + const [grabbedTask, setGrabbedTask] = useState<VaultTask | null>(null) + const dPending = useRef(false) + const dTimer = useRef<ReturnType<typeof setTimeout>>() + const addInputRef = useRef<HTMLInputElement>(null) + // Quick-add box for the selected day. + const [addValue, setAddValue] = useState('') const todayIso = useMemo(() => toIsoDateLocal(today), [today]) @@ -98,6 +157,164 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) const unscheduled = buckets.get('unscheduled') ?? [] const selectedIso = toIsoDateLocal(selectedDate) const selectedTasks = buckets.get(selectedIso) ?? [] + const activeIdx = Math.min(activeTaskIndex, Math.max(0, selectedTasks.length - 1)) + const addLabel = selectedDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + + const addDaysIso = (n: number): string => + toIsoDateLocal(new Date(today.getFullYear(), today.getMonth(), today.getDate() + n)) + + const startPointerDrag = (task: VaultTask, e: React.PointerEvent): void => { + if (e.button !== 0) return + suppressClickRef.current = false + pointerDragRef.current = { task, startX: e.clientX, startY: e.clientY, dragging: false } + } + + // Keep the latest reschedule/move callbacks in a ref so the window listeners + // can mount once (the parent passes fresh arrows each render). + const dropCbRef = useRef({ onMoveTask, onRescheduleTask }) + dropCbRef.current = { onMoveTask, onRescheduleTask } + + useEffect(() => { + const dayUnder = (x: number, y: number): string | null => { + const el = document.elementFromPoint(x, y) + const dayEl = el?.closest('[data-cal-day]') as HTMLElement | null + return dayEl?.dataset.calDay ?? null + } + const onMove = (e: PointerEvent): void => { + const drag = pointerDragRef.current + if (!drag) return + if (!drag.dragging) { + if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 5) return + drag.dragging = true + } + setGhost({ x: e.clientX, y: e.clientY, label: drag.task.content || 'task' }) + setDragOverIso(dayUnder(e.clientX, e.clientY)) + } + const onUp = (e: PointerEvent): void => { + const drag = pointerDragRef.current + if (!drag) return + pointerDragRef.current = null + setGhost(null) + setDragOverIso(null) + if (!drag.dragging) return + // It was a drag, not a click — keep the row's onClick from firing. + suppressClickRef.current = true + const iso = dayUnder(e.clientX, e.clientY) + if (iso) { + const { onMoveTask: move, onRescheduleTask: resched } = dropCbRef.current + setMenu({ + x: e.clientX, + y: e.clientY, + items: dropChoiceItems(drag.task, iso, move, resched) + }) + } + } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + return () => { + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + } + }, []) + + // Right-click a task: reschedule presets, move into a day's note, inline edit, + // delete. (Drag still offers the move/set-due choice.) + const openTaskMenu = (e: React.MouseEvent, task: VaultTask): void => { + e.preventDefault() + e.stopPropagation() + const reschedule = (iso: string | null): void => + void applyTaskMutation(task, { kind: 'set-due', due: iso }) + const items: ContextMenuItem[] = [ + { label: 'Due today', hint: addDaysIso(0), onSelect: () => reschedule(addDaysIso(0)) }, + { label: 'Due tomorrow', hint: addDaysIso(1), onSelect: () => reschedule(addDaysIso(1)) }, + { label: 'Due next week', hint: addDaysIso(7), onSelect: () => reschedule(addDaysIso(7)) } + ] + if (task.due && !task.dueInferred) { + items.push({ label: 'Clear due date', onSelect: () => reschedule(null) }) + } + items.push( + { kind: 'separator' }, + { + label: 'Move to its day’s note', + disabled: !task.due, + onSelect: () => { + if (task.due) onMoveTask(task, task.due) + } + }, + { + label: 'Edit', + onSelect: () => { + setEditingTaskId(task.id) + setEditValue(taskTail(task)) + } + }, + { kind: 'separator' }, + { label: 'Delete', danger: true, onSelect: () => void deleteTaskFromList(task) } + ) + setMenu({ x: e.clientX, y: e.clientY, items }) + } + + const renderTask = ( + task: VaultTask, + extra: { isActive?: boolean; buttonRef?: React.RefObject<HTMLDivElement> | null } + ): JSX.Element => + editingTaskId === task.id ? ( + <div key={task.id} className="flex items-center gap-2 rounded-md px-2 py-1"> + <span className="h-4 w-4 shrink-0 rounded border border-paper-400/70" /> + <input + autoFocus + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.currentTarget.blur() + } else if (e.key === 'Escape') { + e.stopPropagation() + cancelEditRef.current = true + e.currentTarget.blur() + } + }} + onBlur={() => { + if (cancelEditRef.current) { + cancelEditRef.current = false + } else { + const text = editValue.trim() + if (text && text !== taskTail(task)) { + void applyTaskMutation(task, { kind: 'set-text', text }) + } + } + setEditingTaskId(null) + }} + className="min-w-0 flex-1 rounded border border-accent/60 bg-paper-100 px-1 py-0.5 text-sm outline-none" + spellCheck={false} + autoComplete="off" + /> + </div> + ) : ( + <CalendarTaskRow + key={task.id} + task={task} + isOverdue={isTaskOverdue(task, today)} + isActive={extra.isActive} + buttonRef={extra.buttonRef ?? null} + onToggle={() => onToggleTask(task)} + onOpen={() => { + if (suppressClickRef.current) { + suppressClickRef.current = false + return + } + onOpenTask(task) + }} + onContextMenu={(e) => openTaskMenu(e, task)} + onPointerDownTask={(e) => startPointerDrag(task, e)} + /> + ) + + // Reset the keyboard "active task" cursor whenever the selected day changes. + useEffect(() => { + setActiveTaskIndex(0) + }, [selectedIso]) // gt sequence (vim "go to today") const gPending = useRef(0) @@ -132,18 +349,83 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) // sidebar bindings (same trick TasksView's list mode uses). useEffect(() => { const handler = (e: KeyboardEvent): void => { + // While the Vim hint overlay is open it owns the keyboard; yield to it. (#151) + if (document.querySelector('[data-vim-hint-overlay]')) return const active = document.activeElement as HTMLElement | null if (active) { const tag = active.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return } - if (e.metaKey || e.ctrlKey || e.altKey) return const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() } + if (e.metaKey || e.ctrlKey || e.altKey) return + + // Grab & place: while a task is picked up, the grid navigation chooses a + // target day; Enter places it (move / set-due choice), Esc cancels. + if (grabbedTask) { + if (e.key === 'Escape') { + consume() + setGrabbedTask(null) + return + } + if (e.key === 'Enter') { + consume() + const cell = document.querySelector(`[data-cal-day="${selectedIso}"]`) + const rect = cell?.getBoundingClientRect() + setMenu({ + x: rect ? rect.left + rect.width / 2 : window.innerWidth / 2, + y: rect ? rect.bottom : window.innerHeight / 2, + items: dropChoiceItems(grabbedTask, selectedIso, onMoveTask, onRescheduleTask) + }) + setGrabbedTask(null) + return + } + switch (e.key) { + case 'h': + case 'ArrowLeft': + consume() + moveSelection(-1) + return + case 'l': + case 'ArrowRight': + consume() + moveSelection(1) + return + case 'j': + case 'ArrowDown': + consume() + moveSelection(7) + return + case 'k': + case 'ArrowUp': + consume() + moveSelection(-7) + return + case '[': + consume() + goToMonth(-1) + return + case ']': + consume() + goToMonth(1) + return + default: + consume() + return + } + } + + // `a` focuses the quick-add box for the selected day. + if (e.key === 'a' && dailyNotesEnabled) { + consume() + requestAnimationFrame(() => addInputRef.current?.focus()) + return + } + // Two-key `gt` — go to today. if (e.key === 't' && gPending.current > 0) { consume() @@ -180,6 +462,60 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) return } + // Task-list actions on the active task (keyboard analogs of the row's + // drag / right-click menu). + if (selectedTasks.length > 0) { + if (e.key === 'Tab') { + consume() + const n = selectedTasks.length + const dir = e.shiftKey ? -1 : 1 + setActiveTaskIndex((i) => (((Math.min(i, n - 1) + dir) % n) + n) % n) + return + } + const active = selectedTasks[Math.min(activeTaskIndex, selectedTasks.length - 1)] + if (active) { + if (e.key === '>' || e.key === '<') { + consume() + onRescheduleTask(active, toIsoDateLocal(addDays(selectedDate, e.key === '>' ? 1 : -1))) + return + } + if (e.key === 'T') { + consume() + onRescheduleTask(active, todayIso) + return + } + if (e.key === 'x' || e.key === ' ') { + consume() + onToggleTask(active) + return + } + if (e.key === 'e') { + consume() + setEditingTaskId(active.id) + setEditValue(taskTail(active)) + return + } + if (e.key === 'm') { + consume() + setGrabbedTask(active) + return + } + if (e.key === 'd') { + consume() + if (dPending.current) { + dPending.current = false + if (dTimer.current) clearTimeout(dTimer.current) + void deleteTaskFromList(active) + } else { + dPending.current = true + if (dTimer.current) clearTimeout(dTimer.current) + dTimer.current = setTimeout(() => (dPending.current = false), 600) + } + return + } + } + } + switch (e.key) { case 'h': case 'ArrowLeft': @@ -211,7 +547,9 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) return case 'Enter': consume() - if (selectedTasks.length > 0) onOpenTask(selectedTasks[0]) + if (selectedTasks.length > 0) { + onOpenTask(selectedTasks[Math.min(activeTaskIndex, selectedTasks.length - 1)]) + } return default: return @@ -221,12 +559,27 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) return () => window.removeEventListener('keydown', handler, true) // We deliberately re-bind on every relevant change so the closure // sees the latest selection / month / cells. - }, [cells, monthAnchor, selectedDate, selectedTasks, onOpenTask]) + }, [ + cells, + monthAnchor, + selectedDate, + selectedIso, + selectedTasks, + activeTaskIndex, + grabbedTask, + todayIso, + dailyNotesEnabled, + onOpenTask, + onToggleTask, + onRescheduleTask, + onMoveTask, + deleteTaskFromList + ]) - const focusedTaskRef = useRef<HTMLButtonElement | null>(null) + const focusedTaskRef = useRef<HTMLDivElement | null>(null) useEffect(() => { focusedTaskRef.current?.scrollIntoView({ block: 'nearest' }) - }, [selectedIso]) + }, [selectedIso, activeIdx]) return ( <div ref={rootRef} className="flex min-h-0 flex-1 flex-col"> @@ -261,7 +614,9 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) {formatMonthLabel(monthAnchor)} </div> <div className="text-xs text-current/40"> - h/j/k/l move · [ ] month · gt today · Enter open + {grabbedTask + ? `Moving “${grabbedTask.content || 'task'}” — h/j/k/l pick a day · Enter place · Esc cancel` + : 'h/j/k/l day · Tab pick · x toggle · e edit · dd del · m move · < > / T due · a add'} </div> </div> @@ -280,11 +635,13 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) const isOtherMonth = cell.getMonth() !== monthAnchor.getMonth() const isToday = cellIso === todayIso const isSelected = cellIso === selectedIso + const isDragOver = dragOverIso === cellIso const overdueCount = cellTasks.filter((t) => isTaskOverdue(t, today)).length return ( <button type="button" key={cellIso} + data-cal-day={cellIso} onClick={() => { setSelectedDate(cellIso) if (isOtherMonth) setMonthAnchor(toIsoDateLocal(firstOfMonth(cell))) @@ -292,11 +649,13 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) className={[ 'flex h-16 flex-col items-stretch gap-1 px-1.5 py-1 text-left text-xs transition-colors', isOtherMonth ? 'bg-paper-100/40 text-current/35' : 'bg-paper-100/85 text-current/80', - isSelected - ? 'ring-2 ring-inset ring-accent/60' - : isToday - ? 'ring-1 ring-inset ring-accent/40' - : 'hover:bg-paper-200/60' + isDragOver + ? 'bg-accent/10 ring-2 ring-inset ring-accent/80' + : isSelected + ? 'ring-2 ring-inset ring-accent/60' + : isToday + ? 'ring-1 ring-inset ring-accent/40' + : 'hover:bg-paper-200/60' ].join(' ')} > <div className="flex items-center justify-between"> @@ -353,24 +712,63 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) {selectedTasks.length} task{selectedTasks.length === 1 ? '' : 's'} </span> </div> + {dailyNotesEnabled && ( + <form + className="mb-2 flex items-center gap-2" + onSubmit={(e) => { + e.preventDefault() + const value = addValue.trim() + if (!value) return + void onAddTask(selectedIso, value) + setAddValue('') + }} + > + <input + ref={addInputRef} + type="text" + value={addValue} + onChange={(e) => setAddValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation() + if (addValue) setAddValue('') + else e.currentTarget.blur() + } + }} + placeholder={`Add a task for ${selectedIso === todayIso ? 'today' : addLabel}…`} + className="min-w-0 flex-1 rounded-md border border-paper-300/60 bg-paper-200/50 px-2 py-1 text-sm outline-none placeholder:text-current/40 focus:border-accent/60" + spellCheck={false} + autoComplete="off" + /> + <button + type="submit" + disabled={!addValue.trim()} + className="shrink-0 rounded-md bg-accent/90 px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:bg-accent disabled:opacity-40" + > + Add + </button> + </form> + )} {selectedTasks.length === 0 ? ( <div className="rounded-md border border-dashed border-paper-300/60 px-3 py-4 text-center text-xs text-current/50"> - Nothing scheduled. Add{' '} - <code className="rounded bg-paper-300/60 px-1">due:{selectedIso}</code> to a task to see - it here. + {dailyNotesEnabled ? ( + 'Nothing scheduled for this day yet.' + ) : ( + <> + Nothing scheduled. Add{' '} + <code className="rounded bg-paper-300/60 px-1">due:{selectedIso}</code> to a task to + see it here. + </> + )} </div> ) : ( <div className="space-y-1"> - {selectedTasks.map((task, i) => ( - <CalendarTaskRow - key={task.id} - task={task} - isOverdue={isTaskOverdue(task, today)} - buttonRef={i === 0 ? focusedTaskRef : null} - onToggle={() => onToggleTask(task)} - onOpen={() => onOpenTask(task)} - /> - ))} + {selectedTasks.map((task, i) => + renderTask(task, { + isActive: i === activeIdx, + buttonRef: i === activeIdx ? focusedTaskRef : null + }) + )} </div> )} @@ -380,41 +778,88 @@ export function TasksCalendar({ tasks, today, onOpenTask, onToggleTask }: Props) {unscheduled.length} task{unscheduled.length === 1 ? '' : 's'} without a due date </summary> <div className="mt-2 space-y-1"> - {unscheduled.map((task) => ( - <CalendarTaskRow - key={task.id} - task={task} - isOverdue={false} - onToggle={() => onToggleTask(task)} - onOpen={() => onOpenTask(task)} - /> - ))} + {unscheduled.map((task) => renderTask(task, {}))} </div> </details> )} </div> + + {ghost && ( + <div + className="pointer-events-none fixed z-[70] max-w-[220px] truncate rounded-md border border-accent/50 bg-paper-100 px-2 py-1 text-sm text-ink-900 shadow-float" + style={{ left: ghost.x + 12, top: ghost.y + 12 }} + > + {ghost.label} + </div> + )} + + {menu && ( + <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} /> + )} </div> ) } +/** "Move into that day's note" vs "just set the due date" — the menu shown after + * dropping a task on a calendar day. */ +function dropChoiceItems( + task: VaultTask, + dateIso: string, + onMoveTask: (task: VaultTask, dateIso: string) => void, + onRescheduleTask: (task: VaultTask, dueIso: string) => void +): ContextMenuItem[] { + const label = parseIsoLocal(dateIso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric' + }) + return [ + { label: `Move into ${label}'s note`, onSelect: () => onMoveTask(task, dateIso) }, + { label: `Just set due: ${label}`, onSelect: () => onRescheduleTask(task, dateIso) } + ] +} + interface RowProps { task: VaultTask isOverdue: boolean - buttonRef?: React.RefObject<HTMLButtonElement> | null + isActive?: boolean + buttonRef?: React.RefObject<HTMLDivElement> | null onToggle: () => void onOpen: () => void + onContextMenu?: (e: React.MouseEvent) => void + onPointerDownTask?: (e: React.PointerEvent) => void } -function CalendarTaskRow({ task, isOverdue, buttonRef, onToggle, onOpen }: RowProps): JSX.Element { +function CalendarTaskRow({ + task, + isOverdue, + isActive, + buttonRef, + onToggle, + onOpen, + onContextMenu, + onPointerDownTask +}: RowProps): JSX.Element { return ( - <button - type="button" + // Pointer-based drag (see TasksCalendar) instead of native HTML5 DnD. + // role/tabIndex/onKeyDown keep it keyboard-accessible. + <div + role="button" + tabIndex={0} ref={buttonRef ?? undefined} onClick={onOpen} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpen() + } + }} + onPointerDown={onPointerDownTask} + onContextMenu={onContextMenu} + title="Drag to a day to reschedule · right-click for actions" className={[ - 'flex w-full items-center gap-2 rounded-md border-l-2 px-2 py-1 text-left text-sm', + 'flex w-full cursor-grab select-none items-center gap-2 rounded-md border-l-2 px-2 py-1 text-left text-sm active:cursor-grabbing', isOverdue ? 'border-rose-500/70' : 'border-transparent', - 'hover:bg-paper-200/60' + isActive ? 'bg-accent/10 ring-1 ring-inset ring-accent/40' : 'hover:bg-paper-200/60' ].join(' ')} > <span @@ -473,6 +918,6 @@ function CalendarTaskRow({ task, isOverdue, buttonRef, onToggle, onOpen }: RowPr !{task.priority} </span> )} - </button> + </div> ) } diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index 5cdc1750..c577eca6 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -32,6 +32,7 @@ import { groupTasks, isOverdue as isTaskOverdue, toIsoDateLocal } from '@shared/ import { useStore, type KanbanGroupBy, type TaskMutation } from '../store' import { ArrowUpRightIcon, PencilIcon } from './icons' import { InlineMarkdown } from '../lib/inline-markdown' +import { isImeComposing } from '../lib/ime' interface Props { tasks: VaultTask[] @@ -781,6 +782,8 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): // beat VimNav's global handler (which otherwise hijacks h/j/k/l). useEffect(() => { const handler = (e: KeyboardEvent): void => { + // While the Vim hint overlay is open it owns the keyboard; yield to it. (#151) + if (document.querySelector('[data-vim-hint-overlay]')) return const active = document.activeElement as HTMLElement | null if (active) { const tag = active.tagName @@ -885,6 +888,8 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (e.key === 'Enter') { e.preventDefault() commitColumnRename(column.id) diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index bb607e08..cc0a0142 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -1,12 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isTasksViewActive, useStore, type TasksViewMode } from '../store' -import type { VaultTask } from '@shared/tasks' +import { inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' +import { buildDailyNoteDateByPath } from '../lib/vault-layout' import { computeTasksRender, isOverdue } from '../lib/tasks-filter' import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' import { TasksKanban } from './TasksKanban' import { CalendarIcon, CheckSquareIcon, KanbanIcon, ListIcon } from './icons' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' +import { isImeComposing } from '../lib/ime' +import { isAppOverlayOpen } from '../lib/overlay-open' type GroupKey = 'today' | 'upcoming' | 'waiting' | 'done' @@ -29,7 +32,9 @@ const VIEW_BUTTONS: Array<{ ] export function TasksView(): JSX.Element { - const tasks = useStore((s) => s.vaultTasks) + const rawTasks = useStore((s) => s.vaultTasks) + const notes = useStore((s) => s.notes) + const vaultSettings = useStore((s) => s.vaultSettings) const loading = useStore((s) => s.tasksLoading) const filter = useStore((s) => s.tasksFilter) const cursorIndex = useStore((s) => s.taskCursorIndex) @@ -38,8 +43,22 @@ export function TasksView(): JSX.Element { const refreshTasks = useStore((s) => s.refreshTasks) const openTaskAt = useStore((s) => s.openTaskAt) const toggleTaskFromList = useStore((s) => s.toggleTaskFromList) + const applyTaskMutation = useStore((s) => s.applyTaskMutation) + const moveTaskToDate = useStore((s) => s.moveTaskToDate) + const addTaskForDate = useStore((s) => s.addTaskForDate) const closeTasksView = useStore((s) => s.closeTasksView) + + // Tasks written inside a daily note inherit that note's date as an implicit + // due date (a clean line, no `due:` token) so they appear on the calendar. + // Done at the display layer so it works on desktop + web identically and + // re-derives whenever notes/settings change. Explicit `due:` still wins. + const dueByPath = useMemo( + () => buildDailyNoteDateByPath(notes, vaultSettings), + [notes, vaultSettings] + ) + const tasks = useMemo(() => inferDailyTaskDueDates(rawTasks, dueByPath), [rawTasks, dueByPath]) const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) const viewMode = useStore((s) => s.tasksViewMode) const setViewMode = useStore((s) => s.setTasksViewMode) // Only the Tasks panel in the *active* pane should listen for j/k/etc. @@ -202,6 +221,12 @@ export function TasksView(): JSX.Element { useEffect(() => { if (!isActivePanel) return const handler = (e: KeyboardEvent): void => { + // A modal/menu owns the keyboard while open — don't fire list shortcuts + // through it. (songgenqing report) + if (isAppOverlayOpen()) return + // While the Vim hint overlay is open it owns the keyboard; don't let + // task navigation (or Esc closing the view) steal its keys. (#151) + if (document.querySelector('[data-vim-hint-overlay]')) return const active = document.activeElement as HTMLElement | null if (active) { const tag = active.tagName @@ -211,47 +236,49 @@ export function TasksView(): JSX.Element { const key = e.key const overrides = keymapOverrides + // When Vim mode is off, the single-key Vim shortcuts (j/k/gg/G/o/Space/1-3/…) + // are disabled — only arrows/Enter/Escape navigate. (songgenqing report) + const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => + vimMode && matchesSequenceToken(e, overrides, id) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() } if (key === 'Escape') { - if (filter) { - consume() - setFilter('') - return - } + // Tasks is a tab like a note tab — Esc clears an active filter but must + // never close the tab (other tabs don't close on Esc). Close with :q, + // the header ✕, or ⌘W. (#151) consume() - closeTasksView() + if (filter) setFilter('') return } - // View switcher works regardless of sub-view. - if (key === '1') { + // View switcher works regardless of sub-view (Vim mode only). + if (vimMode && key === '1') { consume() setViewMode('list') return } - if (key === '2') { + if (vimMode && key === '2') { consume() setViewMode('calendar') return } - if (key === '3') { + if (vimMode && key === '3') { consume() setViewMode('kanban') return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (seq('nav.filter')) { consume() filterRef.current?.focus() filterRef.current?.select() return } - if (matchesSequenceToken(e, overrides, 'nav.localEx')) { + if (seq('nav.localEx')) { consume() setExValue('') setExOpen(true) @@ -263,22 +290,23 @@ export function TasksView(): JSX.Element { // List-mode-only navigation. Calendar and Kanban have their own. if (viewMode !== 'list') return - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (seq('nav.moveDown') || key === 'ArrowDown') { consume() moveCursor(1) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (seq('nav.moveUp') || key === 'ArrowUp') { consume() moveCursor(-1) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (seq('nav.jumpBottom')) { consume() setCursorIndex(taskRowIndices.length - 1) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -292,12 +320,12 @@ export function TasksView(): JSX.Element { return } - if ((key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openResult')) && currentTask) { + if ((key === 'Enter' || seq('nav.openResult')) && currentTask) { consume() void openTaskAt(currentTask) return } - if ((key === ' ' || matchesSequenceToken(e, overrides, 'nav.toggleTask')) && currentTask) { + if (((vimMode && key === ' ') || seq('nav.toggleTask')) && currentTask) { consume() void toggleTaskFromList(currentTask) return @@ -313,6 +341,7 @@ export function TasksView(): JSX.Element { taskRowIndices.length, currentTask, keymapOverrides, + vimMode, openTaskAt, toggleTaskFromList, closeTasksView, @@ -366,6 +395,8 @@ export function TasksView(): JSX.Element { value={filter} onChange={(e) => setFilter(e.target.value)} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (e.key === 'Escape') { e.stopPropagation() if (filter) setFilter('') @@ -453,6 +484,12 @@ export function TasksView(): JSX.Element { today={today} onOpenTask={(task) => void openTaskAt(task)} onToggleTask={(task) => void toggleTaskFromList(task)} + onRescheduleTask={(task, dueIso) => + void applyTaskMutation(task, { kind: 'set-due', due: dueIso }) + } + onMoveTask={(task, dateIso) => void moveTaskToDate(task, dateIso)} + onAddTask={(dateIso, text) => addTaskForDate(dateIso, text)} + dailyNotesEnabled={vaultSettings.dailyNotes.enabled} /> )} @@ -501,10 +538,10 @@ export function TasksView(): JSX.Element { ) : ( <div className="border-t border-paper-300/45 px-4 py-1.5 text-xs text-current/40"> {viewMode === 'list' - ? 'j/k move · Enter/o open · Space/x toggle · / filter · 1/2/3 view · : command · Esc close' + ? 'j/k move · Enter/o open · Space/x toggle · / filter · 1/2/3 view · : command · :q close' : viewMode === 'calendar' - ? 'h/j/k/l day · [ ] month · gt today · Enter open · 1/2/3 view · : command · Esc close' - : 'h/l column · j/k card · Space toggle · Enter open · 1/2/3 view · : command · Esc close'} + ? 'h/j/k/l day · [ ] month · gt today · Tab pick · < > reschedule · drag to move · Enter open · :q' + : 'h/l column · j/k card · Space toggle · Enter open · 1/2/3 view · : command · :q close'} </div> )} </div> diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index ec45039d..921f3d8a 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -9,7 +9,8 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { Compartment, EditorState, type Transaction } from '@codemirror/state' import { EditorView, drawSelection, highlightActiveLine, keymap, tooltips } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { vimAwareDefaultKeymap } from '../lib/cm-vim-default-keymap' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { yamlFrontmatter } from '@codemirror/lang-yaml' import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language' @@ -20,6 +21,7 @@ import { parseFrontmatter, slugifyTemplateName } from '@shared/template-files' import { renderTemplate } from '../lib/template-render' import { resolveCodeLanguage } from '../lib/cm-code-languages' import { markdownListIndentPlugin } from '../lib/cm-markdown-list-indent' +import { appMarkdownSnippetExtension } from '../lib/markdown-snippets-config' import { templateVariableSource, TEMPLATE_VARIABLES } from '../lib/cm-template-variables' import { templateSlashCommandSource, slashCommandRender } from '../lib/cm-slash-commands' import { completionNavKeymap } from '../lib/cm-completion-nav' @@ -59,6 +61,7 @@ const templateHighlight = HighlightStyle.define([ { tag: t.heading6, class: 'tok-heading6' }, { tag: t.emphasis, class: 'tok-emphasis' }, { tag: t.strong, class: 'tok-strong' }, + { tag: t.strikethrough, class: 'tok-strikethrough' }, { tag: t.link, class: 'tok-link' }, { tag: t.url, class: 'tok-url' }, { tag: t.monospace, class: 'tok-monospace' }, @@ -108,6 +111,7 @@ export function TemplateEditorModal({ const state = EditorState.create({ doc: initialRaw ?? SKELETON, extensions: [ + appMarkdownSnippetExtension(), new Compartment().of(vimModeRef.current ? vim() : []), history(), drawSelection(), @@ -136,7 +140,12 @@ export function TemplateEditorModal({ optionClass: () => 'slash-cmd-option' }), completionNavKeymap, - keymap.of([indentWithTab, ...completionKeymap, ...defaultKeymap, ...historyKeymap]), + keymap.of([ + indentWithTab, + ...completionKeymap, + ...vimAwareDefaultKeymap(vimModeRef.current), + ...historyKeymap + ]), editorTheme, EditorView.updateListener.of((upd) => { if (upd.docChanged) setRaw(upd.state.doc.toString()) diff --git a/packages/app-core/src/components/TemplatePalette.tsx b/packages/app-core/src/components/TemplatePalette.tsx index 4f237513..2cd12f13 100644 --- a/packages/app-core/src/components/TemplatePalette.tsx +++ b/packages/app-core/src/components/TemplatePalette.tsx @@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useStore } from '../store' import { rankItems } from '../lib/fuzzy-score' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' import { focusEditorNormalMode } from '../lib/editor-focus' import { BUILTIN_TEMPLATES } from '@shared/builtin-templates' import { mergeTemplates } from '@shared/template-files' @@ -81,6 +82,8 @@ export function TemplatePalette(): JSX.Element { placeholder={mode === 'insert' ? 'Insert template into note…' : 'Create note from template…'} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/TrashView.tsx b/packages/app-core/src/components/TrashView.tsx index b831182f..d8d3753d 100644 --- a/packages/app-core/src/components/TrashView.tsx +++ b/packages/app-core/src/components/TrashView.tsx @@ -6,6 +6,7 @@ import { CollectionViewHeader } from './CollectionViewHeader' import { advanceSequence, getKeymapBinding, matchesSequenceToken } from '../lib/keymaps' import { getSystemFolderLabel } from '../lib/system-folder-labels' import { confirmApp } from '../lib/confirm-requests' +import { isAppOverlayOpen } from '../lib/overlay-open' function formatDate(ms: number): string { const d = new Date(ms) @@ -29,6 +30,7 @@ export function TrashView(): JSX.Element { const selectNote = useStore((s) => s.selectNote) const closeActiveNote = useStore((s) => s.closeActiveNote) const keymapOverrides = useStore((s) => s.keymapOverrides) + const vimMode = useStore((s) => s.vimMode) const setFocusedPanel = useStore((s) => s.setFocusedPanel) const systemFolderLabels = useStore((s) => s.systemFolderLabels) const amActive = useStore(isTrashViewActive) @@ -125,6 +127,9 @@ export function TrashView(): JSX.Element { useEffect(() => { if (!amActive) return const handler = (e: KeyboardEvent): void => { + // A modal/menu (e.g. the delete-confirm dialog) owns the keyboard while + // open — don't let list shortcuts fire through it. (songgenqing report) + if (isAppOverlayOpen()) return const active = document.activeElement as HTMLElement | null if (active) { const tag = active.tagName @@ -134,6 +139,10 @@ export function TrashView(): JSX.Element { const key = e.key const overrides = keymapOverrides + // When Vim mode is off, the single-key Vim shortcuts (j/k/x/d/gg/G/o/r//…) + // are disabled — only arrows/Enter/Escape navigate. (songgenqing report) + const seq = (id: Parameters<typeof matchesSequenceToken>[2]): boolean => + vimMode && matchesSequenceToken(e, overrides, id) const consume = (): void => { e.preventDefault() e.stopImmediatePropagation() @@ -150,29 +159,30 @@ export function TrashView(): JSX.Element { return } - if (matchesSequenceToken(e, overrides, 'nav.filter')) { + if (seq('nav.filter')) { consume() filterRef.current?.focus() filterRef.current?.select() return } - if (matchesSequenceToken(e, overrides, 'nav.moveDown') || key === 'ArrowDown') { + if (seq('nav.moveDown') || key === 'ArrowDown') { consume() setCursorIndex((i) => Math.max(0, Math.min(filtered.length - 1, i + 1))) return } - if (matchesSequenceToken(e, overrides, 'nav.moveUp') || key === 'ArrowUp') { + if (seq('nav.moveUp') || key === 'ArrowUp') { consume() setCursorIndex((i) => Math.max(0, Math.min(filtered.length - 1, i - 1))) return } - if (matchesSequenceToken(e, overrides, 'nav.jumpBottom')) { + if (seq('nav.jumpBottom')) { consume() setCursorIndex(filtered.length - 1) return } if ( + vimMode && advanceSequence( e, getKeymapBinding(overrides, 'nav.jumpTop'), @@ -185,17 +195,17 @@ export function TrashView(): JSX.Element { ) { return } - if ((key === 'Enter' || matchesSequenceToken(e, overrides, 'nav.openResult')) && current) { + if ((key === 'Enter' || seq('nav.openResult')) && current) { consume() void openCurrent() return } - if (matchesSequenceToken(e, overrides, 'nav.restore') && current) { + if (seq('nav.restore') && current) { consume() void restoreNote(current) return } - if ((matchesSequenceToken(e, overrides, 'nav.delete') || key === 'd') && current) { + if ((seq('nav.delete') || (vimMode && key === 'd')) && current) { consume() void deleteNoteForever(current) } @@ -213,6 +223,7 @@ export function TrashView(): JSX.Element { filter, filtered.length, keymapOverrides, + vimMode, openCurrent, restoreNote ]) diff --git a/packages/app-core/src/components/VaultTextSearchPalette.tsx b/packages/app-core/src/components/VaultTextSearchPalette.tsx index aebf414b..0f634b46 100644 --- a/packages/app-core/src/components/VaultTextSearchPalette.tsx +++ b/packages/app-core/src/components/VaultTextSearchPalette.tsx @@ -7,6 +7,7 @@ import type { import { useStore } from '../store' import { resolveSystemFolderLabels } from '../lib/system-folder-labels' import { isPaletteNextKey, isPalettePreviousKey } from '../lib/palette-nav' +import { isImeComposing } from '../lib/ime' import { recordRendererPerf } from '../lib/perf' import { focusEditorNormalMode } from '../lib/editor-focus' import { Modal } from './ui/Modal' @@ -381,6 +382,8 @@ export function VaultTextSearchPalette(): JSX.Element { placeholder="Search text across the vault…" onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return if (isPaletteNextKey(e)) { e.preventDefault() e.stopPropagation() diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 156cb7fa..fb176456 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -8,6 +8,7 @@ import { hintTargetOpensNote, isEditorInsertMode, isEditorFocused, + isVimAwaitingArgument, resolveNextPanel } from '../lib/vim-nav' import { focusPaneInDirection } from '../lib/pane-nav' @@ -19,8 +20,10 @@ import { getKeymapDisplay, getSequenceTokens, matchesSequenceToken, + matchesShortcutBinding, sequenceTokenFromEvent } from '../lib/keymaps' +import { toggleWrap, wrapLink } from '../lib/cm-format' import { ZEN_OPEN_EDITOR_CONTEXT_MENU_EVENT, dispatchKeyboardContextMenu, @@ -160,6 +163,16 @@ export function VimNav(): JSX.Element | null { keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderFormatNote'), label: 'Format note', detail: 'Run markdown formatting on the active note.' + }, + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderCopyMarkdown'), + label: 'Copy as Markdown', + detail: "Copy the whole note's Markdown to the clipboard." + }, + { + keyLabel: getKeymapDisplay(keymapOverrides, 'vim.leaderToggleFavorite'), + label: 'Toggle favorite', + detail: 'Add or remove the active note from Favorites.' } ] } @@ -299,6 +312,9 @@ export function VimNav(): JSX.Element | null { // Never steal keys from normal text-entry fields such as the // inline note title, prompt inputs, or textarea-based controls. if (tag === 'INPUT' || tag === 'TEXTAREA') return + // The selection format toolbar handles its own keyboard navigation + // (arrows / Enter / Esc) once focused — yield to it entirely. + if (target?.closest('[data-selection-toolbar]')) return // The database/table view runs its own vim-style motion grid; yield to it // so sidebar/note-list navigation doesn't steal j/k/h/l etc. — EXCEPT the // pane prefix (Ctrl+W) and its pending direction key, so the grid can hand @@ -322,6 +338,46 @@ export function VimNav(): JSX.Element | null { const previewEl = getPreviewScrollElement() const hoverPreviewEl = getHoverPreviewScrollElement() + // Inline-format shortcuts (Bold/Italic/Strike/Highlight/Code/Math/Link) + // mirror the selection toolbar. Handled here — in the window capture + // handler — so they work on every platform and beat Vim's own Ctrl + // chords (e.g. <C-b>) in normal/visual mode on Linux/Windows. `Mod` + // resolves to ⌘ on macOS and Ctrl elsewhere. + const fmtView = state.editorViewRef + if (fmtView && isEditorFocused(fmtView)) { + // Focus the selection toolbar (when shown) for keyboard navigation. + if (matchesShortcutBinding(e, 'Mod+/')) { + const firstItem = document.querySelector<HTMLElement>( + '[data-selection-toolbar] [data-toolbar-item]' + ) + if (firstItem) { + e.preventDefault() + e.stopImmediatePropagation() + firstItem.focus() + return + } + } + // Bindings in canonical modifier order (Shift before Mod), matching + // `normalizeShortcutBinding` so `matchesShortcutBinding` compares equal. + const formats: Array<[string, () => void]> = [ + ['Mod+B', () => toggleWrap(fmtView, '**')], + ['Mod+I', () => toggleWrap(fmtView, '*')], + ['Mod+E', () => toggleWrap(fmtView, '`')], + ['Shift+Mod+S', () => toggleWrap(fmtView, '~~')], + ['Shift+Mod+H', () => toggleWrap(fmtView, '==')], + ['Shift+Mod+M', () => toggleWrap(fmtView, '$')], + ['Mod+K', () => wrapLink(fmtView)] + ] + for (const [binding, run] of formats) { + if (matchesShortcutBinding(e, binding)) { + e.preventDefault() + e.stopImmediatePropagation() + run() + return + } + } + } + const wantsJumpBack = matchesSequenceToken(e, overrides, 'vim.historyBack') const wantsJumpForward = matchesSequenceToken(e, overrides, 'vim.historyForward') if ( @@ -536,12 +592,7 @@ export function VimNav(): JSX.Element | null { return } - // ------- Tasks / Tag view active → defer to its own window handler - // Both panels install capture-phase window keydowns that handle - // j/k/gg/G/Enter/o/Esc/etc. themselves. We bail here so VimNav - // doesn't swallow those keys with stale sidebar routing. Exception: - // let `f` (hint mode) fall through — a global affordance that - // should still work anywhere, and its handler sits further down. + // Cancel a pending leader sequence on Escape or a second leader press. if (leaderPending.current && e.key === 'Escape') { e.preventDefault() e.stopImmediatePropagation() @@ -557,8 +608,20 @@ export function VimNav(): JSX.Element | null { resetLeader() return } + // ------- Tasks / Tag view active → defer to its own window handler + // Both panels install capture-phase window keydowns that handle + // j/k/gg/G/Enter/x/Esc/etc. themselves, so we bail and let them — with + // one exception: leader input. The leader (Space) and any in-progress + // leader sequence fall through to the leader logic below so <leader>h + // (hint mode) and every other leader command work in these panels too. + // VimNav consumes the leader keypress before TasksView sees it, so the + // leader no longer collides with Space-to-toggle. (#151) const panelViewActive = isTasksViewActive(state) || isTagsViewActive(state) - if (panelViewActive && e.key !== 'f') { + if ( + panelViewActive && + !leaderPending.current && + sequenceTokenFromEvent(e) !== leaderToken + ) { return } @@ -683,6 +746,20 @@ export function VimNav(): JSX.Element | null { void state.formatActiveNote() return } + if (matchesSequenceToken(e, overrides, 'vim.leaderCopyMarkdown')) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + void state.copyActiveNoteAsMarkdown() + return + } + if (matchesSequenceToken(e, overrides, 'vim.leaderToggleFavorite')) { + e.preventDefault() + e.stopImmediatePropagation() + resetLeader() + void state.toggleFavoriteActiveNote() + return + } resetLeader() } @@ -697,9 +774,20 @@ export function VimNav(): JSX.Element | null { resetLeader() } + // In the tasks/tags panels, only leader input is handled above; hand + // every other key (including a just-reset leader sequence) back to the + // panel's own capture handler. (#151) + if (panelViewActive && sequenceTokenFromEvent(e) !== leaderToken) { + return + } + if ( sequenceTokenFromEvent(e) === leaderToken && - !editorInsertMode + !editorInsertMode && + // While Vim is mid-command in the focused editor (e.g. after f/t/r or an + // operator), Space is the command's argument (r<Space>, f<Space>), not + // the leader — let it fall through to codemirror-vim. (#147) + !(isEditorFocused(state.editorViewRef) && isVimAwaitingArgument(state.editorViewRef)) ) { const tag = (e.target as HTMLElement | null)?.tagName if (tag !== 'INPUT' && tag !== 'TEXTAREA') { @@ -747,6 +835,29 @@ export function VimNav(): JSX.Element | null { return } + // A focused breadcrumb folder crumb (e.g. reached via hint mode) owns the + // context-menu key — open *its* create menu, not the sidebar item's. + { + const activeCrumb = document.activeElement as HTMLElement | null + if ( + activeCrumb?.hasAttribute('data-crumb-menu') && + (matchesSequenceToken(e, overrides, 'nav.contextMenu') || wantsNativeContextMenuKey(e)) + ) { + e.preventDefault() + e.stopImmediatePropagation() + const rect = activeCrumb.getBoundingClientRect() + activeCrumb.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: Math.round(rect.left), + clientY: Math.round(rect.bottom + 2) + }) + ) + return + } + } + // ------- Sidebar navigation (explicit) ----------------------------- // When focusedPanel is 'sidebar', always handle here — even if the // editor still holds stale DOM focus from a previous interaction. @@ -800,7 +911,7 @@ export function VimNav(): JSX.Element | null { } // `f` (and operator+motion sequences like df/cf/yf) are Vim find-char - // motions here — hint mode lives on the leader (<leader>f) so it never + // motions here — hint mode lives on the leader (<leader>h) so it never // hijacks them. (#107) return } @@ -1707,6 +1818,8 @@ export function VimNav(): JSX.Element | null { void state.openArchiveView() } else if (itemType === 'trash') { void state.openTrashView() + } else if (itemType === 'assets') { + void state.openAssetsView() } } diff --git a/packages/app-core/src/components/icons.tsx b/packages/app-core/src/components/icons.tsx index 638dc6be..e664f1f6 100644 --- a/packages/app-core/src/components/icons.tsx +++ b/packages/app-core/src/components/icons.tsx @@ -44,6 +44,19 @@ export const DatabaseIcon = (p: IconProps): JSX.Element => ( </I> ) +export const ExcalidrawIcon = (p: IconProps): JSX.Element => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="16" + height="16" + viewBox="0 0 107 101" + fill="currentColor" + {...p} + > + <path fillRule="nonzero" transform="matrix(1 0 0 1 -26.41 -29.49)" d="M119.81 105.98a.549.549 0 0 0-.53-.12c-4.19-6.19-9.52-12.06-14.68-17.73l-.85-.93c0-.11-.05-.21-.12-.3a.548.548 0 0 0-.34-.2l-.17-.18-.12-.09c-.15-.32-.53-.56-.95-.35-1.58.81-3 1.97-4.4 3.04-1.87 1.43-3.7 2.92-5.42 4.52-.7.65-1.39 1.33-1.97 2.09-.28.37-.07.72.27.87-1.22 1.2-2.45 2.45-3.68 3.74-.11.12-.17.28-.16.44.01.16.09.31.22.41l2.16 1.65s.01.03.03.04c3.09 3.05 8.51 7.28 14.25 11.76.85.67 1.71 1.34 2.57 2.01.39.47.76.94 1.12 1.4.19.25.55.3.8.11.13.1.26.21.39.31a.57.57 0 0 0 .8-.1c.07-.09.1-.2.11-.31.04 0 .07.03.1.03.15 0 .31-.06.42-.18l10.18-11.12a.56.56 0 0 0-.04-.8l.01-.01Zm-29.23-3.85c.07.09.14.17.21.25 1.16.98 2.4 2.04 3.66 3.12l-5.12-3.91s-.32-.22-.52-.36c-.11-.08-.21-.16-.31-.24l-.38-.32s.07-.07.1-.11l.35-.35c1.72-1.74 4.67-4.64 6.19-6.06-1.61 1.62-4.87 6.37-4.17 7.98h-.01Zm17.53 13.81-4.22-3.22c-1.65-1.71-3.43-3.4-5.24-5.03 2.28 1.76 4.23 3.25 4.52 3.51 2.21 1.97 2.11 1.61 3.63 2.91l1.83 1.33c-.18.16-.36.33-.53.49l.01.01Zm1.06.81-.08-.06c.16-.13.33-.25.49-.38l-.4.44h-.01ZM42.24 51.45c.14.72.27 1.43.4 2.11.69 3.7 1.33 7.03 2.55 9.56l.48 1.92c.19.73.46 1.64.71 1.83 2.85 2.52 7.22 6.28 11.89 9.82.21.16.5.15.7-.01.01.02.03.03.04.04.11.1.24.15.38.15.16 0 .31-.06.42-.19 5.98-6.65 10.43-12.12 13.6-16.7.2-.25.3-.54.29-.84.2-.24.41-.48.6-.68a.558.558 0 0 0-.1-.86.578.578 0 0 0-.17-.36c-1.39-1.34-2.42-2.31-3.46-3.28-1.84-1.72-3.74-3.5-7.77-7.51-.02-.02-.05-.04-.07-.06a.555.555 0 0 0-.22-.14c-1.11-.39-3.39-.78-6.26-1.28-4.22-.72-10-1.72-15.2-3.27h-.04v-.01s-.02 0-.03.02h-.01l.04-.02s-.31.01-.37.04c-.08.04-.14.09-.19.15-.05.06-.09.12-.47.2-.38.08.08 0 .11 0h-.11v.03c.07.34.05.58.16.97-.02.1.21 1.02.24 1.11l1.83 7.26h.03Zm30.95 6.54s-.03.04-.04.05l-.64-.71c.22.21.44.42.68.66Zm-7.09 9.39s-.07.08-.1.12l-.02-.02c.04-.03.08-.07.13-.1h-.01Zm-7.07 8.47Zm3.02-28.57c.35.35 1.74 1.65 2.06 1.97-1.45-.66-5.06-2.34-6.74-2.88 1.65.29 3.93.66 4.68.91Zm-19.18-2.77c.84 1.44 1.5 6.49 2.16 11.4-.37-1.58-.69-3.12-.99-4.6-.52-2.56-1-4.85-1.67-6.88.14.01.31.03.49.05 0 .01 0 .02.02.03h-.01Zm-.29-1.21c-.23-.02-.44-.04-.62-.05-.02-.04-.03-.08-.04-.12l.66.18v-.01Zm-2.22.45v-.02.02ZM118.9 42.57c.04-.23-1.1-1.24-.74-1.26.85-.04.86-1.35 0-1.31-1.13.06-2.27.32-3.37.53-1.98.37-3.95.78-5.92 1.21-4.39.94-8.77 1.93-13.1 3.11-1.36.37-2.86.7-4.11 1.36-.42.22-.4.67-.17.95-.09.05-.18.08-.28.09-.37.07-.74.13-1.11.19a.566.566 0 0 0-.39.86c-2.32 3.1-4.96 6.44-7.82 9.95-2.81 3.21-5.73 6.63-8.72 10.14-9.41 11.06-20.08 23.6-31.9 34.64-.23.21-.24.57-.03.8.05.06.12.1.19.13-.16.15-.32.3-.48.44-.1.09-.14.2-.16.32-.08.08-.16.17-.23.25-.21.23-.2.59.03.8.23.21.59.2.8-.03.04-.04.08-.09.12-.13a.84.84 0 0 1 1.22 0c.69.74 1.34 1.44 1.95 2.09l-1.38-1.15a.57.57 0 0 0-.8.07c-.2.24-.17.6.07.8l14.82 12.43c.11.09.24.13.37.13.15 0 .29-.06.4-.17l.36-.36a.56.56 0 0 0 .63-.12c20.09-20.18 36.27-35.43 54.8-49.06.17-.12.25-.32.23-.51a.57.57 0 0 0 .48-.39c3.42-10.46 4.08-19.72 4.28-24.27 0-.03.01-.05.02-.07.02-.05.03-.1.04-.14.03-.11.05-.19.05-.19.26-.78.17-1.53-.15-2.15v.02ZM82.98 58.94c.9-1.03 1.79-2.04 2.67-3.02-5.76 7.58-15.3 19.26-28.81 33.14 9.2-10.18 18.47-20.73 26.14-30.12Zm-32.55 52.81-.03-.03c.11.02.19.04.2.04a.47.47 0 0 0-.17 0v-.01Zm6.9 6.42-.05-.04.03-.03c.02 0 .03.02.04.02 0 .02-.02.03-.03.05h.01Zm8.36-7.21 1.38-1.44c.01.01.02.03.03.05-.47.46-.94.93-1.42 1.39h.01Zm2.24-2.21c.26-.3.56-.65.87-1.02.01-.01.02-.03.04-.04 3.29-3.39 6.68-6.82 10.18-10.25.02-.02.05-.04.07-.06.86-.66 1.82-1.39 2.72-2.08-4.52 4.32-9.11 8.78-13.88 13.46v-.01Zm21.65-55.88c-1.86 2.42-3.9 5.56-5.63 8.07-5.46 7.91-23.04 27.28-23.43 27.65-2.71 2.62-10.88 10.46-16.09 15.37-.14.13-.25.24-.34.35a.794.794 0 0 1 .03-1.13c24.82-23.4 39.88-42.89 46-51.38-.13.33-.24.69-.55 1.09l.01-.02Zm16.51 7.1-.01.02c0-.02-.02-.07.01-.02Zm-.91-5.13Zm-5.89 9.45c-2.26-1.31-3.32-3.27-2.71-5.25l.19-.66c.08-.19.17-.38.28-.57.59-.98 1.49-1.85 2.52-2.36.05-.02.1-.03.15-.04a.795.795 0 0 1-.04-.43c.05-.31.25-.58.66-.58.67 0 2.75.62 3.54 1.3.24.19.47.4.68.63.3.35.74.92.96 1.33.13.06.23.62.38.91.14.46.2.93.18 1.4 0 .02 0 .02.01.03-.03.07 0 .37-.04.4-.1.72-.36 1.43-.75 2.05-.04.05-.07.11-.11.16 0 .01-.02.02-.03.04-.3.43-.65.83-1.08 1.13-1.26.89-2.73 1.16-4.2.79a6.33 6.33 0 0 1-.57-.25l-.02-.03Zm16.27-1.63c-.49 2.05-1.09 4.19-1.8 6.38-.03.08-.03.16-.03.23-.1.01-.19.05-.27.11-4.44 3.26-8.73 6.62-12.98 10.11 3.67-3.32 7.39-6.62 11.23-9.95a6.409 6.409 0 0 0 2.11-3.74l.56-3.37.03-.1c.25-.71 1.34-.4 1.17.33h-.02Z" /> + </svg> +) + export const InboxIcon = (p: IconProps): JSX.Element => ( <I {...p}> <path d="M4 13v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5" /> @@ -141,6 +154,34 @@ export const ChevronLeftIcon = (p: IconProps): JSX.Element => ( </I> ) +export const ImageIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <rect x="3" y="3" width="18" height="18" rx="2" /> + <circle cx="8.5" cy="8.5" r="1.5" /> + <path d="m21 15-4.5-4.5L5 21" /> + </I> +) + +export const PaperclipIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /> + </I> +) + +export const ArrowLeftIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M19 12H5" /> + <path d="m12 19-7-7 7-7" /> + </I> +) + +export const ArrowRightIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M5 12h14" /> + <path d="m12 5 7 7-7 7" /> + </I> +) + export const CalendarIcon = (p: IconProps): JSX.Element => ( <I {...p}> <rect x="3" y="5" width="18" height="16" rx="2" /> @@ -320,3 +361,54 @@ export const FileDownIcon = (p: IconProps): JSX.Element => ( <path d="m9 14 3 3 3-3" /> </I> ) + +// --- inline-format toolbar icons (selection bubble) ----------------------- +export const BoldIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M6 4h7a4 4 0 0 1 0 8H6z" /> + <path d="M6 12h8a4 4 0 0 1 0 8H6z" /> + </I> +) + +export const ItalicIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <line x1="19" y1="4" x2="10" y2="4" /> + <line x1="14" y1="20" x2="5" y2="20" /> + <line x1="15" y1="4" x2="9" y2="20" /> + </I> +) + +export const StrikethroughIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M16 4H9a3 3 0 0 0-2.83 4" /> + <path d="M14 12a4 4 0 0 1 0 8H6" /> + <line x1="4" y1="12" x2="20" y2="12" /> + </I> +) + +export const HighlighterIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="m9 11-6 6v3h3l6-6" /> + <path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" /> + </I> +) + +export const CodeIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <polyline points="16 18 22 12 16 6" /> + <polyline points="8 6 2 12 8 18" /> + </I> +) + +export const SigmaIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M18 7V5a1 1 0 0 0-1-1H7l5 8-5 8h10a1 1 0 0 0 1-1v-2" /> + </I> +) + +export const LinkIcon = (p: IconProps): JSX.Element => ( + <I {...p}> + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /> + </I> +) diff --git a/packages/app-core/src/lib/app-core.perf.test.ts b/packages/app-core/src/lib/app-core.perf.test.ts index eaf98232..0b86ad85 100644 --- a/packages/app-core/src/lib/app-core.perf.test.ts +++ b/packages/app-core/src/lib/app-core.perf.test.ts @@ -19,6 +19,7 @@ function makeNote(index: number): NoteMeta { size: 512, tags: ['perf', tag], wikilinks: [`Target ${index % 200}`], + assetEmbeds: [], hasAttachments: index % 31 === 0, excerpt: `Synthetic renderer benchmark note ${index} with phrase needle-${index} and desktop-runtime-benchmark-${id}.` } diff --git a/packages/app-core/src/lib/cm-code-block-flair.ts b/packages/app-core/src/lib/cm-code-block-flair.ts new file mode 100644 index 00000000..ed63027e --- /dev/null +++ b/packages/app-core/src/lib/cm-code-block-flair.ts @@ -0,0 +1,149 @@ +/** + * Code-block "flair" for the WYSIWYG (Edit) editor: a small label pinned to + * the top-right of each fenced code block showing its language (or "text" + * when none is given). Clicking the label copies the block's contents — it + * doubles as the copy button, so there's no separate copy/fold chrome. + * + * WYSIWYG-only: this lives in `wysiwygExtensions()` and never loads in the + * Split (source) editor. + */ +import { syntaxTree } from '@codemirror/language' +import { RangeSetBuilder } from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType +} from '@codemirror/view' + +const FENCE_RE = /^\s*(?:`{3,}|~{3,})\s*([^\s`]*)/ + +class CodeFlairWidget extends WidgetType { + constructor( + private readonly language: string, + /** Doc offsets of the block content to copy (fences excluded). */ + private readonly contentFrom: number, + private readonly contentTo: number + ) { + super() + } + + eq(other: CodeFlairWidget): boolean { + return ( + other.language === this.language && + other.contentFrom === this.contentFrom && + other.contentTo === this.contentTo + ) + } + + toDOM(view: EditorView): HTMLElement { + const button = document.createElement('button') + button.type = 'button' + button.className = 'cm-code-flair' + button.textContent = this.language + button.title = 'Copy code' + button.setAttribute('aria-label', `Copy ${this.language} code block`) + button.setAttribute('contenteditable', 'false') + + // Don't let the editor move the caret / start a selection when the label + // is pressed — copying shouldn't disturb where the user was typing. + button.addEventListener('mousedown', (event) => { + event.preventDefault() + event.stopPropagation() + }) + button.addEventListener('click', (event) => { + event.preventDefault() + event.stopPropagation() + const text = + this.contentTo > this.contentFrom + ? view.state.doc.sliceString(this.contentFrom, this.contentTo) + : '' + void navigator.clipboard?.writeText(text).then( + () => { + button.classList.add('is-copied') + button.textContent = 'Copied' + window.setTimeout(() => { + button.classList.remove('is-copied') + button.textContent = this.language + }, 1100) + }, + () => { + /* clipboard denied — leave the label as-is */ + } + ) + }) + return button + } + + ignoreEvent(): boolean { + return false + } +} + +function buildDecorations(view: EditorView): DecorationSet { + const { state } = view + const tree = syntaxTree(state) + const seen = new Set<number>() + const pending: Array<{ at: number; deco: Decoration }> = [] + + for (const { from, to } of view.visibleRanges) { + tree.iterate({ + from, + to, + enter: (node) => { + if (node.name !== 'FencedCode') return + const beginLine = state.doc.lineAt(node.from) + if (seen.has(beginLine.from)) return false + seen.add(beginLine.from) + + const langMatch = beginLine.text.match(FENCE_RE) + const language = (langMatch?.[1] || 'text').toLowerCase() + + const lastLine = state.doc.lineAt(Math.max(node.from, node.to - 1)) + // Content sits between the opening and closing fence lines. When the + // block has no body (begin === end), copy nothing. + const contentFrom = + beginLine.number < lastLine.number + ? state.doc.line(beginLine.number + 1).from + : beginLine.to + const contentTo = + lastLine.number > beginLine.number + ? state.doc.line(lastLine.number - 1).to + : beginLine.to + + pending.push({ + at: beginLine.to, + deco: Decoration.widget({ + side: 1, + widget: new CodeFlairWidget(language, contentFrom, contentTo) + }) + }) + return false + } + }) + } + + pending.sort((a, b) => a.at - b.at) + const builder = new RangeSetBuilder<Decoration>() + for (const item of pending) builder.add(item.at, item.at, item.deco) + return builder.finish() +} + +export const codeBlockFlairPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = buildDecorations(view) + } + + update(update: ViewUpdate): void { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildDecorations(update.view) + } + } + }, + { decorations: (plugin) => plugin.decorations } +) diff --git a/packages/app-core/src/lib/cm-code-block-font.ts b/packages/app-core/src/lib/cm-code-block-font.ts index 9a7f70fa..a5657114 100644 --- a/packages/app-core/src/lib/cm-code-block-font.ts +++ b/packages/app-core/src/lib/cm-code-block-font.ts @@ -20,33 +20,51 @@ import { } from '@codemirror/view' const codeBlockLine = Decoration.line({ class: 'cm-code-block-line' }) +// First / last line of each block also carry begin/end classes so the WYSIWYG +// stylesheet can round the top and bottom of the code "card". +const codeBlockBegin = Decoration.line({ class: 'cm-code-block-begin' }) +const codeBlockEnd = Decoration.line({ class: 'cm-code-block-end' }) function buildDecorations(view: EditorView): DecorationSet { const tree = syntaxTree(view.state) - // Collect unique line starts first: a block straddling the viewport gap can - // be visited under two visible ranges, so we dedupe and sort before adding to - // satisfy RangeSetBuilder's strictly-ascending requirement. - const lineStarts = new Set<number>() + // Collect each block's line starts and the first/last line so a block that + // straddles the viewport gap (visited twice) is deduped before we add to the + // builder in strictly-ascending order. + const lineClasses = new Map<number, { line: boolean; begin: boolean; end: boolean }>() + const mark = (from: number, key: 'line' | 'begin' | 'end'): void => { + const entry = lineClasses.get(from) ?? { line: false, begin: false, end: false } + entry[key] = true + lineClasses.set(from, entry) + } for (const { from, to } of view.visibleRanges) { tree.iterate({ from, to, enter: (node) => { if (node.name !== 'FencedCode' && node.name !== 'CodeBlock') return + const firstLine = view.state.doc.lineAt(node.from) + const lastLine = view.state.doc.lineAt(Math.max(node.from, node.to - 1)) let pos = node.from while (pos <= node.to) { const line = view.state.doc.lineAt(pos) - lineStarts.add(line.from) + mark(line.from, 'line') if (line.to >= node.to) break pos = line.to + 1 } + mark(firstLine.from, 'begin') + mark(lastLine.from, 'end') return false // whole block handled; skip its children }, }) } const builder = new RangeSetBuilder<Decoration>() - for (const from of [...lineStarts].sort((a, b) => a - b)) { - builder.add(from, from, codeBlockLine) + for (const from of [...lineClasses.keys()].sort((a, b) => a - b)) { + const entry = lineClasses.get(from)! + // Order matters: RangeSetBuilder keeps insertion order for equal points, + // and CodeMirror merges the classes of stacked line decorations. + if (entry.line) builder.add(from, from, codeBlockLine) + if (entry.begin) builder.add(from, from, codeBlockBegin) + if (entry.end) builder.add(from, from, codeBlockEnd) } return builder.finish() } diff --git a/packages/app-core/src/lib/cm-completion-nav.ts b/packages/app-core/src/lib/cm-completion-nav.ts index 86e68e55..17d4df74 100644 --- a/packages/app-core/src/lib/cm-completion-nav.ts +++ b/packages/app-core/src/lib/cm-completion-nav.ts @@ -1,4 +1,9 @@ -import { completionStatus, moveCompletionSelection } from '@codemirror/autocomplete' +import { + acceptCompletion, + completionStatus, + moveCompletionSelection, + selectedCompletion +} from '@codemirror/autocomplete' import { Prec } from '@codemirror/state' import { EditorView } from '@codemirror/view' @@ -48,13 +53,53 @@ export const completionNavKeymap = Prec.highest( EditorView.domEventHandlers({ keydown: (event, view) => { const direction = completionNavDirection(event) - if (!direction) return false - if (completionStatus(view.state) !== 'active') return false - const moved = moveCompletionSelection(direction === 'next')(view) - if (!moved) return false - event.preventDefault() - event.stopPropagation() - return true + if (direction) { + if (completionStatus(view.state) !== 'active') return false + if (!moveCompletionSelection(direction === 'next')(view)) return false + event.preventDefault() + event.stopPropagation() + return true + } + + const noMods = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + + // Ctrl+Y — accept the highlighted completion (Vim-style). + if ( + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === 'y' + ) { + if (completionStatus(view.state) !== 'active') return false + if (!acceptCompletion(view)) return false + event.preventDefault() + event.stopPropagation() + return true + } + + // Tab — accept, but for a note/asset wikilink keep the caret *inside* the + // `[[…]]` so you can keep typing (e.g. a `#heading` anchor). Headings, + // slash commands, etc. accept normally; with no completion Tab indents. + if (event.key === 'Tab' && noMods) { + if (completionStatus(view.state) !== 'active') return false + const completion = selectedCompletion(view.state) as + | { _kind?: string; _target?: string } + | null + if (!acceptCompletion(view)) return false + const keepsLinkOpen = completion?._kind === 'wikilink' && completion._target != null + if (keepsLinkOpen) { + const pos = view.state.selection.main.head + if (view.state.doc.sliceString(pos - 2, pos) === ']]') { + view.dispatch({ selection: { anchor: pos - 2 } }) + } + } + event.preventDefault() + event.stopPropagation() + return true + } + + return false } }) ) diff --git a/packages/app-core/src/lib/cm-format.test.ts b/packages/app-core/src/lib/cm-format.test.ts new file mode 100644 index 00000000..fcc2e447 --- /dev/null +++ b/packages/app-core/src/lib/cm-format.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment jsdom + +import { EditorSelection, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, describe, expect, it } from 'vitest' +import { setBlockType, toggleWrap, wrapLink } from './cm-format' + +const views: EditorView[] = [] +function mount(doc: string, from: number, to: number): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, selection: EditorSelection.range(from, to) }) + }) + views.push(view) + return view +} + +afterEach(() => { + while (views.length) views.pop()!.destroy() +}) + +describe('toggleWrap', () => { + it('wraps the selection and keeps it selected', () => { + const view = mount('hello world', 6, 11) // "world" + toggleWrap(view, '**') + expect(view.state.doc.toString()).toBe('hello **world**') + expect(view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to)).toBe( + 'world' + ) + }) + + it('unwraps when the markers sit just outside the selection', () => { + const view = mount('hello **world**', 8, 13) // "world" inside the **…** + toggleWrap(view, '**') + expect(view.state.doc.toString()).toBe('hello world') + }) + + it('unwraps when the selection itself includes the markers', () => { + const view = mount('a *italic* b', 2, 10) // "*italic*" + toggleWrap(view, '*') + expect(view.state.doc.toString()).toBe('a italic b') + }) + + it('inserts an empty pair with the cursor between on an empty selection', () => { + const view = mount('x', 1, 1) + toggleWrap(view, '==') + expect(view.state.doc.toString()).toBe('x====') + expect(view.state.selection.main.empty).toBe(true) + expect(view.state.selection.main.head).toBe(3) // between == and == + }) + + it('works for the other markers', () => { + for (const [marker, expected] of [ + ['~~', 'a ~~b~~ c'], + ['`', 'a `b` c'], + ['$', 'a $b$ c'] + ] as const) { + const view = mount('a b c', 2, 3) + toggleWrap(view, marker) + expect(view.state.doc.toString()).toBe(expected) + } + }) +}) + +describe('setBlockType', () => { + it('turns a paragraph into a heading', () => { + const view = mount('hello', 0, 5) + setBlockType(view, 'h1') + expect(view.state.doc.toString()).toBe('# hello') + }) + + it('re-types an existing block, replacing its marker', () => { + const view = mount('## hello', 0, 0) + setBlockType(view, 'h3') + expect(view.state.doc.toString()).toBe('### hello') + }) + + it('turns a list item back into plain text', () => { + const view = mount('- item', 0, 0) + setBlockType(view, 'paragraph') + expect(view.state.doc.toString()).toBe('item') + }) + + it('makes a to-do and a quote', () => { + const todo = mount('task', 0, 4) + setBlockType(todo, 'todo') + expect(todo.state.doc.toString()).toBe('- [ ] task') + + const quote = mount('wisdom', 0, 6) + setBlockType(quote, 'quote') + expect(quote.state.doc.toString()).toBe('> wisdom') + }) + + it('numbers a multi-line selection sequentially', () => { + const view = mount('a\nb\nc', 0, 5) + setBlockType(view, 'numbered') + expect(view.state.doc.toString()).toBe('1. a\n2. b\n3. c') + }) + + it('preserves indentation when re-typing', () => { + const view = mount(' - nested', 0, 0) + setBlockType(view, 'bullet') + expect(view.state.doc.toString()).toBe(' - nested') + }) + + it('wraps the selection in a fenced code block', () => { + const view = mount('print(1)', 0, 8) + setBlockType(view, 'code') + expect(view.state.doc.toString()).toBe('```\nprint(1)\n```') + }) +}) + +describe('wrapLink', () => { + it('wraps the selection as [text]() with the cursor in the parens', () => { + const view = mount('see docs here', 4, 8) // "docs" + wrapLink(view) + expect(view.state.doc.toString()).toBe('see [docs]() here') + // Cursor sits between ( and ): after "see [docs](" = index 11. + expect(view.state.selection.main.head).toBe(11) + }) +}) diff --git a/packages/app-core/src/lib/cm-format.ts b/packages/app-core/src/lib/cm-format.ts new file mode 100644 index 00000000..5b932313 --- /dev/null +++ b/packages/app-core/src/lib/cm-format.ts @@ -0,0 +1,157 @@ +/** + * Inline Markdown formatting commands for the selection bubble toolbar: toggle a + * symmetric marker (`**` bold, `*` italic, `~~` strike, `` ` `` code, `==` + * highlight, `$` math) around the selection, or wrap it as a link. (#201-style + * quick-format affordance.) + */ +import { EditorSelection } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' + +/** + * Toggle a symmetric inline marker around each selection range: wrap when it + * isn't wrapped, unwrap when the markers already sit just outside (or just + * inside) the selection. + */ +export function toggleWrap(view: EditorView, marker: string): boolean { + const m = marker + view.dispatch( + view.state.changeByRange((range) => { + const { from, to } = range + if (from === to) { + // No selection: insert the pair and drop the cursor between them. + return { + changes: { from, insert: m + m }, + range: EditorSelection.cursor(from + m.length) + } + } + const before = view.state.sliceDoc(Math.max(0, from - m.length), from) + const after = view.state.sliceDoc(to, Math.min(view.state.doc.length, to + m.length)) + if (before === m && after === m) { + // Unwrap: drop the markers just outside the selection. + return { + changes: [ + { from: from - m.length, to: from, insert: '' }, + { from: to, to: to + m.length, insert: '' } + ], + range: EditorSelection.range(from - m.length, to - m.length) + } + } + const selected = view.state.sliceDoc(from, to) + if (selected.length >= m.length * 2 && selected.startsWith(m) && selected.endsWith(m)) { + // The selection itself includes the markers — strip them from inside. + return { + changes: { from, to, insert: selected.slice(m.length, selected.length - m.length) }, + range: EditorSelection.range(from, to - m.length * 2) + } + } + // Wrap. + return { + changes: [ + { from, insert: m }, + { from: to, insert: m } + ], + range: EditorSelection.range(from + m.length, to + m.length) + } + }) + ) + view.focus() + return true +} + +/** + * The block types offered by the selection toolbar's "Turn into" menu — a + * lighter version of Notion's block menu. + */ +export type BlockType = + | 'paragraph' + | 'h1' + | 'h2' + | 'h3' + | 'bullet' + | 'numbered' + | 'todo' + | 'quote' + | 'code' + +// Leading block marker (indent captured separately): heading, quote, list +// bullet (optionally a task checkbox), or an ordered-list number. +const LINE_MARKER_RE = /^(\s*)(?:#{1,6}\s+|>\s+|[-*+]\s+\[[ xX]\]\s+|[-*+]\s+|\d+[.)]\s+)?/ + +function blockPrefix(type: BlockType, index: number): string { + switch (type) { + case 'h1': + return '# ' + case 'h2': + return '## ' + case 'h3': + return '### ' + case 'bullet': + return '- ' + case 'numbered': + return `${index + 1}. ` + case 'todo': + return '- [ ] ' + case 'quote': + return '> ' + default: + return '' // paragraph + } +} + +/** + * Turn the line(s) touched by the selection into a block of `type`: re-prefix + * each line (stripping any existing heading/list/quote marker), or wrap them in + * a fenced code block. "paragraph" just removes the marker. + */ +export function setBlockType(view: EditorView, type: BlockType): boolean { + const { state } = view + const sel = state.selection.main + const firstLine = state.doc.lineAt(sel.from) + const lastLine = state.doc.lineAt(sel.to) + + if (type === 'code') { + const text = state.sliceDoc(firstLine.from, lastLine.to) + const insert = '```\n' + text + '\n```' + view.dispatch({ + changes: { from: firstLine.from, to: lastLine.to, insert }, + selection: EditorSelection.range(firstLine.from + 4, firstLine.from + 4 + text.length) + }) + view.focus() + return true + } + + const changes: Array<{ from: number; to: number; insert: string }> = [] + let index = 0 + for (let ln = firstLine.number; ln <= lastLine.number; ln++) { + const line = state.doc.line(ln) + if (line.text.trim() === '') continue + const m = line.text.match(LINE_MARKER_RE) + const indent = m?.[1] ?? '' + const body = line.text.slice(m?.[0].length ?? 0) + const next = indent + blockPrefix(type, index) + body + index++ + if (next !== line.text) changes.push({ from: line.from, to: line.to, insert: next }) + } + if (changes.length > 0) view.dispatch({ changes }) + view.focus() + return true +} + +/** + * Wrap each selection as a Markdown link `[text](url)`, leaving the cursor in + * the empty `()` so the URL can be typed. An empty selection inserts `[]()`. + */ +export function wrapLink(view: EditorView): boolean { + view.dispatch( + view.state.changeByRange((range) => { + const { from, to } = range + const text = view.state.sliceDoc(from, to) + const insert = `[${text}]()` + // Cursor between the parentheses: after `[text](`. + const cursor = from + 1 + text.length + 2 + return { changes: { from, to, insert }, range: EditorSelection.cursor(cursor) } + }) + ) + view.focus() + return true +} diff --git a/packages/app-core/src/lib/cm-frontmatter.test.ts b/packages/app-core/src/lib/cm-frontmatter.test.ts new file mode 100644 index 00000000..e7e4cf78 --- /dev/null +++ b/packages/app-core/src/lib/cm-frontmatter.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom + +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { afterEach, describe, expect, it } from 'vitest' +import { frontmatterStyle } from './cm-frontmatter' + +const views: EditorView[] = [] +function mount(doc: string): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [frontmatterStyle] }) + }) + views.push(view) + return view +} + +afterEach(() => { + while (views.length) views.pop()!.destroy() +}) + +describe('frontmatterStyle', () => { + it('builds line + key decorations for a record-page frontmatter without throwing', () => { + // The mixed line + mark decorations must be added in sorted order, or the + // RangeSetBuilder throws and breaks the editor for every note with + // frontmatter. Constructing the view exercises that. + const view = mount(['---', 'New field: test', 'New field 2:', '---', '', '# test'].join('\n')) + + // Property lines render as the metadata card body; the fences become caps. + expect(view.dom.querySelector('.cm-frontmatter-top')).not.toBeNull() + expect(view.dom.querySelector('.cm-frontmatter-bottom')).not.toBeNull() + // The key (before the `:`) is marked as a label. + const key = view.dom.querySelector('.cm-frontmatter-key') + expect(key?.textContent).toBe('New field') + }) + + it('no-ops a note without leading frontmatter', () => { + const view = mount('# Just a note\n\nbody') + expect(view.dom.querySelector('.cm-frontmatter-line')).toBeNull() + }) + + it('handles a value that itself contains a colon', () => { + const view = mount(['---', 'time: 12:30', '---', '', 'body'].join('\n')) + // The key stops at the FIRST colon. + expect(view.dom.querySelector('.cm-frontmatter-key')?.textContent).toBe('time') + }) +}) diff --git a/packages/app-core/src/lib/cm-frontmatter.ts b/packages/app-core/src/lib/cm-frontmatter.ts index d2fa3273..9321a1b4 100644 --- a/packages/app-core/src/lib/cm-frontmatter.ts +++ b/packages/app-core/src/lib/cm-frontmatter.ts @@ -15,7 +15,9 @@ import { } from '@codemirror/view' const FRONTMATTER_LINE = Decoration.line({ class: 'cm-frontmatter-line' }) -const FRONTMATTER_FENCE = Decoration.line({ class: 'cm-frontmatter-line cm-frontmatter-fence' }) +const FRONTMATTER_TOP = Decoration.line({ class: 'cm-frontmatter-line cm-frontmatter-top' }) +const FRONTMATTER_BOTTOM = Decoration.line({ class: 'cm-frontmatter-line cm-frontmatter-bottom' }) +const FRONTMATTER_KEY = Decoration.mark({ class: 'cm-frontmatter-key' }) function buildFrontmatterDeco(view: EditorView): DecorationSet { const builder = new RangeSetBuilder<Decoration>() @@ -32,7 +34,19 @@ function buildFrontmatterDeco(view: EditorView): DecorationSet { if (endLine === -1) return builder.finish() for (let i = 1; i <= endLine; i++) { const line = doc.line(i) - builder.add(line.from, line.from, i === 1 || i === endLine ? FRONTMATTER_FENCE : FRONTMATTER_LINE) + // Line decoration first (its start side sorts before any mark at the same + // offset), then the key mark for property lines. + builder.add( + line.from, + line.from, + i === 1 ? FRONTMATTER_TOP : i === endLine ? FRONTMATTER_BOTTOM : FRONTMATTER_LINE + ) + if (i !== 1 && i !== endLine) { + // Mark the key (text before the first `:`) so it reads as a muted label + // next to its value — a metadata panel, not a wall of text. + const colon = line.text.indexOf(':') + if (colon > 0) builder.add(line.from, line.from + colon, FRONTMATTER_KEY) + } } return builder.finish() } diff --git a/packages/app-core/src/lib/cm-hashtags.test.ts b/packages/app-core/src/lib/cm-hashtags.test.ts new file mode 100644 index 00000000..2e3e1ae0 --- /dev/null +++ b/packages/app-core/src/lib/cm-hashtags.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { hashtagExtension } from './cm-hashtags' + +function mount(doc: string): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage }), hashtagExtension] + }) + }) + forceParsing(view, doc.length, 5000) + view.dispatch({ changes: { from: doc.length, insert: ' ' } }) + view.dispatch({ changes: { from: doc.length, to: doc.length + 1 } }) + return view +} + +describe('hashtagPlugin', () => { + it('marks inline hashtags as pills', () => { + const view = mount('Tags: #tubex #peertube #crawler done') + expect(view.dom.querySelectorAll('.cm-hashtag').length).toBe(3) + view.destroy() + }) + + it('recognizes non-Latin tags — Cyrillic, CJK (#205)', () => { + const view = mount('Заметки: #тест #ошибка и 笔记 #标签 done') + const tags = Array.from(view.dom.querySelectorAll('.cm-hashtag')).map((e) => e.textContent) + expect(tags).toEqual(['#тест', '#ошибка', '#标签']) + view.destroy() + }) + + it('skips `#` inside code and headings', () => { + const view = mount('# Heading #notatag\n\nuse `#include` here\n\n#real') + const tags = Array.from(view.dom.querySelectorAll('.cm-hashtag')).map( + (e) => e.textContent + ) + expect(tags).toEqual(['#real']) + view.destroy() + }) +}) diff --git a/packages/app-core/src/lib/cm-hashtags.ts b/packages/app-core/src/lib/cm-hashtags.ts new file mode 100644 index 00000000..3d7cc34b --- /dev/null +++ b/packages/app-core/src/lib/cm-hashtags.ts @@ -0,0 +1,103 @@ +/** + * Styles Obsidian-style inline hashtags (`#tag`) as pills in the WYSIWYG + * editor, mirroring how the Preview pipeline (`remarkHashtags`) renders them. + * The `#tag` text stays editable — we only add a `cm-hashtag` mark — so this + * needs no active-line handling. Matches in code spans/blocks and headings are + * skipped, same as Preview. + * + * WYSIWYG-only: registered via `wysiwygExtensions()`. + */ +import { syntaxTree } from '@codemirror/language' +import { RangeSetBuilder } from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate +} from '@codemirror/view' +import { useStore } from '../store' + +// Same shape as the Preview regex: a `#`, preceded by start-of-text or +// whitespace, then any Unicode letter (Cyrillic/CJK/… included, #205) followed +// by letters, digits, `_`, `-`, or `/`. +const HASHTAG_RE = /(^|\s)#(\p{L}[\p{L}\d_/-]*)/gu + +/** True when `pos` sits inside a code span/block or a heading — contexts where + * a `#` isn't a tag (`#include`, `# Heading`, …). */ +function skipContext(state: EditorView['state'], pos: number): boolean { + let node = syntaxTree(state).resolveInner(pos, 1) + while (node) { + const n = node.name + if ( + n === 'FencedCode' || + n === 'CodeBlock' || + n === 'InlineCode' || + n.startsWith('ATXHeading') || + n.startsWith('SetextHeading') + ) { + return true + } + if (!node.parent) break + node = node.parent + } + return false +} + +function buildDecorations(view: EditorView): DecorationSet { + const { state } = view + const builder = new RangeSetBuilder<Decoration>() + for (const { from, to } of view.visibleRanges) { + const firstLine = state.doc.lineAt(from).number + const lastLine = state.doc.lineAt(Math.max(from, to - 1)).number + for (let n = firstLine; n <= lastLine; n++) { + const line = state.doc.line(n) + if (!line.text.includes('#')) continue + HASHTAG_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = HASHTAG_RE.exec(line.text)) !== null) { + const tagStart = line.from + m.index + m[1].length + const tagEnd = tagStart + 1 + m[2].length + if (skipContext(state, tagStart)) continue + // Per-match so the tag name rides along for the click handler. + builder.add( + tagStart, + tagEnd, + Decoration.mark({ class: 'cm-hashtag', attributes: { 'data-tag': m[2] } }) + ) + } + } + } + return builder.finish() +} + +const hashtagPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + constructor(view: EditorView) { + this.decorations = buildDecorations(view) + } + update(update: ViewUpdate): void { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildDecorations(update.view) + } + } + }, + { decorations: (p) => p.decorations } +) + +// Clicking a hashtag opens the tag view (Obsidian-style). We intercept on +// mousedown so CodeMirror doesn't first drop the caret into the tag. +const hashtagClick = EditorView.domEventHandlers({ + mousedown: (event) => { + const target = event.target as HTMLElement | null + const el = target?.closest<HTMLElement>('.cm-hashtag') + const tag = el?.dataset.tag + if (!tag) return false + event.preventDefault() + void useStore.getState().openTagView(tag) + return true + } +}) + +export const hashtagExtension = [hashtagPlugin, hashtagClick] diff --git a/packages/app-core/src/lib/cm-live-preview.test.ts b/packages/app-core/src/lib/cm-live-preview.test.ts index e775f74a..416523f1 100644 --- a/packages/app-core/src/lib/cm-live-preview.test.ts +++ b/packages/app-core/src/lib/cm-live-preview.test.ts @@ -53,6 +53,17 @@ describe('livePreviewPlugin', () => { view.destroy() }) + it('keeps the colon visible in a reference-link definition (#188)', () => { + // The `:` parses as a LinkMark; live preview must not hide it, or the + // definition reads as a broken `[label] url`. + const doc = 'intro\n\n[Markdown Lang]: https://www.markdownlang.com' + const view = mountEditor(doc, 0) // cursor on "intro" → definition line inactive + + expect(view.dom.textContent).toContain('[Markdown Lang]: https://www.markdownlang.com') + + view.destroy() + }) + it('keeps heading markers hidden when editing the heading text', () => { const doc = '# Code blocks\n\nBody' const view = mountEditor(doc, doc.indexOf('Code')) @@ -73,9 +84,9 @@ describe('livePreviewPlugin', () => { }) it('replaces an unchecked task marker with a checkbox widget', () => { - const doc = '- [ ] Buy milk' - // Cursor at end of line, off the marker. - const view = mountEditor(doc, doc.length) + // Cursor on the intro line — the task line is inactive, so it renders. + const doc = 'intro\n\n- [ ] Buy milk' + const view = mountEditor(doc, 0) const inputs = view.dom.querySelectorAll<HTMLInputElement>('input.cm-task-checkbox-input') expect(inputs).toHaveLength(1) @@ -89,8 +100,8 @@ describe('livePreviewPlugin', () => { }) it('replaces a checked task marker with a checked checkbox', () => { - const doc = '- [x] Done\n- [X] Also done' - const view = mountEditor(doc, doc.length) + const doc = 'intro\n\n- [x] Done\n- [X] Also done' + const view = mountEditor(doc, 0) const inputs = view.dom.querySelectorAll<HTMLInputElement>('input.cm-task-checkbox-input') expect(inputs).toHaveLength(2) @@ -114,35 +125,36 @@ describe('livePreviewPlugin', () => { }) it('toggles the underlying marker when the checkbox is clicked', () => { - const doc = '- [ ] Buy milk' - const view = mountEditor(doc, doc.length) + const doc = 'intro\n\n- [ ] Buy milk' + const view = mountEditor(doc, 0) const input = view.dom.querySelector<HTMLInputElement>('input.cm-task-checkbox-input') expect(input).toBeTruthy() input!.click() - expect(view.state.doc.toString()).toBe('- [x] Buy milk') + expect(view.state.doc.toString()).toBe('intro\n\n- [x] Buy milk') view.destroy() }) it('toggles back to unchecked from a `[x]` marker', () => { - const doc = '- [x] Already done' - const view = mountEditor(doc, doc.length) + const doc = 'intro\n\n- [x] Already done' + const view = mountEditor(doc, 0) const input = view.dom.querySelector<HTMLInputElement>('input.cm-task-checkbox-input') expect(input).toBeTruthy() input!.click() - expect(view.state.doc.toString()).toBe('- [ ] Already done') + expect(view.state.doc.toString()).toBe('intro\n\n- [ ] Already done') view.destroy() }) it('renders checkboxes for ordered, nested, and quoted tasks', () => { - // Task variants the TASK_LINE_RE in shared/tasklists supports. - const doc = ['1. [ ] Ordered', ' - [x] Nested', '> - [ ] Quoted'].join('\n') - const view = mountEditor(doc, doc.length) + // Task variants the TASK_LINE_RE in shared/tasklists supports. Cursor on + // the intro line so every task line is inactive (and thus rendered). + const doc = ['intro', '1. [ ] Ordered', ' - [x] Nested', '> - [ ] Quoted'].join('\n') + const view = mountEditor(doc, 0) const inputs = view.dom.querySelectorAll<HTMLInputElement>('input.cm-task-checkbox-input') expect(inputs).toHaveLength(3) diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index a54fd4d0..d256bfbc 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -724,11 +724,11 @@ function computeDecorations(view: EditorView): DecorationSet { if (name === TASK_MARKER_NODE) { const line = state.doc.lineAt(node.from).number if (replacedLines.has(line)) return - // Reveal the raw `[ ]` / `[x]` so the user can edit it directly - // when the cursor lands inside the marker. Cursor elsewhere on - // the line still shows the checkbox — same model headings use - // for `#` markers. - if (selectionTouchesRange(state, node.from, node.to)) return + // Reveal the raw `[ ]` / `[x]` on the active line so the whole task + // line reads as source — matching Obsidian, and consistent with the + // list/quote/heading markers, which also reveal on the active line. + // Off the line, render the checkbox. + if (activeLines.has(line)) return const markerText = state.doc.sliceString(node.from, node.to) // `markerText` is `[ ]` / `[x]` / `[X]`; default to unchecked if the // parser ever hands us something unexpected. @@ -757,6 +757,11 @@ function computeDecorations(view: EditorView): DecorationSet { if ((name === 'CodeMark' || name === 'CodeInfo') && node.node.parent?.name === 'FencedCode') return + // The `:` in a reference-link definition (`[label]: url`) parses as a + // LinkMark whose parent is LinkReference. Keep it visible — hiding it + // makes the definition read as a broken `[label] url`. (#188) + if (name === 'LinkMark' && node.node.parent?.name === 'LinkReference') return + const line = state.doc.lineAt(node.from).number if (replacedLines.has(line)) return if (isLinkSyntax) { diff --git a/packages/app-core/src/lib/cm-markdown-snippets.test.ts b/packages/app-core/src/lib/cm-markdown-snippets.test.ts new file mode 100644 index 00000000..6e651d3c --- /dev/null +++ b/packages/app-core/src/lib/cm-markdown-snippets.test.ts @@ -0,0 +1,150 @@ +import { EditorState } from '@codemirror/state' +import { describe, expect, it } from 'vitest' +import { markdownSnippetExtension, markdownSnippetTransaction } from './cm-markdown-snippets' + +function createState(doc: string, pos = doc.length): EditorState { + return EditorState.create({ + doc, + selection: { anchor: pos }, + extensions: [markdownSnippetExtension()] + }) +} + +function typeInput(state: EditorState, text: string): EditorState { + const pos = state.selection.main.head + return state.update({ + changes: { from: pos, to: pos, insert: text }, + selection: { anchor: pos + text.length }, + userEvent: 'input.type' + }).state +} + +function typeChars(state: EditorState, text: string): EditorState { + let next = state + for (const char of text) next = typeInput(next, char) + return next +} + +function selectionOnlyUpdate(state: EditorState): EditorState { + return state.update({ + selection: { anchor: state.selection.main.head } + }).state +} + +function triggerSnippet(state: EditorState, key: string): EditorState | null { + const transaction = markdownSnippetTransaction(state, key) + if (!transaction) return null + return state.update(transaction).state +} + +function typeThenTrigger(doc: string, typed: string, key: string, pos = doc.length): EditorState | null { + return triggerSnippet(typeInput(createState(doc, pos), typed), key) +} + +function applySnippet(doc: string, key: string, pos = doc.length): EditorState | null { + return triggerSnippet(createState(doc, pos), key) +} + +describe('markdownSnippetTransaction', () => { + it('expands a backtick fence with Enter', () => { + const state = typeThenTrigger('', '```', 'Enter') + + expect(state?.doc.toString()).toBe('```\n\n```') + expect(state?.selection.main.head).toBe(4) + }) + + it('expands a backtick fence after character-by-character typing', () => { + const state = triggerSnippet(typeChars(createState(''), '```'), 'Enter') + + expect(state?.doc.toString()).toBe('```\n\n```') + expect(state?.selection.main.head).toBe(4) + }) + + it('keeps a pending block snippet across selection-only updates', () => { + const state = triggerSnippet(selectionOnlyUpdate(typeChars(createState(''), '```')), 'Enter') + + expect(state?.doc.toString()).toBe('```\n\n```') + expect(state?.selection.main.head).toBe(4) + }) + + it('does not expand block snippets with Space', () => { + expect(applySnippet('```', 'Space')).toBeNull() + expect(applySnippet('~~~', 'Space')).toBeNull() + expect(applySnippet('$$', 'Space')).toBeNull() + }) + + it('preserves indentation for block snippets', () => { + const state = typeThenTrigger('', ' $$', 'Enter') + + expect(state?.doc.toString()).toBe(' $$\n \n $$') + expect(state?.selection.main.head).toBe(7) + }) + + it('does not expand an already closed block', () => { + expect(applySnippet('```\nbody\n```', 'Enter', 3)).toBeNull() + }) + + it('does not treat a closing block delimiter as a new opener', () => { + expect(typeThenTrigger('```\ncode\n', '```', 'Enter')).toBeNull() + expect(typeThenTrigger('``` js\ncode\n', '```', 'Enter')).toBeNull() + expect(typeThenTrigger('```js\ncode\n', '```', 'Enter')).toBeNull() + expect(typeThenTrigger('$$\nmath\n', '$$', 'Enter')).toBeNull() + }) + + it('still expands a later block delimiter after a closed block', () => { + const state = typeThenTrigger('```\ncode\n```\n', '```', 'Enter') + + expect(state?.doc.toString()).toBe('```\ncode\n```\n```\n\n```') + expect(state?.selection.main.head).toBe(17) + }) + + it('expands after a prior fenced code block with an info string', () => { + const state = typeThenTrigger('```ts\nconst mode = "preview"\n```\n', '```', 'Enter') + + expect(state?.doc.toString()).toBe('```ts\nconst mode = "preview"\n```\n```\n\n```') + }) + + it('expands before an existing fenced code block with an info string', () => { + const doc = '\n```ts\nconst mode = "preview"\n```' + const state = typeThenTrigger(doc, '```', 'Enter', 0) + + expect(state?.doc.toString()).toBe('```\n\n```\n```ts\nconst mode = "preview"\n```') + }) + + it('expands inline strong markup with Space', () => { + const state = applySnippet('**', 'Space') + + expect(state?.doc.toString()).toBe('****') + expect(state?.selection.main.head).toBe(2) + }) + + it('expands wikilinks with Space', () => { + const state = applySnippet('[[', 'Space') + + expect(state?.doc.toString()).toBe('[[]]') + expect(state?.selection.main.head).toBe(2) + }) + + it('does not expand inline markup that is already closed', () => { + expect(applySnippet('****', 'Space', 2)).toBeNull() + }) + + it('does not treat closing delimiters as new openers', () => { + expect(applySnippet('**text**', 'Space')).toBeNull() + expect(applySnippet('`code`', 'Space')).toBeNull() + expect(applySnippet('~~done~~', 'Space')).toBeNull() + expect(applySnippet('%%comment%%', 'Space')).toBeNull() + }) + + it('still expands a later unmatched delimiter after a closed pair', () => { + const state = applySnippet('**text** **', 'Space') + + expect(state?.doc.toString()).toBe('**text** ****') + expect(state?.selection.main.head).toBe(11) + }) + + it('does not handle unrelated keys or text', () => { + expect(applySnippet('**', 'Enter')).toBeNull() + expect(applySnippet('hello', 'Space')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/cm-markdown-snippets.ts b/packages/app-core/src/lib/cm-markdown-snippets.ts new file mode 100644 index 00000000..f68aad50 --- /dev/null +++ b/packages/app-core/src/lib/cm-markdown-snippets.ts @@ -0,0 +1,259 @@ +import { + Facet, + Prec, + StateField, + type EditorState, + type Extension, + type TransactionSpec +} from '@codemirror/state' +import { keymap, type EditorView } from '@codemirror/view' + +export type MarkdownSnippetMode = 'inline' | 'block' + +export interface MarkdownSnippetRule { + id: string + open: string + close: string + triggerKeys: readonly string[] + mode: MarkdownSnippetMode +} + +export interface MarkdownSnippetExtensionConfig { + rules?: readonly MarkdownSnippetRule[] + shouldHandle?: (view: EditorView) => boolean +} + +interface PendingBlockSnippet { + ruleId: string + lineFrom: number +} + +export const defaultMarkdownSnippetRules: readonly MarkdownSnippetRule[] = [ + { id: 'fenced-code-backtick', open: '```', close: '```', triggerKeys: ['Enter'], mode: 'block' }, + { id: 'fenced-code-tilde', open: '~~~', close: '~~~', triggerKeys: ['Enter'], mode: 'block' }, + { id: 'math-block', open: '$$', close: '$$', triggerKeys: ['Enter'], mode: 'block' }, + { id: 'strong-asterisk', open: '**', close: '**', triggerKeys: ['Space'], mode: 'inline' }, + { id: 'strong-underscore', open: '__', close: '__', triggerKeys: ['Space'], mode: 'inline' }, + { id: 'strikethrough', open: '~~', close: '~~', triggerKeys: ['Space'], mode: 'inline' }, + { id: 'inline-code', open: '`', close: '`', triggerKeys: ['Space'], mode: 'inline' }, + { id: 'highlight', open: '==', close: '==', triggerKeys: ['Space'], mode: 'inline' }, + { id: 'wikilink', open: '[[', close: ']]', triggerKeys: ['Space'], mode: 'inline' }, + { id: 'comment', open: '%%', close: '%%', triggerKeys: ['Space'], mode: 'inline' } +] + +const markdownSnippetRulesFacet = Facet.define< + readonly MarkdownSnippetRule[], + readonly MarkdownSnippetRule[] +>({ + combine: (values) => values.at(-1) ?? defaultMarkdownSnippetRules +}) + +function isBlockOpenerLine(rule: MarkdownSnippetRule, text: string): boolean { + const content = text.trimEnd().trimStart() + if (!content.startsWith(rule.open)) return false + const after = content.slice(rule.open.length) + if (rule.open === '$$') return after.trim() === '' + return true +} + +function isBlockCloserLine(rule: MarkdownSnippetRule, text: string): boolean { + return text.trim() === rule.close +} + +function hasUnclosedBlockOpenerAbove( + state: EditorState, + lineNumber: number, + rule: MarkdownSnippetRule +): boolean { + let open = false + for (let number = 1; number < lineNumber; number++) { + const text = state.doc.line(number).text + if (rule.open === rule.close) { + if (isBlockOpenerLine(rule, text)) open = !open + } else if (isBlockOpenerLine(rule, text)) { + open = true + } else if (isBlockCloserLine(rule, text)) { + open = false + } + } + return open +} + +function blockPendingAt( + state: EditorState, + pos: number, + rules: readonly MarkdownSnippetRule[] +): PendingBlockSnippet | null { + const line = state.doc.lineAt(pos) + if (pos !== line.to) return null + + for (const rule of rules) { + if (rule.mode !== 'block') continue + if (!isBlockOpenerLine(rule, line.text)) continue + if (hasUnclosedBlockOpenerAbove(state, line.number, rule)) continue + return { ruleId: rule.id, lineFrom: line.from } + } + return null +} + +const pendingBlockSnippetField = StateField.define<PendingBlockSnippet | null>({ + create: () => null, + update: (pending, tr) => { + if (!tr.docChanged) { + if (!pending) return null + const selection = tr.state.selection.main + if (!selection.empty) return null + const line = tr.state.doc.lineAt(selection.head) + if (line.from !== pending.lineFrom || selection.head !== line.to) return null + const rule = tr.state + .facet(markdownSnippetRulesFacet) + .find((candidate) => candidate.id === pending.ruleId) + if (!rule || !isBlockOpenerLine(rule, line.text)) return null + return pending + } + if (!tr.isUserEvent('input')) return null + const selection = tr.state.selection.main + if (!selection.empty) return null + return blockPendingAt(tr.state, selection.head, tr.state.facet(markdownSnippetRulesFacet)) + } +}) + +export function pendingMarkdownBlockSnippetLineFrom(state: EditorState): number | null { + return state.field(pendingBlockSnippetField, false)?.lineFrom ?? null +} + +export function hasPendingMarkdownBlockSnippet(state: EditorState): boolean { + return pendingMarkdownBlockSnippetLineFrom(state) != null +} + +export function isPendingMarkdownBlockSnippetStart(state: EditorState, from: number): boolean { + return pendingMarkdownBlockSnippetLineFrom(state) === state.doc.lineAt(from).from +} + +function blockSnippetTransaction( + state: EditorState, + pending: PendingBlockSnippet, + rules: readonly MarkdownSnippetRule[] +): TransactionSpec | null { + const rule = rules.find((candidate) => candidate.id === pending.ruleId) + if (!rule) return null + + const selection = state.selection.main + if (!selection.empty) return null + const line = state.doc.lineAt(selection.head) + if (line.from !== pending.lineFrom || selection.head !== line.to) return null + if (!isBlockOpenerLine(rule, line.text)) return null + if (hasUnclosedBlockOpenerAbove(state, line.number, rule)) return null + + const indentMatch = line.text.match(/^[\t ]*/) + const indent = indentMatch?.[0] ?? '' + + const insert = `${line.text}\n${indent}\n${indent}${rule.close}` + const cursor = line.from + line.text.length + 1 + indent.length + return { + changes: { from: line.from, to: line.to, insert }, + selection: { anchor: cursor } + } +} + +function hasOddBackslashRun(text: string, before: number): boolean { + let count = 0 + for (let i = before - 1; i >= 0 && text[i] === '\\'; i--) count++ + return count % 2 === 1 +} + +function countUnescapedOccurrences(text: string, token: string): number { + let count = 0 + for (let index = 0; index <= text.length - token.length; index++) { + if (text.slice(index, index + token.length) !== token) continue + if (!hasOddBackslashRun(text, index)) count++ + index += token.length - 1 + } + return count +} + +function isOpeningDelimiter(state: EditorState, rule: MarkdownSnippetRule, from: number): boolean { + const line = state.doc.lineAt(from) + const before = state.doc.sliceString(line.from, from) + if (hasOddBackslashRun(before, before.length)) return false + if (rule.open === rule.close && countUnescapedOccurrences(before, rule.open) % 2 === 1) { + return false + } + return true +} + +function inlineSnippetTransaction( + state: EditorState, + rule: MarkdownSnippetRule, + pos: number +): TransactionSpec | null { + if (pos < rule.open.length) return null + const from = pos - rule.open.length + if (state.doc.sliceString(from, pos) !== rule.open) return null + if (!isOpeningDelimiter(state, rule, from)) return null + if ( + rule.open.length > 0 && + [...rule.open].every((char) => char === rule.open[0]) && + from > 0 && + state.doc.sliceString(from - 1, from) === rule.open[0] + ) { + return null + } + if (state.doc.sliceString(pos, Math.min(state.doc.length, pos + rule.close.length)) === rule.close) { + return null + } + + return { + changes: { from, to: pos, insert: rule.open + rule.close }, + selection: { anchor: from + rule.open.length } + } +} + +export function markdownSnippetTransaction( + state: EditorState, + triggerKey: string +): TransactionSpec | null { + const selection = state.selection.main + if (!selection.empty) return null + + const rules = state.facet(markdownSnippetRulesFacet) + const pending = state.field(pendingBlockSnippetField, false) + if (pending) { + const rule = rules.find((candidate) => candidate.id === pending.ruleId) + if (rule?.triggerKeys.includes(triggerKey)) { + const transaction = blockSnippetTransaction(state, pending, rules) + if (transaction) return transaction + } + } + + for (const rule of rules) { + if (rule.mode === 'block') continue + if (!rule.triggerKeys.includes(triggerKey)) continue + const transaction = inlineSnippetTransaction(state, rule, selection.head) + if (transaction) return transaction + } + return null +} + +export function markdownSnippetExtension(config: MarkdownSnippetExtensionConfig = {}): Extension { + const rules = config.rules ?? defaultMarkdownSnippetRules + const triggerKeys = [...new Set(rules.flatMap((rule) => rule.triggerKeys))] + return [ + markdownSnippetRulesFacet.of(rules), + pendingBlockSnippetField, + Prec.high( + keymap.of( + triggerKeys.map((triggerKey) => ({ + key: triggerKey, + run: (view) => { + if (config.shouldHandle && !config.shouldHandle(view)) return false + const transaction = markdownSnippetTransaction(view.state, triggerKey) + if (!transaction) return false + view.dispatch(transaction) + return true + } + })) + ) + ) + ] +} diff --git a/packages/app-core/src/lib/cm-slash-commands.ts b/packages/app-core/src/lib/cm-slash-commands.ts index 38eb79c1..f940ba14 100644 --- a/packages/app-core/src/lib/cm-slash-commands.ts +++ b/packages/app-core/src/lib/cm-slash-commands.ts @@ -9,6 +9,11 @@ interface SlashCmd { insert: string /** Cursor offset from end of inserted text. Negative = move back. */ cursorOffset?: number + /** Ranking nudge for the unfiltered list — higher floats to the top. */ + boost?: number + /** Extra space-separated terms that should also match this command (e.g. + * `/todo` finding "Task"). Folded into the match label, hidden from display. */ + keywords?: string } type DecoratedCompletion = Completion & { @@ -22,9 +27,16 @@ const COMMANDS: SlashCmd[] = [ { label: 'Heading 2', detail: '##', icon: 'H2', insert: '## ' }, { label: 'Heading 3', detail: '###', icon: 'H3', insert: '### ' }, { label: 'Heading 4', detail: '####', icon: 'H4', insert: '#### ' }, + { + label: 'Task', + detail: '[ ]', + icon: '☐', + insert: '- [ ] ', + boost: 99, + keywords: 'todo to-do checkbox check task list' + }, { label: 'Bulleted list', detail: '-', icon: '•', insert: '- ' }, { label: 'Numbered list', detail: '1.', icon: '1.', insert: '1. ' }, - { label: 'To-do list', detail: '[ ]', icon: '☐', insert: '- [ ] ' }, { label: 'Quote', detail: '>', icon: '❝', insert: '> ' }, { label: 'Code block', detail: '```', icon: '</>', insert: '```\n\n```', cursorOffset: -4 }, { label: 'Divider', detail: '---', icon: '—', insert: '---\n' }, @@ -69,7 +81,7 @@ function renderCompletion(completion: Completion): HTMLElement { const label = document.createElement('span') label.className = 'slash-cmd-label' - label.textContent = completion.label + label.textContent = completion.displayLabel ?? completion.label const detail = document.createElement('span') detail.className = 'slash-cmd-detail' @@ -101,8 +113,12 @@ export function slashCommandSource(context: CompletionContext): CompletionResult from: filterFrom, options: COMMANDS.map( (cmd): Completion => ({ - label: cmd.label, + // Fold alias keywords into the matched label so `/todo`, `/checkbox`, + // etc. surface the command; `displayLabel` keeps the menu text clean. + label: cmd.keywords ? `${cmd.label} ${cmd.keywords}` : cmd.label, + displayLabel: cmd.label, detail: cmd.detail, + boost: cmd.boost, _kind: 'slash', // Store icon for the custom renderer _icon: cmd.icon, diff --git a/packages/app-core/src/lib/cm-table-menu.ts b/packages/app-core/src/lib/cm-table-menu.ts new file mode 100644 index 00000000..9125ba3d --- /dev/null +++ b/packages/app-core/src/lib/cm-table-menu.ts @@ -0,0 +1,246 @@ +/** + * Right-click context menu for the WYSIWYG table widget. Self-contained DOM + * menu (the widget lives outside React), mapping Obsidian's table action set + * onto the pure ops in `markdown-table.ts`. Each action produces a new table + * model and hands it to `apply`, which re-serializes and commits. + */ +import { + deleteColumn, + deleteRow, + duplicateColumn, + duplicateRow, + insertColumn, + insertRow, + moveColumn, + moveRow, + setColumnAlign, + sortByColumn, + columnCount, + type ColumnAlign, + type MarkdownTable +} from './markdown-table' + +export interface TableMenuRequest { + x: number + y: number + /** Clicked cell — `row === -1` is the header row. */ + row: number + col: number + model: MarkdownTable + apply: (next: MarkdownTable, focus?: { row: number; col: number }) => void +} + +type MenuItem = + | { kind: 'sep' } + | { kind: 'item'; label: string; disabled?: boolean; run: () => void } + +let openMenu: HTMLElement | null = null +let teardown: (() => void) | null = null + +export function closeTableContextMenu(): void { + if (teardown) teardown() +} + +export function openTableContextMenu(req: TableMenuRequest): void { + closeTableContextMenu() + const { row, col, model, apply } = req + // Restore focus to whatever opened the menu (e.g. a table cell) on close, + // unless an action ran — that focuses its own target cell. + const previouslyFocused = document.activeElement as HTMLElement | null + let actioned = false + const lastRow = model.rows.length - 1 + const lastCol = columnCount(model) - 1 + const onBody = row >= 0 + + const items: MenuItem[] = [ + { + kind: 'item', + label: 'Add row above', + disabled: !onBody, + run: () => apply(insertRow(model, row), { row, col }) + }, + { + kind: 'item', + label: 'Add row below', + run: () => { + const at = onBody ? row + 1 : 0 + apply(insertRow(model, at), { row: at, col }) + } + }, + { + kind: 'item', + label: 'Add column before', + run: () => apply(insertColumn(model, col), { row, col }) + }, + { + kind: 'item', + label: 'Add column after', + run: () => apply(insertColumn(model, col + 1), { row, col: col + 1 }) + }, + { kind: 'sep' }, + { + kind: 'item', + label: 'Move row up', + disabled: !onBody || row === 0, + run: () => apply(moveRow(model, row, row - 1), { row: row - 1, col }) + }, + { + kind: 'item', + label: 'Move row down', + disabled: !onBody || row === lastRow, + run: () => apply(moveRow(model, row, row + 1), { row: row + 1, col }) + }, + { + kind: 'item', + label: 'Move column left', + disabled: col === 0, + run: () => apply(moveColumn(model, col, col - 1), { row, col: col - 1 }) + }, + { + kind: 'item', + label: 'Move column right', + disabled: col === lastCol, + run: () => apply(moveColumn(model, col, col + 1), { row, col: col + 1 }) + }, + { kind: 'sep' }, + { + kind: 'item', + label: 'Duplicate row', + disabled: !onBody, + run: () => apply(duplicateRow(model, row), { row: row + 1, col }) + }, + { + kind: 'item', + label: 'Duplicate column', + run: () => apply(duplicateColumn(model, col), { row, col: col + 1 }) + }, + { kind: 'sep' }, + { + kind: 'item', + label: 'Delete row', + disabled: !onBody, + run: () => apply(deleteRow(model, row)) + }, + { + kind: 'item', + label: 'Delete column', + disabled: columnCount(model) <= 1, + run: () => apply(deleteColumn(model, col)) + }, + { kind: 'sep' }, + alignItem('Align left', 'left', col, model, apply, row), + alignItem('Align center', 'center', col, model, apply, row), + alignItem('Align right', 'right', col, model, apply, row), + { kind: 'sep' }, + { + kind: 'item', + label: 'Sort column (A → Z)', + run: () => apply(sortByColumn(model, col, 'asc')) + }, + { + kind: 'item', + label: 'Sort column (Z → A)', + run: () => apply(sortByColumn(model, col, 'desc')) + } + ] + + const menu = document.createElement('div') + menu.className = 'cm-table-menu' + menu.setAttribute('role', 'menu') + + for (const item of items) { + if (item.kind === 'sep') { + const sep = document.createElement('div') + sep.className = 'cm-table-menu-sep' + menu.append(sep) + continue + } + const button = document.createElement('button') + button.type = 'button' + button.className = 'cm-table-menu-item' + button.textContent = item.label + if (item.disabled) { + button.disabled = true + } else { + button.addEventListener('mousedown', (e) => e.preventDefault()) + button.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + actioned = true + item.run() + closeTableContextMenu() + }) + } + menu.append(button) + } + + document.body.append(menu) + openMenu = menu + + // Position, flipping to stay on-screen. + const rect = menu.getBoundingClientRect() + const x = Math.min(req.x, window.innerWidth - rect.width - 8) + const y = Math.min(req.y, window.innerHeight - rect.height - 8) + menu.style.left = `${Math.max(8, x)}px` + menu.style.top = `${Math.max(8, y)}px` + + // Keyboard navigation (Vim-friendly): j/k or ↓/↑ move the highlight, Enter + // invokes, Esc closes. Lets the whole action set be driven without a mouse. + const enabledButtons = Array.from( + menu.querySelectorAll<HTMLButtonElement>('.cm-table-menu-item:not(:disabled)') + ) + let activeIndex = 0 + const focusItem = (i: number): void => { + if (enabledButtons.length === 0) return + activeIndex = (i + enabledButtons.length) % enabledButtons.length + enabledButtons[activeIndex].focus() + } + + const onDown = (e: MouseEvent): void => { + if (!menu.contains(e.target as Node)) closeTableContextMenu() + } + const NAV_KEYS = ['Escape', 'ArrowDown', 'j', 'ArrowUp', 'k', 'Enter', ' '] + const onKey = (e: KeyboardEvent): void => { + if (!NAV_KEYS.includes(e.key)) return + // Consume the key fully so it can't also drive global/editor shortcuts + // while the menu is open. + e.preventDefault() + e.stopPropagation() + if (e.key === 'Escape') closeTableContextMenu() + else if (e.key === 'ArrowDown' || e.key === 'j') focusItem(activeIndex + 1) + else if (e.key === 'ArrowUp' || e.key === 'k') focusItem(activeIndex - 1) + else enabledButtons[activeIndex]?.click() + } + // Defer so the originating contextmenu/right-click doesn't immediately close it. + setTimeout(() => { + window.addEventListener('mousedown', onDown, true) + window.addEventListener('keydown', onKey, true) + focusItem(0) + }, 0) + + teardown = () => { + window.removeEventListener('mousedown', onDown, true) + window.removeEventListener('keydown', onKey, true) + menu.remove() + if (openMenu === menu) openMenu = null + teardown = null + if (!actioned) previouslyFocused?.focus?.() + } +} + +function alignItem( + label: string, + align: ColumnAlign, + col: number, + model: MarkdownTable, + apply: TableMenuRequest['apply'], + row: number +): MenuItem { + const active = model.aligns[col] === align + return { + kind: 'item', + label: active ? `${label} ✓` : label, + run: () => + apply(setColumnAlign(model, col, active ? 'none' : align), { row, col }) + } +} diff --git a/packages/app-core/src/lib/cm-table.test.ts b/packages/app-core/src/lib/cm-table.test.ts new file mode 100644 index 00000000..89a02810 --- /dev/null +++ b/packages/app-core/src/lib/cm-table.test.ts @@ -0,0 +1,245 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { history } from '@codemirror/commands' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { + tablePlugin, + nextWordStart, + prevWordStart, + nextWordEnd, + textObjectRange +} from './cm-table' +import { closeTableContextMenu } from './cm-table-menu' + +const TABLE_DOC = `Intro text. + +| Name | Age | +| --- | --- | +| Alice | 30 | +| Bob | 25 | + +Outro text.` + +function mount(doc: string): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + extensions: [markdown({ base: markdownLanguage }), history(), tablePlugin] + }) + }) + // Ensure the GFM table node is parsed, then nudge the field to rebuild. + forceParsing(view, doc.length, 5000) + view.dispatch({ changes: { from: 0, insert: ' ' } }) + view.dispatch({ changes: { from: 0, to: 1 } }) + return view +} + +describe('tablePlugin', () => { + it('renders a GFM table as an editable table widget without throwing', () => { + const view = mount(TABLE_DOC) + const widget = view.dom.querySelector('.cm-table-widget') + expect(widget).toBeTruthy() + const cells = widget?.querySelectorAll('.cm-table-cell') ?? [] + // 2 header + 4 body cells. + expect(cells.length).toBe(6) + expect(view.dom.textContent).toContain('Alice') + expect(view.dom.textContent).toContain('Age') + // One row grip per body row (2), one column grip per column (2). + expect(widget?.querySelectorAll('.cm-table-row-handle').length).toBe(2) + expect(widget?.querySelectorAll('.cm-table-col-handle').length).toBe(2) + view.destroy() + }) + + it('renders a plain doc with no table widget', () => { + const view = mount('Just a paragraph, no table here.') + expect(view.dom.querySelector('.cm-table-widget')).toBeNull() + view.destroy() + }) + + // Vim mode defaults on (DEFAULT_PREFS.vimMode), so cells start in NORMAL mode. + it('swallows vim normal-mode motion/printable keys inside a cell', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + // h/j/k/l are consumed as motions, not typed. + for (const key of ['h', 'j', 'k', 'l']) { + const ev = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + } + // A stray printable key is swallowed too (won't corrupt the cell text). + const xEv = new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }) + cell.dispatchEvent(xEv) + expect(xEv.defaultPrevented).toBe(true) + view.destroy() + }) + + it('enters insert mode on `i`, revealing the raw cell source', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + expect(cell.dataset.rendered).toBe('true') + // NORMAL cells are non-editable (no caret); editing turns it on. + expect(cell.getAttribute('contenteditable')).toBe('false') + const iEv = new KeyboardEvent('keydown', { key: 'i', bubbles: true, cancelable: true }) + cell.dispatchEvent(iEv) + expect(iEv.defaultPrevented).toBe(true) + // Now editing: cell is editable, shows raw markdown, accepts typed chars. + expect(cell.getAttribute('contenteditable')).toBe('true') + expect(cell.dataset.rendered).toBe('false') + const xEv = new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true }) + cell.dispatchEvent(xEv) + expect(xEv.defaultPrevented).toBe(false) + view.destroy() + }) + + it('opens the keyboard-navigable action menu on `m`', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + const mEv = new KeyboardEvent('keydown', { key: 'm', bubbles: true, cancelable: true }) + cell.dispatchEvent(mEv) + expect(mEv.defaultPrevented).toBe(true) + const menu = document.querySelector('.cm-table-menu') + expect(menu).toBeTruthy() + // The full Obsidian-style action set (add/move/dup/delete/align/sort). + expect(menu!.querySelectorAll('.cm-table-menu-item').length).toBeGreaterThan(10) + closeTableContextMenu() + view.destroy() + }) + + it('supports x / dd / D editing operators in a cell', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + expect(cell.dataset.raw).toBe('Alice') + // x deletes the char under the cursor (offset 0). + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true })) + expect(cell.dataset.raw).toBe('lice') + // D deletes to end of cell. + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'D', bubbles: true, cancelable: true })) + expect(cell.dataset.raw).toBe('') + view.destroy() + }) + + it('supports char-wise visual mode: v + motion + d deletes the selection', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + expect(cell.dataset.raw).toBe('Alice') + // v (anchor at 0) → l (extend to 1) → d (delete [0,2) = "Al"). + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'v', bubbles: true, cancelable: true })) + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'l', bubbles: true, cancelable: true })) + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true, cancelable: true })) + expect(cell.dataset.raw).toBe('ice') + view.destroy() + }) + + it('u commits the pending cell edit and undoes it', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'x', bubbles: true, cancelable: true })) + expect(cell.dataset.raw).toBe('lice') + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'u', bubbles: true, cancelable: true })) + // The committed edit is undone — the source table is back to "Alice". + expect(view.state.doc.toString()).toContain('| Alice |') + view.destroy() + }) + + it('diw deletes the inner word (operator + text object)', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + expect(cell.dataset.raw).toBe('Alice') + for (const key of ['d', 'i', 'w']) { + cell.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) + } + expect(cell.dataset.raw).toBe('') + view.destroy() + }) + + it('Esc in a normal-mode cell is a no-op (stays put, no jump below)', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="0"][data-col="0"]' + )! + const ev = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + cell.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + // Cell content untouched; widget still present (didn't tear down / jump out). + expect(cell.dataset.raw).toBe('Alice') + expect(view.dom.querySelector('.cm-table-widget')).toBeTruthy() + view.destroy() + }) + + it('supports the dd operator (clear cell) via operator-pending', () => { + const view = mount(TABLE_DOC) + const cell = view.dom.querySelector<HTMLElement>( + '.cm-table-widget [data-row="1"][data-col="0"]' + )! + expect(cell.dataset.raw).toBe('Bob') + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true, cancelable: true })) + cell.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', bubbles: true, cancelable: true })) + expect(cell.dataset.raw).toBe('') + view.destroy() + }) +}) + +describe('vim word motions (cell cursor)', () => { + const t = 'foo bar baz' + it('w moves to the next word start', () => { + expect(nextWordStart(t, 0)).toBe(4) + expect(nextWordStart(t, 4)).toBe(8) + expect(nextWordStart(t, 8)).toBe(t.length - 1) // clamps at the last word + }) + it('b moves to the previous word start', () => { + expect(prevWordStart(t, 8)).toBe(4) + expect(prevWordStart(t, 4)).toBe(0) + expect(prevWordStart(t, 0)).toBe(0) + }) + it('e moves to the next word end', () => { + expect(nextWordEnd(t, 0)).toBe(2) + expect(nextWordEnd(t, 2)).toBe(6) + }) + it('treats punctuation as its own word', () => { + // "a, b" → a(0) ,(1) space(2) b(3) + expect(nextWordStart('a, b', 0)).toBe(1) // 'a' → ',' + expect(nextWordStart('a, b', 1)).toBe(3) // ',' → 'b' + }) +}) + +describe('text objects (vi / va, di / ca)', () => { + it('iw / aw select the word (a includes trailing space)', () => { + const t = 'foo bar baz' + expect(textObjectRange(t, 5, 'i', 'w')).toEqual({ from: 4, to: 7 }) // "bar" + expect(textObjectRange(t, 5, 'a', 'w')).toEqual({ from: 4, to: 8 }) // "bar " + }) + it('i" / a" select inside / around quotes', () => { + const t = 'say "hi" now' + expect(textObjectRange(t, 5, 'i', '"')).toEqual({ from: 5, to: 7 }) // hi + expect(textObjectRange(t, 5, 'a', '"')).toEqual({ from: 4, to: 8 }) // "hi" + }) + it('i( / a) select inside / around brackets', () => { + const t = 'f(x, y)' + expect(textObjectRange(t, 3, 'i', '(')).toEqual({ from: 2, to: 6 }) // x, y + expect(textObjectRange(t, 3, 'a', ')')).toEqual({ from: 1, to: 7 }) // (x, y) + }) + it('returns null when the object is absent', () => { + expect(textObjectRange('plain', 0, 'i', '"')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/cm-table.ts b/packages/app-core/src/lib/cm-table.ts new file mode 100644 index 00000000..11348904 --- /dev/null +++ b/packages/app-core/src/lib/cm-table.ts @@ -0,0 +1,1391 @@ +/** + * WYSIWYG table editing for the Edit-mode editor — an Obsidian-style live + * table. Each GFM pipe table is replaced by a block widget that renders a real + * `<table>` with editable cells and GUI affordances (add/move/delete rows & + * columns, alignment, sort, drag-to-reorder). The markdown source stays the + * single source of truth: every edit re-serializes the table model + * (`markdown-table.ts`) and writes it back as one CodeMirror change, so undo, + * autosave, and multi-pane sync keep working. + * + * The replaced range is also marked atomic, so the CM caret never lands in the + * raw `| pipe |` text — all editing happens through the widget. (Raw source + * editing still lives in Split mode, which doesn't load this extension.) + * + * WYSIWYG-only: registered via `wysiwygExtensions()`; never loads in Split. + */ +import { syntaxTree } from '@codemirror/language' +import { Prec, RangeSetBuilder, StateField, type EditorState } from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + WidgetType +} from '@codemirror/view' +import { + insertColumn, + insertRow, + moveColumn, + moveRow, + parseTable, + serializeTable, + type MarkdownTable +} from './markdown-table' +import { openTableContextMenu } from './cm-table-menu' +import { renderMarkdown } from './markdown' +import { getCM } from '@replit/codemirror-vim' +import { undo, redo } from '@codemirror/commands' +import { useStore } from '../store' + +/** True when the editor is in Vim mode — gates the table's modal (normal / + * insert) keyboard navigation. With Vim off, cells stay plain contenteditable + * fields driven by Tab/Enter (unchanged). */ +function vimEnabled(): boolean { + return useStore.getState().vimMode +} + +/** Render a cell's markdown source to inline HTML (sanitized by the markdown + * pipeline). Strips the wrapping `<p>` so the content sits inline in the cell. + * Empty cells render nothing. */ +function renderInlineCell(text: string): string { + const trimmed = text.trim() + if (!trimmed) return '' + const html = renderMarkdown(trimmed).trim() + const match = html.match(/^<p[^>]*>([\s\S]*?)<\/p>\s*$/) + return match ? match[1] : html +} + +/** Find the enclosing `Table` node range for a doc position, or null. */ +function tableRangeAt(view: EditorView, pos: number): { from: number; to: number } | null { + let node = syntaxTree(view.state).resolveInner(pos, 1) + while (node) { + if (node.name === 'Table') return { from: node.from, to: node.to } + if (!node.parent) break + node = node.parent + } + return null +} + +/** + * Re-serialize `table` and write it over whichever Table node currently sits + * under the widget's DOM. Resolving the range live (via posAtDOM) keeps the + * write correct even when edits elsewhere have shifted the document. + */ +function commitTable(view: EditorView, dom: HTMLElement, table: MarkdownTable): void { + let pos: number + try { + pos = view.posAtDOM(dom) + } catch { + return + } + const range = tableRangeAt(view, pos) + if (!range) return + const next = serializeTable(table) + if (next === view.state.sliceDoc(range.from, range.to)) return + view.dispatch({ + changes: { from: range.from, to: range.to, insert: next } + }) +} + +type CellAddress = { row: number; col: number } + +class TableWidget extends WidgetType { + /** Working copy edited in place by the cells; committed on focus-out. */ + private model: MarkdownTable + private dom: HTMLElement | null = null + private dirty = false + /** Vim cell mode: 'normal' is a block-cursor that moves over characters + * (h/l) and rows (j/k); 'insert' edits the focused cell. Vim mode only. */ + private cellMode: 'normal' | 'insert' = 'normal' + /** Character index of the Vim block cursor within the focused cell's source. */ + private cursorOffset = 0 + /** Where to land the block cursor on the next cell focus: a char index, or + * 'end' (last char). Lets h/l carry the cursor across cell boundaries. */ + private pendingOffset: number | 'end' | null = null + /** A pending operator (`d`/`c`) waiting for its motion key (dw, cc, d$, …). */ + private pendingOp: 'd' | 'c' | null = null + /** Char-wise visual mode: true after `v`; `visualAnchor` is the fixed end of + * the selection, `cursorOffset` is the moving end. */ + private visualMode = false + private visualAnchor = 0 + /** Pending text-object scope after `i`/`a` — in visual mode (`viw`) and in + * operator-pending (`diw`, `ca"`). */ + private visualScope: 'i' | 'a' | null = null + private pendingScope: 'i' | 'a' | null = null + /** Set in toDOM — CodeMirror hands the live view there. Block widgets are + * provided by a StateField, which has no view at build time. */ + private view!: EditorView + + constructor( + /** Raw markdown of the table block — drives `eq` so unchanged tables keep + * their DOM (and any in-progress cell focus) across rebuilds. */ + private readonly source: string, + parsed: MarkdownTable + ) { + super() + this.model = parsed + } + + eq(other: TableWidget): boolean { + return other.source === this.source + } + + /** Commit a concrete next-model: re-serialize and write it over the source. + * The dispatch rebuilds the decorations (a fresh widget), so we refocus the + * requested cell on the next frame. Used by the context menu, which has + * already computed `next` from the current model. */ + private applyModel(next: MarkdownTable, focus?: CellAddress): void { + // Capture the live range BEFORE the dispatch detaches our DOM. + const dom = this.dom as HTMLElement + this.model = next + this.dirty = false + commitTable(this.view, dom, next) + if (focus) { + requestAnimationFrame(() => this.focusCellAt(focus)) + } + } + + /** Pull pending cell edits into the model, then apply a structural + * transform and commit — all without an intermediate dispatch, so our DOM + * stays attached for the single `commitTable` write. */ + private applyTransform( + fn: (model: MarkdownTable) => MarkdownTable, + focus?: CellAddress + ): void { + this.syncFromDom() + this.applyModel(fn(this.model), focus) + } + + private focusCellAt(addr: CellAddress): void { + const view = this.view + const dom = view.contentDOM.querySelector<HTMLElement>( + `.cm-table-widget [data-row="${addr.row}"][data-col="${addr.col}"]` + ) + if (dom) { + dom.focus() + placeCaretEnd(dom) + } + } + + /** Pull every cell's text out of the DOM into the model. `data-raw` holds the + * markdown source — `textContent` would lose it when a cell shows rendered + * inline markup (e.g. `code` chips). */ + private syncFromDom(): void { + if (!this.dom) return + const cells = this.dom.querySelectorAll<HTMLElement>('[data-row]') + cells.forEach((cell) => { + const row = Number(cell.dataset.row) + const col = Number(cell.dataset.col) + const value = cell.dataset.raw ?? cell.textContent ?? '' + if (row === -1) this.model.headers[col] = value + else if (this.model.rows[row]) this.model.rows[row][col] = value + }) + } + + private commitIfDirty(): void { + if (!this.dirty) return + this.syncFromDom() + this.dirty = false + commitTable(this.view, this.dom as HTMLElement, this.model) + } + + toDOM(view: EditorView): HTMLElement { + this.view = view + const root = document.createElement('div') + root.className = 'cm-table-widget' + root.setAttribute('contenteditable', 'false') + this.dom = root + + const wrapper = document.createElement('div') + wrapper.className = 'cm-table-wrapper' + + const table = document.createElement('table') + const thead = document.createElement('thead') + const headRow = document.createElement('tr') + this.model.headers.forEach((text, col) => { + headRow.append(this.buildCell('th', -1, col, text)) + }) + thead.append(headRow) + table.append(thead) + + const tbody = document.createElement('tbody') + this.model.rows.forEach((row, r) => { + const tr = document.createElement('tr') + row.forEach((text, col) => tr.append(this.buildCell('td', r, col, text))) + tbody.append(tr) + }) + table.append(tbody) + wrapper.append(table) + + // Add-row / add-column buttons (appear on hover via CSS). + const addCol = document.createElement('button') + addCol.type = 'button' + addCol.className = 'cm-table-add cm-table-add-col' + addCol.textContent = '+' + addCol.title = 'Add column' + addCol.addEventListener('mousedown', (e) => e.preventDefault()) + addCol.addEventListener('click', (e) => { + e.preventDefault() + const col = this.model.headers.length + this.applyTransform((m) => insertColumn(m, m.headers.length), { row: -1, col }) + }) + + const addRow = document.createElement('button') + addRow.type = 'button' + addRow.className = 'cm-table-add cm-table-add-row' + addRow.textContent = '+' + addRow.title = 'Add row' + addRow.addEventListener('mousedown', (e) => e.preventDefault()) + addRow.addEventListener('click', (e) => { + e.preventDefault() + const row = this.model.rows.length + this.applyTransform((m) => insertRow(m, m.rows.length), { row, col: 0 }) + }) + + wrapper.append(addCol, addRow) + root.append(wrapper) + + // Commit when focus leaves the whole widget. + root.addEventListener('focusout', (event) => { + const next = event.relatedTarget as Node | null + if (next && root.contains(next)) return + this.commitIfDirty() + }) + + return root + } + + private buildCell( + tag: 'th' | 'td', + row: number, + col: number, + text: string + ): HTMLTableCellElement { + const cell = document.createElement(tag) + const align = this.model.aligns[col] ?? 'none' + if (align !== 'none') cell.setAttribute('align', align) + + const editable = document.createElement('div') + editable.className = 'cm-table-cell' + editable.dataset.row = String(row) + editable.dataset.col = String(col) + editable.dataset.raw = text + // Vim: cells are non-editable in NORMAL mode, so there's no native text + // caret (a block cursor is drawn in CSS); i/a turns editing on. tabindex + // keeps the cell focusable for navigation while non-editable. Non-Vim: + // always editable (click-to-edit), unchanged. + editable.setAttribute('tabindex', '-1') + editable.setAttribute('contenteditable', vimEnabled() ? 'false' : 'true') + // Idle: show rendered inline markdown (code/bold/links). Editing: show the + // raw source. `data-raw` stays authoritative for commits either way. + editable.innerHTML = renderInlineCell(text) + editable.dataset.rendered = 'true' + + editable.addEventListener('focus', () => { + if (vimEnabled()) { + // Vim: land in NORMAL mode at the pending offset (carried across cells + // by h/l), or 0. The cell becomes a block cursor over its source. + this.cellMode = 'normal' + this.pendingOp = null + this.pendingScope = null + this.visualMode = false + this.visualScope = null + const len = (editable.dataset.raw ?? '').length + this.cursorOffset = + this.pendingOffset === 'end' + ? Math.max(0, len - 1) + : Math.max(0, this.pendingOffset ?? 0) + this.pendingOffset = null + this.enterNormalCell(editable) + return + } + if (editable.dataset.rendered === 'true') { + editable.textContent = editable.dataset.raw ?? '' + editable.dataset.rendered = 'false' + placeCaretEnd(editable) + } + }) + editable.addEventListener('input', () => { + this.dirty = true + editable.dataset.raw = editable.textContent ?? '' + }) + editable.addEventListener('blur', () => { + this.clearCellCursor(editable) + editable.classList.remove('is-vim-normal') + // Leaving the cell: pull the (possibly edited) source back out of the DOM + // and re-render it. `data-raw` is authoritative, so read that. + if (editable.dataset.rendered === 'false') { + const raw = editable.dataset.raw ?? editable.textContent ?? '' + editable.dataset.raw = raw + editable.innerHTML = renderInlineCell(raw) + editable.dataset.rendered = 'true' + } + }) + editable.addEventListener('keydown', (event) => this.onCellKeydown(event, row, col, editable)) + cell.addEventListener('contextmenu', (event) => { + event.preventDefault() + event.stopPropagation() + // Pull pending edits into the model (no dispatch) so the menu acts on + // the current contents; the chosen action commits in one write. + this.syncFromDom() + openTableContextMenu({ + x: event.clientX, + y: event.clientY, + row, + col, + model: this.model, + apply: (next, focus) => this.applyModel(next, focus) + }) + }) + cell.append(editable) + + // Drag handles: row grip on the first cell of each body row, column grip + // above each header cell. They appear on hover (CSS) and start a manual + // pointer-drag to reorder. + if (row >= 0 && col === 0) { + cell.append(this.buildDragHandle('row', row)) + } + if (row === -1) { + cell.append(this.buildDragHandle('col', col)) + } + return cell + } + + private buildDragHandle(kind: 'row' | 'col', index: number): HTMLElement { + const handle = document.createElement('div') + handle.className = kind === 'row' ? 'cm-table-row-handle' : 'cm-table-col-handle' + handle.setAttribute('contenteditable', 'false') + handle.title = kind === 'row' ? 'Drag to move row' : 'Drag to move column' + handle.addEventListener('mousedown', (event) => { + if (event.button !== 0) return + event.preventDefault() + event.stopPropagation() + if (kind === 'row') this.startRowDrag(index) + else this.startColDrag(index) + }) + return handle + } + + /** Drag a body row to a new position. Tracks the pointer, draws a drop + * indicator, and commits a `moveRow` on release. */ + private startRowDrag(fromRow: number): void { + const dom = this.dom + if (!dom) return + this.syncFromDom() + const wrapper = dom.querySelector<HTMLElement>('.cm-table-wrapper') + const rows = Array.from(dom.querySelectorAll<HTMLElement>('tbody tr')) + if (!wrapper || rows.length === 0) return + dom.classList.add('is-dragging') + const indicator = document.createElement('div') + indicator.className = 'cm-table-drop-indicator cm-table-drop-row' + wrapper.append(indicator) + + // Which row is the pointer over? Drop-onto-row semantics: the dragged row + // moves into that row's slot (so dropping row 0 anywhere on row 1 swaps + // them), which is far more intuitive than a midpoint insertion gap. + const hoveredFor = (y: number): number => { + for (let i = 0; i < rows.length; i++) { + if (y <= rows[i].getBoundingClientRect().bottom) return i + } + return rows.length - 1 + } + const place = (hovered: number): void => { + if (hovered === fromRow) { + indicator.style.display = 'none' + return + } + indicator.style.display = 'block' + const wrect = wrapper.getBoundingClientRect() + const rect = rows[hovered].getBoundingClientRect() + // Line on the far edge in the drag direction. + const top = (hovered > fromRow ? rect.bottom : rect.top) - wrect.top + indicator.style.top = `${top}px` + } + place(fromRow) + + const onMove = (e: MouseEvent): void => place(hoveredFor(e.clientY)) + const onUp = (e: MouseEvent): void => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + indicator.remove() + dom.classList.remove('is-dragging') + const hovered = hoveredFor(e.clientY) + if (hovered !== fromRow) { + this.applyModel(moveRow(this.model, fromRow, hovered)) + } + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + + /** Drag a column to a new position. */ + private startColDrag(fromCol: number): void { + const dom = this.dom + if (!dom) return + this.syncFromDom() + const wrapper = dom.querySelector<HTMLElement>('.cm-table-wrapper') + const headers = Array.from(dom.querySelectorAll<HTMLElement>('thead th')) + if (!wrapper || headers.length === 0) return + dom.classList.add('is-dragging') + const indicator = document.createElement('div') + indicator.className = 'cm-table-drop-indicator cm-table-drop-col' + wrapper.append(indicator) + + // Drop-onto-column semantics, mirroring rows. + const hoveredFor = (x: number): number => { + for (let i = 0; i < headers.length; i++) { + if (x <= headers[i].getBoundingClientRect().right) return i + } + return headers.length - 1 + } + const place = (hovered: number): void => { + if (hovered === fromCol) { + indicator.style.display = 'none' + return + } + indicator.style.display = 'block' + const wrect = wrapper.getBoundingClientRect() + const rect = headers[hovered].getBoundingClientRect() + const left = (hovered > fromCol ? rect.right : rect.left) - wrect.left + indicator.style.left = `${left}px` + } + place(fromCol) + + const onMove = (e: MouseEvent): void => place(hoveredFor(e.clientX)) + const onUp = (e: MouseEvent): void => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + indicator.remove() + dom.classList.remove('is-dragging') + const hovered = hoveredFor(e.clientX) + if (hovered !== fromCol) { + this.applyModel(moveColumn(this.model, fromCol, hovered)) + } + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + + private onCellKeydown( + event: KeyboardEvent, + row: number, + col: number, + editable: HTMLElement + ): void { + const cols = this.model.headers.length + const rowsCount = this.model.rows.length + + if (vimEnabled()) { + if (this.cellMode === 'insert') { + // INSERT: Escape commits the cell text and drops back to NORMAL, staying + // put (classic Vim). Everything else types normally / falls through to + // the shared Tab / Enter handling below. + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + const sel = window.getSelection() + const caret = + sel && sel.anchorNode && editable.contains(sel.anchorNode) ? sel.anchorOffset : 0 + editable.dataset.raw = editable.textContent ?? '' + editable.setAttribute('contenteditable', 'false') + this.cellMode = 'normal' + // Vim leaves the cursor on the char left of the caret. + this.cursorOffset = Math.max(0, caret - 1) + this.enterNormalCell(editable) + return + } + } else { + // NORMAL: h/j/k/l move between cells, i/a start editing, Esc leaves the + // table. Stray printable keys are swallowed so they never reach the cell. + // Stop unmodified keys from leaking to global shortcuts (the cell is not + // contenteditable in NORMAL mode, so app-level handlers — e.g. the `m` + // sidebar menu — would otherwise also fire). Modified combos (⌘S etc.) + // still pass through. + if (!event.metaKey && !event.ctrlKey && !event.altKey) { + event.stopPropagation() + } + const cellText = editable.dataset.raw ?? '' + if (this.visualMode) { + event.preventDefault() + this.handleVisualKey(editable, event.key, cellText) + return + } + if (this.pendingOp) { + event.preventDefault() + const op = this.pendingOp + if (this.pendingScope) { + // Third key: the text-object char (diw, ca", di(, …). + const scope = this.pendingScope + this.pendingOp = null + this.pendingScope = null + const r = textObjectRange(cellText, this.cursorOffset, scope, event.key) + if (r) { + this.deleteRange(editable, r.from, r.to) + if (op === 'c') this.enterInsertMode(editable, r.from) + } + return + } + if (event.key === 'i' || event.key === 'a') { + // Operator + text-object scope (di…, ca…): wait for the object key. + this.pendingScope = event.key + return + } + // Plain motion: dd / cc, dw / cw, d$ / c$, etc. + this.pendingOp = null + this.applyOperator(editable, op, event.key, cellText) + return + } + switch (event.key) { + case 'h': + event.preventDefault() + this.moveCharLeft(editable, row, col) + return + case 'l': + event.preventDefault() + this.moveCharRight(editable, row, col) + return + case 'j': + event.preventDefault() + this.moveRowDown(row, col) + return + case 'k': + event.preventDefault() + this.moveRowUp(row, col) + return + case 'i': + event.preventDefault() + this.enterInsertMode(editable, this.cursorOffset) + return + case 'a': + event.preventDefault() + this.enterInsertMode(editable, this.cursorOffset + 1) + return + case 'I': + event.preventDefault() + this.enterInsertMode(editable, 0) + return + case 'A': + event.preventDefault() + this.enterInsertMode(editable, cellText.length) + return + case 'w': + event.preventDefault() + this.cursorOffset = nextWordStart(cellText, this.cursorOffset) + this.renderCellCursor(editable) + return + case 'e': + event.preventDefault() + this.cursorOffset = nextWordEnd(cellText, this.cursorOffset) + this.renderCellCursor(editable) + return + case 'b': + event.preventDefault() + this.cursorOffset = prevWordStart(cellText, this.cursorOffset) + this.renderCellCursor(editable) + return + case '0': + event.preventDefault() + this.cursorOffset = 0 + this.renderCellCursor(editable) + return + case '$': + event.preventDefault() + this.cursorOffset = Math.max(0, cellText.length - 1) + this.renderCellCursor(editable) + return + case 'x': + event.preventDefault() + this.deleteRange(editable, this.cursorOffset, this.cursorOffset + 1) + return + case 'D': + event.preventDefault() + this.deleteRange(editable, this.cursorOffset, cellText.length) + return + case 'C': { + event.preventDefault() + const at = this.cursorOffset + this.deleteRange(editable, at, cellText.length) + this.enterInsertMode(editable, at) + return + } + case 'd': + event.preventDefault() + this.pendingOp = 'd' + return + case 'c': + event.preventDefault() + this.pendingOp = 'c' + return + case 'v': + event.preventDefault() + this.visualMode = true + this.visualAnchor = this.cursorOffset + this.renderCellSelection(editable) + return + case 'u': + // Undo: flush pending cell edits as one history step, then undo it + // (or the previous change). Hands focus back to the editor. + event.preventDefault() + this.commitIfDirty() + undo(this.view) + this.view.focus() + return + case 'r': + // Ctrl-r → redo. Plain `r` is swallowed (no replace-char yet). + event.preventDefault() + if (event.ctrlKey) { + event.stopPropagation() + this.commitIfDirty() + redo(this.view) + this.view.focus() + } + return + case 'Escape': + // Vim: Esc in normal mode is a no-op — don't jump out of the table. + // Leave a cell with j/k at the top/bottom edges (or click away). + event.preventDefault() + return + case 'm': + // Open the full table action menu (add/move/duplicate/delete/align/ + // sort) for this cell — keyboard-navigable with j/k/Enter/Esc. + event.preventDefault() + this.openCellMenu(editable, row, col) + return + case 'Tab': + case 'Enter': + break // fall through to shared Tab / Enter navigation + default: + if ( + event.key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + event.preventDefault() + } + return + } + } + } + + // Body rows are 0..rowsCount-1; header is -1. Flatten for navigation. + const order: CellAddress[] = [] + for (let c = 0; c < cols; c++) order.push({ row: -1, col: c }) + for (let r = 0; r < rowsCount; r++) + for (let c = 0; c < cols; c++) order.push({ row: r, col: c }) + const idx = order.findIndex((a) => a.row === row && a.col === col) + + if (event.key === 'Tab') { + event.preventDefault() + const nextIdx = event.shiftKey ? idx - 1 : idx + 1 + if (nextIdx >= 0 && nextIdx < order.length) { + this.moveFocus(order[nextIdx]) + } else if (!event.shiftKey) { + // Tab past the last cell → add a new row and land in it. + const row = this.model.rows.length + this.applyTransform((m) => insertRow(m, m.rows.length), { row, col: 0 }) + } + return + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + const target = row === -1 ? { row: 0, col } : { row: row + 1, col } + if (target.row >= rowsCount) { + this.applyTransform((m) => insertRow(m, rowsCount), { row: rowsCount, col }) + } else { + this.moveFocus(target) + } + return + } + + if (event.key === 'Escape') { + event.preventDefault() + this.commitIfDirty() + this.view.focus() + } + } + + private moveFocus(addr: CellAddress): void { + if (!this.dom) return + const el = this.dom.querySelector<HTMLElement>( + `[data-row="${addr.row}"][data-col="${addr.col}"]` + ) + if (el) { + el.focus() + placeCaretEnd(el) + } + } + + /** Resolve a cell element by address within this widget. */ + private cellEl(row: number, col: number): HTMLElement | null { + return this.dom?.querySelector<HTMLElement>(`[data-row="${row}"][data-col="${col}"]`) ?? null + } + + /** Focus a cell, landing the block cursor at a source offset (or its last + * char). Used when h/l cross a cell boundary and by j/k. */ + private focusCellAtOffset(row: number, col: number, offset: number | 'end'): void { + const cell = this.cellEl(row, col) + if (!cell) return + this.pendingOffset = offset + cell.focus() + } + + /** Put a focused cell into NORMAL mode: non-editable, raw source shown, with + * the block cursor drawn over the current character. */ + private enterNormalCell(cell: HTMLElement): void { + cell.setAttribute('contenteditable', 'false') + cell.classList.add('is-vim-normal') + // Show the raw source so motions map to real characters (and are + // measurable for the block cursor). + cell.textContent = cell.dataset.raw ?? '' + cell.dataset.rendered = 'false' + this.renderCellCursor(cell) + } + + private clearCellCursor(cell: HTMLElement): void { + cell.querySelector('.cm-table-cell-cursor')?.remove() + cell.querySelector('.cm-table-cell-sel')?.remove() + } + + /** Draw the block cursor over the character at `cursorOffset`, measuring its + * rect from the cell's text node (so it tracks real glyph positions). */ + private renderCellCursor(cell: HTMLElement): void { + this.clearCellCursor(cell) + const text = cell.dataset.raw ?? '' + const offset = Math.max(0, Math.min(this.cursorOffset, Math.max(0, text.length - 1))) + this.cursorOffset = offset + const cursor = document.createElement('span') + cursor.className = 'cm-table-cell-cursor' + cursor.setAttribute('contenteditable', 'false') + const node = cell.firstChild + const cellRect = cell.getBoundingClientRect() + const range = document.createRange() + if ( + node && + node.nodeType === Node.TEXT_NODE && + text.length > 0 && + typeof range.getBoundingClientRect === 'function' + ) { + range.setStart(node, offset) + range.setEnd(node, Math.min(text.length, offset + 1)) + const rect = range.getBoundingClientRect() + cursor.style.left = `${rect.left - cellRect.left}px` + cursor.style.top = `${rect.top - cellRect.top}px` + cursor.style.width = `${Math.max(rect.width, 3)}px` + cursor.style.height = `${rect.height || 18}px` + } else { + // Empty cell, or no measurement available (e.g. jsdom): a thin block at + // the cell's start. + cursor.style.left = '10px' + cursor.style.top = '5px' + cursor.style.bottom = '5px' + cursor.style.width = '0.5em' + } + cell.appendChild(cursor) + } + + /** Vim NORMAL `l`: next character; at the cell's end, jump to the next + * column. */ + private moveCharRight(cell: HTMLElement, row: number, col: number): void { + const len = (cell.dataset.raw ?? '').length + if (this.cursorOffset < len - 1) { + this.cursorOffset += 1 + this.renderCellCursor(cell) + } else if (col + 1 < this.model.headers.length) { + this.focusCellAtOffset(row, col + 1, 0) + } + } + + /** Vim NORMAL `h`: previous character; at the cell's start, jump to the + * previous column (landing on its last char). */ + private moveCharLeft(cell: HTMLElement, row: number, col: number): void { + if (this.cursorOffset > 0) { + this.cursorOffset -= 1 + this.renderCellCursor(cell) + } else if (col - 1 >= 0) { + this.focusCellAtOffset(row, col - 1, 'end') + } + } + + /** Vim NORMAL `j`: header → first body row → … → past the last row leaves the + * table downward, keeping the column offset (clamped). */ + private moveRowDown(row: number, col: number): void { + const target = row === -1 ? 0 : row + 1 + if (target >= this.model.rows.length) { + this.exitToEditor('after') + return + } + this.focusCellAtOffset(target, col, this.cursorOffset) + } + + /** Vim NORMAL `k`: body row → … → header → past the header leaves upward. */ + private moveRowUp(row: number, col: number): void { + if (row === -1) { + this.exitToEditor('before') + return + } + this.focusCellAtOffset(row === 0 ? -1 : row - 1, col, this.cursorOffset) + } + + /** Switch the focused cell into INSERT mode at a source offset: editable, + * native caret, block cursor removed. Don't re-focus — that re-fires the + * focus handler, snapping back to NORMAL. */ + private enterInsertMode(cell: HTMLElement, caretOffset: number): void { + this.cellMode = 'insert' + this.pendingOp = null + this.pendingScope = null + this.visualMode = false + this.visualScope = null + this.clearCellCursor(cell) + cell.classList.remove('is-vim-normal') + cell.setAttribute('contenteditable', 'true') + if (cell.dataset.rendered !== 'false') { + cell.textContent = cell.dataset.raw ?? '' + cell.dataset.rendered = 'false' + } + placeCaretAt(cell, caretOffset) + } + + /** Delete `[from, to)` from the focused cell's source in place (no dispatch — + * committed on blur, like typing). Re-renders the NORMAL block cursor. */ + private deleteRange(cell: HTMLElement, from: number, to: number): void { + const text = cell.dataset.raw ?? '' + const a = Math.max(0, Math.min(from, text.length)) + const b = Math.max(a, Math.min(to, text.length)) + if (b === a) return + const next = text.slice(0, a) + text.slice(b) + cell.dataset.raw = next + this.dirty = true + this.cursorOffset = Math.max(0, Math.min(a, next.length - 1)) + cell.textContent = next + cell.dataset.rendered = 'false' + this.renderCellCursor(cell) + } + + /** Apply a pending operator (`d`/`c`) over the motion in `key`: dd/cc clear + * the cell; dw/cw, d$/c$, d0/c0, dl, db act over that range. `c` then edits. */ + private applyOperator( + cell: HTMLElement, + op: 'd' | 'c', + key: string, + text: string + ): void { + const off = this.cursorOffset + let from = off + let to = off + if (key === op) { + // dd / cc → the whole cell + from = 0 + to = text.length + } else { + switch (key) { + case 'w': + to = op === 'c' ? Math.min(text.length, nextWordEnd(text, off) + 1) : nextWordStart(text, off) + break + case 'e': + to = Math.min(text.length, nextWordEnd(text, off) + 1) + break + case '$': + to = text.length + break + case '0': + from = 0 + to = off + break + case 'l': + to = Math.min(text.length, off + 1) + break + case 'h': + from = Math.max(0, off - 1) + break + case 'b': + from = prevWordStart(text, off) + break + default: + return // unknown motion — cancel the operator + } + } + const wholeCell = from === 0 && to === text.length + if (to > from || wholeCell) { + this.deleteRange(cell, from, to) + if (op === 'c') this.enterInsertMode(cell, from) + } + } + + /** Render the char-wise visual selection [anchor..head] as a themed overlay + * measured over the range — NOT a native DOM selection, which CodeMirror + * would mirror into a multi-cell editor selection (and which falls back to + * the washed-out OS color). */ + private renderCellSelection(cell: HTMLElement): void { + this.clearCellCursor(cell) + const text = cell.dataset.raw ?? '' + const node = cell.firstChild + if (!node || node.nodeType !== Node.TEXT_NODE || text.length === 0) return + const from = Math.max(0, Math.min(this.visualAnchor, this.cursorOffset)) + const to = Math.min(text.length, Math.max(this.visualAnchor, this.cursorOffset) + 1) + const range = document.createRange() + range.setStart(node, from) + range.setEnd(node, to) + if (typeof range.getBoundingClientRect !== 'function') return + const rect = range.getBoundingClientRect() + const cellRect = cell.getBoundingClientRect() + const overlay = document.createElement('span') + overlay.className = 'cm-table-cell-sel' + overlay.style.left = `${rect.left - cellRect.left}px` + overlay.style.top = `${rect.top - cellRect.top}px` + overlay.style.width = `${Math.max(rect.width, 2)}px` + overlay.style.height = `${rect.height || 18}px` + cell.appendChild(overlay) + } + + private exitVisual(): void { + this.visualMode = false + this.visualScope = null + window.getSelection()?.removeAllRanges() + } + + /** Keys while in char-wise visual mode: motions extend the selection; d/x + * delete it, c/s change it, y yanks it, Esc cancels. */ + private handleVisualKey(cell: HTMLElement, key: string, text: string): void { + const selFrom = (): number => Math.max(0, Math.min(this.visualAnchor, this.cursorOffset)) + const selTo = (): number => + Math.min(text.length, Math.max(this.visualAnchor, this.cursorOffset) + 1) + if (this.visualScope) { + // `vi{obj}` / `va{obj}`: select the text object. + const scope = this.visualScope + this.visualScope = null + const r = textObjectRange(text, this.cursorOffset, scope, key) + if (r) { + this.visualAnchor = r.from + this.cursorOffset = Math.max(r.from, r.to - 1) + this.renderCellSelection(cell) + } + return + } + if (key === 'i' || key === 'a') { + this.visualScope = key + return + } + switch (key) { + case 'h': + this.cursorOffset = Math.max(0, this.cursorOffset - 1) + this.renderCellSelection(cell) + return + case 'l': + this.cursorOffset = Math.min(Math.max(0, text.length - 1), this.cursorOffset + 1) + this.renderCellSelection(cell) + return + case 'w': + this.cursorOffset = nextWordStart(text, this.cursorOffset) + this.renderCellSelection(cell) + return + case 'e': + this.cursorOffset = nextWordEnd(text, this.cursorOffset) + this.renderCellSelection(cell) + return + case 'b': + this.cursorOffset = prevWordStart(text, this.cursorOffset) + this.renderCellSelection(cell) + return + case '0': + this.cursorOffset = 0 + this.renderCellSelection(cell) + return + case '$': + this.cursorOffset = Math.max(0, text.length - 1) + this.renderCellSelection(cell) + return + case 'd': + case 'x': { + const from = selFrom() + const to = selTo() + this.exitVisual() + this.deleteRange(cell, from, to) + return + } + case 'c': + case 's': { + const from = selFrom() + const to = selTo() + this.exitVisual() + this.deleteRange(cell, from, to) + this.enterInsertMode(cell, from) + return + } + case 'y': { + const from = selFrom() + const to = selTo() + void navigator.clipboard?.writeText(text.slice(from, to)) + this.cursorOffset = from + this.exitVisual() + this.renderCellCursor(cell) + return + } + case 'Escape': + this.exitVisual() + this.renderCellCursor(cell) + return + default: + return + } + } + + /** Leave the table, returning the caret to the document just before/after the + * table block. The atomic range keeps it from sliding back in. */ + private exitToEditor(side: 'before' | 'after'): void { + this.commitIfDirty() + this.cellMode = 'normal' + this.pendingOp = null + this.pendingScope = null + this.visualMode = false + this.visualScope = null + const dom = this.dom + if (!dom) { + this.view.focus() + return + } + let pos: number + try { + pos = this.view.posAtDOM(dom) + } catch { + this.view.focus() + return + } + const range = tableRangeAt(this.view, pos) + this.view.focus() + if (!range) return + const doc = this.view.state.doc + const anchor = + side === 'before' ? Math.max(0, range.from - 1) : Math.min(doc.length, range.to + 1) + this.view.dispatch({ selection: { anchor } }) + } + + /** Open the table action menu for the focused cell (Vim NORMAL `m`), + * anchored at the cell. The menu itself is keyboard-navigable. */ + private openCellMenu(editable: HTMLElement, row: number, col: number): void { + this.syncFromDom() + const rect = editable.getBoundingClientRect() + openTableContextMenu({ + x: rect.left, + y: rect.bottom, + row, + col, + model: this.model, + apply: (next, focus) => this.applyModel(next, focus) + }) + } + + ignoreEvent(): boolean { + return true + } + + destroy(): void { + // Flush any uncommitted edits if the widget is torn down while focused. + this.commitIfDirty() + this.dom = null + } +} + +function placeCaretEnd(el: HTMLElement): void { + const range = document.createRange() + range.selectNodeContents(el) + range.collapse(false) + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) +} + +/** Vim character class: 0 = whitespace, 1 = word char, 2 = punctuation. A + * "word" is a maximal run of one non-whitespace class. */ +function charClass(ch: string): 0 | 1 | 2 { + if (/\s/.test(ch)) return 0 + if (/[A-Za-z0-9_]/.test(ch)) return 1 + return 2 +} + +/** Vim `w`: index of the start of the next word (clamped to the last char). */ +export function nextWordStart(text: string, off: number): number { + const n = text.length + if (n === 0) return 0 + let i = off + const cls = charClass(text[i] ?? ' ') + if (cls !== 0) while (i < n && charClass(text[i]) === cls) i++ + while (i < n && charClass(text[i]) === 0) i++ + return Math.min(i, n - 1) +} + +/** Vim `b`: index of the start of the current/previous word. */ +export function prevWordStart(text: string, off: number): number { + let i = off - 1 + while (i > 0 && charClass(text[i]) === 0) i-- + if (i <= 0) return 0 + const cls = charClass(text[i]) + while (i > 0 && charClass(text[i - 1]) === cls) i-- + return Math.max(0, i) +} + +/** Vim `e`: index of the end of the current/next word. */ +export function nextWordEnd(text: string, off: number): number { + const n = text.length + if (n === 0) return 0 + let i = off + 1 + while (i < n && charClass(text[i]) === 0) i++ + if (i >= n) return n - 1 + const cls = charClass(text[i]) + while (i + 1 < n && charClass(text[i + 1]) === cls) i++ + return Math.min(i, n - 1) +} + +/** Vim text object: the range covering `iw`/`aw` (word), `i"`/`a"` (and `'`/`` ` ``), + * and `i(`/`a(` etc. (brackets) around `offset`. `to` is exclusive. Returns + * null if the object isn't found (e.g. unbalanced brackets). */ +export function textObjectRange( + text: string, + offset: number, + scope: 'i' | 'a', + obj: string +): { from: number; to: number } | null { + const n = text.length + if (n === 0) return null + const off = Math.max(0, Math.min(offset, n - 1)) + + if (obj === 'w') { + const cls = charClass(text[off]) + let from = off + let to = off + 1 + while (from > 0 && charClass(text[from - 1]) === cls) from-- + while (to < n && charClass(text[to]) === cls) to++ + if (scope === 'a') { + const afterEnd = to + while (to < n && charClass(text[to]) === 0) to++ + if (to === afterEnd) while (from > 0 && charClass(text[from - 1]) === 0) from-- + } + return { from, to } + } + + if (obj === '"' || obj === "'" || obj === '`') { + let open = -1 + for (let i = off; i >= 0; i--) { + if (text[i] === obj) { + open = i + break + } + } + if (open === -1) { + for (let i = off; i < n; i++) { + if (text[i] === obj) { + open = i + break + } + } + } + if (open === -1) return null + let close = -1 + for (let i = open + 1; i < n; i++) { + if (text[i] === obj) { + close = i + break + } + } + if (close === -1) return null + return scope === 'i' ? { from: open + 1, to: close } : { from: open, to: close + 1 } + } + + const OPENERS: Record<string, string> = { '(': ')', '[': ']', '{': '}' } + const CLOSERS: Record<string, string> = { ')': '(', ']': '[', '}': '{' } + let openCh: string + let closeCh: string + if (OPENERS[obj]) { + openCh = obj + closeCh = OPENERS[obj] + } else if (CLOSERS[obj]) { + openCh = CLOSERS[obj] + closeCh = obj + } else { + return null + } + let depth = 0 + let openPos = -1 + for (let i = off; i >= 0; i--) { + if (text[i] === closeCh && i !== off) depth++ + else if (text[i] === openCh) { + if (depth === 0) { + openPos = i + break + } + depth-- + } + } + if (openPos === -1) return null + depth = 0 + let closePos = -1 + for (let i = openPos + 1; i < n; i++) { + if (text[i] === openCh) depth++ + else if (text[i] === closeCh) { + if (depth === 0) { + closePos = i + break + } + depth-- + } + } + if (closePos === -1) return null + return scope === 'i' ? { from: openPos + 1, to: closePos } : { from: openPos, to: closePos + 1 } +} + +/** Collapse the caret at a character offset within the element's text node. */ +function placeCaretAt(el: HTMLElement, offset: number): void { + const node = el.firstChild + const range = document.createRange() + if (node && node.nodeType === Node.TEXT_NODE) { + const max = node.textContent?.length ?? 0 + range.setStart(node, Math.max(0, Math.min(offset, max))) + } else { + range.selectNodeContents(el) + } + range.collapse(true) + const sel = window.getSelection() + sel?.removeAllRanges() + sel?.addRange(range) +} + +function buildDecorations(state: EditorState): DecorationSet { + const tree = syntaxTree(state) + const ranges: Array<{ from: number; to: number; deco: Decoration }> = [] + + // Iterate the whole parsed tree (no viewport): block replace decorations + // must come from a StateField, which has no viewport. Tables are sparse, so + // this stays cheap for typical notes. + tree.iterate({ + enter: (node) => { + if (node.name !== 'Table') return + const source = state.sliceDoc(node.from, node.to) + const parsed = parseTable(source) + if (!parsed) return false + ranges.push({ + from: node.from, + to: node.to, + deco: Decoration.replace({ + block: true, + widget: new TableWidget(source, parsed) + }) + }) + return false + } + }) + + ranges.sort((a, b) => a.from - b.from) + const builder = new RangeSetBuilder<Decoration>() + for (const r of ranges) builder.add(r.from, r.to, r.deco) + return builder.finish() +} + +/** + * Block widgets (and any decoration that replaces line breaks) must be + * supplied through a StateField, not a ViewPlugin — CodeMirror needs to know + * the block structure before the viewport is computed. The field also feeds + * `atomicRanges` so the caret never lands inside the raw pipe source. + */ +export const tablePlugin = StateField.define<DecorationSet>({ + create: (state) => buildDecorations(state), + update(deco, tr) { + // Rebuild on edits and whenever the parser advances (the syntax tree is a + // fresh object); otherwise positions are unchanged, so reuse as-is. + if (tr.docChanged || syntaxTree(tr.startState) !== syntaxTree(tr.state)) { + return buildDecorations(tr.state) + } + return deco + }, + provide: (field) => [ + EditorView.decorations.from(field), + EditorView.atomicRanges.of((view) => view.state.field(field, false) ?? Decoration.none) + ] +}) + +/** Find a `Table` node directly adjacent to `line`: `down` → a table on the + * next line, `up` → a table on the previous line. Returns its range or null. */ +function adjacentTableRange( + state: EditorState, + line: number, + dir: 'down' | 'up' +): { from: number; to: number } | null { + const targetLine = dir === 'down' ? line + 1 : line - 1 + if (targetLine < 1 || targetLine > state.doc.lines) return null + const probe = + dir === 'down' ? state.doc.line(targetLine).from : state.doc.line(targetLine).to + let node: ReturnType<typeof syntaxTree>['topNode'] | null = syntaxTree(state).resolveInner( + probe, + dir === 'down' ? 1 : -1 + ) + while (node) { + if (node.name === 'Table') return { from: node.from, to: node.to } + node = node.parent + } + return null +} + +/** Focus the entry cell of the table widget at `tableFrom`: the first header + * cell when entering from above, the first cell of the last row from below. */ +function focusTableEntryCell( + view: EditorView, + tableFrom: number, + edge: 'first' | 'last' +): boolean { + const widgets = view.contentDOM.querySelectorAll<HTMLElement>('.cm-table-widget') + for (const widget of widgets) { + let pos: number + try { + pos = view.posAtDOM(widget) + } catch { + continue + } + if (pos !== tableFrom) continue + let cell: HTMLElement | null + if (edge === 'first') { + cell = widget.querySelector<HTMLElement>('[data-row="-1"][data-col="0"]') + } else { + const bodyCol0 = Array.from( + widget.querySelectorAll<HTMLElement>('[data-col="0"]') + ).filter((c) => Number(c.dataset.row) >= 0) + cell = bodyCol0.length ? bodyCol0[bodyCol0.length - 1] : null + } + if (cell) { + cell.focus() + return true + } + } + return false +} + +/** + * Vim integration: in NORMAL mode, `j` / `k` on a line directly adjacent to a + * rendered table steps the caret into the table (first header cell from above, + * last row from below) instead of jumping over the atomic block. Inside, the + * widget's own key handler takes over. Highest precedence so it runs before Vim + * consumes the key; a no-op (returns false) whenever there's no adjacent table. + */ +export const tableVimEntry = Prec.highest( + EditorView.domEventHandlers({ + keydown(event, view) { + if (!vimEnabled()) return false + if (event.key !== 'j' && event.key !== 'k') return false + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false + // Keys from inside a table widget are the widget's own cell navigation — + // this handler only enters a table from the surrounding document. + if ((event.target as HTMLElement | null)?.closest?.('.cm-table-widget')) return false + const vimState = getCM(view)?.state?.vim as { insertMode?: boolean } | undefined + if (vimState?.insertMode) return false + const sel = view.state.selection.main + if (!sel.empty) return false + const line = view.state.doc.lineAt(sel.head).number + const dir = event.key === 'j' ? 'down' : 'up' + const table = adjacentTableRange(view.state, line, dir) + if (!table) return false + if (focusTableEntryCell(view, table.from, dir === 'down' ? 'first' : 'last')) { + event.preventDefault() + return true + } + return false + } + }) +) diff --git a/packages/app-core/src/lib/cm-vim-clipboard.test.ts b/packages/app-core/src/lib/cm-vim-clipboard.test.ts new file mode 100644 index 00000000..b81db97e --- /dev/null +++ b/packages/app-core/src/lib/cm-vim-clipboard.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +// A stand-in for codemirror-vim's register controller. The lib wraps its +// pushText; `original` lets us assert the wrapped call still runs. +const unnamed = { value: '', toString: () => unnamed.value } +const original = vi.fn((registerName: string, _operator: string, text: string) => { + if (!registerName || registerName === '"') unnamed.value = text +}) +const controller = { pushText: original, unnamedRegister: unnamed } + +vi.mock('@replit/codemirror-vim', () => ({ + Vim: { getRegisterController: () => controller } +})) + +const writeText = vi.fn().mockResolvedValue(undefined) +Object.defineProperty(globalThis.navigator, 'clipboard', { + value: { writeText }, + configurable: true +}) + +// Imported after the mocks so the lib captures the fake controller. +const { setYankToClipboardEnabled } = await import('./cm-vim-clipboard') + +afterEach(() => { + writeText.mockClear() + original.mockClear() + unnamed.value = '' +}) + +describe('vim yank to clipboard', () => { + it('writes a default-register yank to the system clipboard when enabled', () => { + setYankToClipboardEnabled(true) + controller.pushText('', 'yank', 'hello') + expect(original).toHaveBeenCalled() + expect(writeText).toHaveBeenCalledWith('hello') + }) + + it('does not write when disabled', () => { + setYankToClipboardEnabled(false) + controller.pushText('', 'yank', 'nope') + expect(original).toHaveBeenCalled() + expect(writeText).not.toHaveBeenCalled() + }) + + it('only syncs the unnamed register, not explicit named ones', () => { + setYankToClipboardEnabled(true) + controller.pushText('a', 'yank', 'named') + expect(writeText).not.toHaveBeenCalled() + }) + + it('ignores the black hole register', () => { + setYankToClipboardEnabled(true) + controller.pushText('_', 'delete', 'gone') + expect(writeText).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app-core/src/lib/cm-vim-clipboard.ts b/packages/app-core/src/lib/cm-vim-clipboard.ts new file mode 100644 index 00000000..030c06fe --- /dev/null +++ b/packages/app-core/src/lib/cm-vim-clipboard.ts @@ -0,0 +1,74 @@ +/** + * Central hook for Vim yank side effects. codemirror-vim funnels every + * yank/delete/change through the register controller's `pushText`, so we wrap it + * once (the controller is created a single time at module load, via + * `resetVimGlobalState`) and from there drive two features: + * + * - `clipboard=unnamed` emulation: copy the unnamed register to the system + * clipboard. codemirror-vim only syncs the explicit `"+` register natively. + * - a yank handler other modules register (used for highlight-on-yank), invoked + * on yank so the active view can flash the yanked range. + */ +import { Vim } from '@replit/codemirror-vim' + +interface PatchableRegisterController { + pushText: ( + registerName: string, + operator: string, + text: string, + linewise?: boolean, + blockwise?: boolean + ) => void + unnamedRegister?: { toString(): string } +} + +let clipboardEnabled = false +let yankHandler: (() => void) | null = null +let patched = false + +function ensurePatched(): void { + if (patched) return + const controller = Vim.getRegisterController() as unknown as PatchableRegisterController | null + if (!controller || typeof controller.pushText !== 'function') return + const original = controller.pushText.bind(controller) + controller.pushText = (registerName, operator, text, linewise, blockwise): void => { + original(registerName, operator, text, linewise, blockwise) + + // Fire the yank handler (highlight-on-yank) regardless of the clipboard + // setting. The handler reads the live editor selection, which is still the + // yank range at this point. + if (operator === 'yank' && yankHandler) { + try { + yankHandler() + } catch { + /* never let a handler break the yank */ + } + } + + if (!clipboardEnabled) return + // Only the unnamed/default register mirrors the clipboard. Explicit named + // registers (e.g. `"ay`) and the black hole register (`"_`) are left alone; + // the `"+` register already writes to the clipboard natively. + if (registerName && registerName !== '"') return + // Read back the unnamed register so linewise yanks keep their trailing + // newline; fall back to the raw text if it is unavailable. + const out = controller.unnamedRegister?.toString() ?? text + if (out) void navigator.clipboard?.writeText(out).catch(() => {}) + } + patched = true +} + +/** + * Toggle whether Vim yank/delete/change also copy to the system clipboard. + * Safe to call repeatedly; the underlying patch is installed at most once. + */ +export function setYankToClipboardEnabled(on: boolean): void { + clipboardEnabled = on + if (on) ensurePatched() +} + +/** Register a handler invoked on every Vim yank (used for highlight-on-yank). */ +export function setVimYankHandler(handler: (() => void) | null): void { + yankHandler = handler + ensurePatched() +} diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.test.ts b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts new file mode 100644 index 00000000..f8aecee5 --- /dev/null +++ b/packages/app-core/src/lib/cm-vim-default-keymap.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { vimAwareDefaultKeymap } from './cm-vim-default-keymap' + +// Regression guard for the macOS Vim `Ctrl-d` bug: defaultKeymap's emacs-style +// mac chords used to shadow Vim's <C-d> (half-page down) and delete a char. +describe('vimAwareDefaultKeymap', () => { + it('strips the macOS emacs chords in Vim mode so Vim handles them', () => { + const macs = new Set( + vimAwareDefaultKeymap(true) + .map((b) => b.mac) + .filter(Boolean) + ) + for (const chord of ['Ctrl-d', 'Ctrl-a', 'Ctrl-e', 'Ctrl-f', 'Ctrl-b', 'Ctrl-v', 'Ctrl-h']) { + expect(macs.has(chord)).toBe(false) + } + }) + + it('keeps the emacs chords when Vim is off (standard macOS editing keys)', () => { + const macs = new Set(vimAwareDefaultKeymap(false).map((b) => b.mac)) + expect(macs.has('Ctrl-d')).toBe(true) + expect(macs.has('Ctrl-e')).toBe(true) + }) + + it('never drops non-emacs bindings (Mod-a, arrows survive in Vim mode)', () => { + const vim = vimAwareDefaultKeymap(true) + expect(vim.some((b) => b.key === 'Mod-a')).toBe(true) + expect(vim.some((b) => b.key === 'ArrowDown')).toBe(true) + expect(vim.some((b) => b.key === 'Enter')).toBe(true) + }) + + it('removes exactly the 13 documented emacs chords, nothing more', () => { + expect(vimAwareDefaultKeymap(false).length - vimAwareDefaultKeymap(true).length).toBe(13) + }) +}) diff --git a/packages/app-core/src/lib/cm-vim-default-keymap.ts b/packages/app-core/src/lib/cm-vim-default-keymap.ts new file mode 100644 index 00000000..f9840e2a --- /dev/null +++ b/packages/app-core/src/lib/cm-vim-default-keymap.ts @@ -0,0 +1,58 @@ +import { defaultKeymap } from '@codemirror/commands' +import type { KeyBinding } from '@codemirror/view' + +/** + * macOS-only Vim keymap conflict (`Ctrl-d` deletes instead of half-page-down). + * + * `@codemirror/commands`' `defaultKeymap` folds in the emacs-style control + * chords as **mac-only** bindings (each entry's `mac` field). On macOS that + * makes the editor bind, among others: + * + * Ctrl-d → deleteCharForward Ctrl-a → cursorLineStart + * Ctrl-e → cursorLineEnd Ctrl-f → cursorCharRight + * Ctrl-b → cursorCharLeft Ctrl-v → cursorPageDown … + * + * These collide with Vim's own normal/visual-mode chords (`<C-d>` half-page + * down, `<C-a>` increment, `<C-v>` visual-block, `<C-f>`/`<C-b>` page, …). + * Because the keymap's key handler runs at higher precedence than the Vim + * plugin's, the emacs action wins — so in Vim mode on macOS `Ctrl-d` deletes a + * character instead of scrolling. (Linux/Windows are unaffected: these bindings + * are mac-only. `Ctrl-u`, which has no emacs binding, already worked — hence the + * up/down asymmetry users notice.) + * + * The fix is to drop these chords from the editor keymap while Vim mode is on, + * so Vim receives them and handles them natively. When Vim is off we keep them — + * they're standard macOS text-editing keys. + */ +const MAC_EMACS_CHORDS = new Set([ + 'Ctrl-b', + 'Ctrl-f', + 'Ctrl-p', + 'Ctrl-n', + 'Ctrl-a', + 'Ctrl-e', + 'Ctrl-d', + 'Ctrl-h', + 'Ctrl-k', + 'Ctrl-Alt-h', + 'Ctrl-o', + 'Ctrl-t', + 'Ctrl-v' +]) + +const isMacEmacsChord = (binding: KeyBinding): boolean => + typeof binding.mac === 'string' && MAC_EMACS_CHORDS.has(binding.mac) + +/** `defaultKeymap` with the macOS emacs-style control chords removed. */ +const defaultKeymapWithoutMacEmacs: readonly KeyBinding[] = defaultKeymap.filter( + (binding) => !isMacEmacsChord(binding) +) + +/** + * CodeMirror's `defaultKeymap`, made Vim-aware: in Vim mode the macOS + * emacs-style control chords are stripped so Vim's `<C-d>`/`<C-a>`/`<C-v>`/… + * bindings work; with Vim off the full keymap (including those chords) is used. + */ +export function vimAwareDefaultKeymap(vimMode: boolean): readonly KeyBinding[] { + return vimMode ? defaultKeymapWithoutMacEmacs : defaultKeymap +} diff --git a/packages/app-core/src/lib/cm-wikilink-render.test.ts b/packages/app-core/src/lib/cm-wikilink-render.test.ts new file mode 100644 index 00000000..ffafb8a3 --- /dev/null +++ b/packages/app-core/src/lib/cm-wikilink-render.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { wikilinkRenderExtension } from './cm-wikilink-render' + +function mount(doc: string, anchor: number): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + return new EditorView({ + parent, + state: EditorState.create({ + doc, + selection: { anchor }, + extensions: [markdown({ base: markdownLanguage }), wikilinkRenderExtension] + }) + }) +} + +describe('wikilinkRenderExtension', () => { + it('hides the [[ ]] brackets and shows the label, with the target on the mark', () => { + const doc = 'see [[Foo]] and [[Bar|baz]] end' + const view = mount(doc, doc.length) + const links = Array.from(view.dom.querySelectorAll<HTMLElement>('.cm-wikilink')) + expect(links.map((e) => e.textContent)).toEqual(['Foo', 'baz']) + expect(links.map((e) => e.dataset.target)).toEqual(['Foo', 'Bar']) + expect(view.dom.textContent).not.toContain('[[') + expect(view.dom.textContent).not.toContain('Bar|') + view.destroy() + }) + + it('reveals the raw [[...]] on the wikilink the cursor is in', () => { + const doc = 'see [[Foo]] end' + const view = mount(doc, doc.indexOf('Foo')) + expect(view.dom.textContent).toContain('[[Foo]]') + view.destroy() + }) + + it('leaves embeds (![[...]]) alone', () => { + const doc = 'pic ![[image.png]] end' + const view = mount(doc, doc.length) + expect(view.dom.querySelector('.cm-wikilink')).toBeNull() + view.destroy() + }) +}) diff --git a/packages/app-core/src/lib/cm-wikilink-render.ts b/packages/app-core/src/lib/cm-wikilink-render.ts new file mode 100644 index 00000000..2b1f8f42 --- /dev/null +++ b/packages/app-core/src/lib/cm-wikilink-render.ts @@ -0,0 +1,151 @@ +/** + * WYSIWYG rendering for Obsidian-style `[[wikilinks]]`: hide the `[[ ]]` + * brackets (and the `target|` part of an aliased link), show the label as a + * clickable accent link, and navigate to the note on click. The raw `[[...]]` + * source is revealed on whichever wikilink the cursor is in — matching how the + * rest of live preview reveals the active token. + * + * Image/transclusion embeds (`![[...]]`) are left to the existing embed + * handling and skipped here. + * + * WYSIWYG-only: registered via `wysiwygExtensions()`. + */ +import { RangeSetBuilder } from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate +} from '@codemirror/view' +import { useStore } from '../store' +import { resolveWikilinkTarget, wikilinkHeadingAnchor } from './wikilinks' +import { openWikilinkHeading } from './wikilink-navigation' + +// Same shape as the Preview pipeline (remarkWikilinks). +const WIKILINK_RE = /(!?)\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g +const hide = Decoration.replace({}) +// Quiet edit markers for the revealed `[[ ]]` / `|` when the cursor is in the +// wikilink (overrides the orange link highlight so brackets read as markers). +const bracketMark = Decoration.mark({ class: 'cm-wikilink-bracket' }) + +function selectionTouches( + state: EditorView['state'], + from: number, + to: number +): boolean { + for (const range of state.selection.ranges) { + if (range.empty) { + if (range.from >= from && range.from <= to) return true + } else if (Math.max(range.from, from) < Math.min(range.to, to)) { + return true + } + } + return false +} + +function buildDecorations(view: EditorView): DecorationSet { + const { state } = view + const pending: Array<{ from: number; to: number; deco: Decoration }> = [] + + for (const { from, to } of view.visibleRanges) { + const firstLine = state.doc.lineAt(from).number + const lastLine = state.doc.lineAt(Math.max(from, to - 1)).number + for (let n = firstLine; n <= lastLine; n++) { + const line = state.doc.line(n) + if (!line.text.includes('[[')) continue + WIKILINK_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = WIKILINK_RE.exec(line.text)) !== null) { + if (m[1] === '!') continue // embed — handled elsewhere + const target = m[2].trim() + if (!target) continue + const matchStart = line.from + m.index + const matchEnd = matchStart + m[0].length + const hasAlias = m[3] != null + const labelStart = hasAlias + ? matchStart + 2 + m[2].length + 1 // after `[[target|` + : matchStart + 2 // after `[[` + const labelEnd = matchEnd - 2 // before `]]` + if (labelEnd <= labelStart) continue + // Cursor inside this wikilink → reveal the raw `[[...]]`, but mute the + // brackets / pipe so they read as quiet edit markers. + if (selectionTouches(state, matchStart, matchEnd)) { + pending.push({ from: matchStart, to: matchStart + 2, deco: bracketMark }) + if (hasAlias) { + pending.push({ from: labelStart - 1, to: labelStart, deco: bracketMark }) + } + pending.push({ from: matchEnd - 2, to: matchEnd, deco: bracketMark }) + continue + } + pending.push({ from: matchStart, to: labelStart, deco: hide }) + pending.push({ + from: labelStart, + to: labelEnd, + deco: Decoration.mark({ + class: 'cm-wikilink', + attributes: { 'data-target': target } + }) + }) + pending.push({ from: labelEnd, to: matchEnd, deco: hide }) + } + } + } + + pending.sort((a, b) => a.from - b.from || a.to - b.to) + const builder = new RangeSetBuilder<Decoration>() + for (const p of pending) builder.add(p.from, p.to, p.deco) + return builder.finish() +} + +const wikilinkRenderPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + constructor(view: EditorView) { + this.decorations = buildDecorations(view) + } + update(update: ViewUpdate): void { + if (update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = buildDecorations(update.view) + } + } + }, + { decorations: (p) => p.decorations } +) + +/** + * Open the note a wikilink points to, scrolling to its `#heading` when the + * target carries one (`[[Doc#Heading]]`). (#196) + */ +function openWikilink(target: string): void { + const state = useStore.getState() + const resolved = resolveWikilinkTarget(state.notes, target) + if (!resolved) return // unresolved link — leave note creation to other flows + + const focusEditorSoon = (): void => { + useStore.getState().setFocusedPanel('editor') + requestAnimationFrame(() => useStore.getState().editorViewRef?.focus()) + } + + const anchor = wikilinkHeadingAnchor(target) + if (!anchor) { + void state.selectNote(resolved.path).then(focusEditorSoon) + return + } + void openWikilinkHeading(resolved.path, anchor).then(focusEditorSoon) +} + +// Click a rendered wikilink to jump. Intercept on mousedown so CodeMirror +// doesn't first drop the caret into the (hidden) source. +const wikilinkClick = EditorView.domEventHandlers({ + mousedown: (event) => { + const el = (event.target as HTMLElement | null)?.closest<HTMLElement>('.cm-wikilink') + const target = el?.dataset.target + if (!target) return false + event.preventDefault() + openWikilink(target) + return true + } +}) + +export const wikilinkRenderExtension = [wikilinkRenderPlugin, wikilinkClick] diff --git a/packages/app-core/src/lib/cm-wikilinks.test.ts b/packages/app-core/src/lib/cm-wikilinks.test.ts index 59e65d34..1e0d7300 100644 --- a/packages/app-core/src/lib/cm-wikilinks.test.ts +++ b/packages/app-core/src/lib/cm-wikilinks.test.ts @@ -4,7 +4,7 @@ import { CompletionContext } from '@codemirror/autocomplete' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { describe, expect, it, vi } from 'vitest' -import { wikilinkSource } from './cm-wikilinks' +import { wikilinkSource, wikilinkHeadingSource } from './cm-wikilinks' const storeState = vi.hoisted(() => ({ activeNote: { @@ -19,7 +19,10 @@ const storeState = vi.hoisted(() => ({ wikilinks: [], hasAttachments: false, excerpt: '', - body: '' + body: '# Home\n\n## Tasks\n' + }, + noteContents: { + 'inbox/Zen Garden.md': { body: '# Intro\n\n## Setup\n\n## Usage Notes\n\nbody\n' } }, notes: [ { @@ -103,3 +106,46 @@ describe('wikilinkSource', () => { parent.remove() }) }) + +function headingResult(doc: string) { + const state = EditorState.create({ doc }) + return wikilinkHeadingSource(new CompletionContext(state, doc.length, true)) +} + +describe('wikilinkHeadingSource (#196 — heading autocomplete)', () => { + it('suggests the target note headings after #', async () => { + const result = await headingResult('[[Zen Garden#') + expect(result?.options.map((o) => o.label)).toEqual(['Intro', 'Setup', 'Usage Notes']) + }) + + it('anchors the completion just after the # so the heading lands inside the link', async () => { + const doc = '[[Zen Garden#Us' + const result = await headingResult(doc) + expect(result?.from).toBe('[[Zen Garden#'.length) + }) + + it('inserts the chosen heading and closes the link', async () => { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ parent, state: EditorState.create({ doc: '[[Zen Garden#Us' }) }) + const result = await wikilinkHeadingSource( + new CompletionContext(view.state, view.state.doc.length, true) + ) + const option = result?.options.find((o) => o.label === 'Usage Notes') + const apply = option?.apply + if (typeof apply !== 'function') throw new Error('expected a function apply handler') + apply(view, option!, result!.from, view.state.doc.length) + expect(view.state.doc.toString()).toBe('[[Zen Garden#Usage Notes]]') + view.destroy() + parent.remove() + }) + + it('falls back to the current note for [[#', async () => { + const result = await headingResult('[[#') + expect(result?.options.map((o) => o.label)).toEqual(['Home', 'Tasks']) + }) + + it('returns null without a # (note mode owns that)', async () => { + expect(await headingResult('[[Zen Garden')).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/cm-wikilinks.ts b/packages/app-core/src/lib/cm-wikilinks.ts index cfc0cccc..00acb54a 100644 --- a/packages/app-core/src/lib/cm-wikilinks.ts +++ b/packages/app-core/src/lib/cm-wikilinks.ts @@ -3,6 +3,8 @@ import type { EditorView } from '@codemirror/view' import type { AssetMeta, NoteMeta } from '@shared/ipc' import { useStore } from '../store' import { isPrimaryNotesAtRoot, noteFolderSubpath } from './vault-layout' +import { resolveWikilinkTarget } from './wikilinks' +import { parseOutline } from './outline' function normalize(value: string): string { return value.trim().toLowerCase() @@ -321,3 +323,95 @@ export function wikilinkSource(context: CompletionContext): CompletionResult | n filter: false } } + +/** + * Match `[[Note#<headingQuery>` so we can suggest the target note's headings. + * The note is everything before the first `#`; the heading query is whatever + * follows the last `#` (so nested `#a#b` still completes the deepest part). + */ +function wikilinkHeadingMatch(context: CompletionContext): { + from: number + notePart: string + query: string +} | null { + const { state, pos } = context + const line = state.doc.lineAt(pos) + const before = state.doc.sliceString(line.from, pos) + const openIndex = before.lastIndexOf('[[') + if (openIndex < 0) return null + + const inside = before.slice(openIndex + 2) + if (inside.includes(']]') || inside.includes('|')) return null + const firstHash = inside.indexOf('#') + if (firstHash < 0) return null // no heading anchor — `wikilinkSource` owns this + const lastHash = inside.lastIndexOf('#') + + return { + from: line.from + openIndex + 2 + lastHash + 1, + notePart: inside.slice(0, firstHash).trim(), + query: inside.slice(lastHash + 1) + } +} + +// Bodies fetched for heading completion are cached so typing the heading query +// doesn't re-read the file on every keystroke (`validFor` keeps the option list +// while the query stays anchor-shaped, so this mostly matters across notes). +const headingBodyCache = new Map<string, string>() + +/** + * Autocomplete headings inside a wikilink: typing `[[Note#` (or `[[#` for the + * current note) suggests that note's headings. (#196) + */ +export async function wikilinkHeadingSource( + context: CompletionContext +): Promise<CompletionResult | null> { + const match = wikilinkHeadingMatch(context) + if (!match) return null + + const state = useStore.getState() + const note = match.notePart + ? resolveWikilinkTarget(state.notes, match.notePart) + : state.activeNote + if (!note) return null + + let body = + state.noteContents[note.path]?.body ?? + (note as { body?: string }).body ?? // activeNote ([[#…]]) already carries its body + headingBodyCache.get(note.path) + if (body == null) { + try { + body = (await window.zen.readNote(note.path)).body + headingBodyCache.set(note.path, body) + } catch { + return null + } + } + + const seen = new Set<string>() + const options: Completion[] = [] + for (const heading of parseOutline(body)) { + const text = heading.text.trim() + const key = normalize(text) + if (!text || seen.has(key)) continue + seen.add(key) + options.push({ + label: text, + detail: `H${heading.level}`, + type: 'text', + apply: (view: EditorView, _completion: Completion, from: number, to: number) => { + const existingClose = view.state.doc.sliceString(to, to + 2) === ']]' + const insert = `${text}${existingClose ? '' : ']]'}` + view.dispatch({ + changes: { from, to, insert }, + selection: { anchor: from + text.length + (existingClose ? 0 : 2) } + }) + } + }) + if (options.length >= 100) break + } + if (options.length === 0) return null + + // Default filter (fuzzy) on the heading query; validFor lets CodeMirror keep + // and filter the list client-side while the query stays anchor-shaped. + return { from: match.from, options, validFor: /^[^\]|]*$/ } +} diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts new file mode 100644 index 00000000..a73f9e4b --- /dev/null +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { wysiwygBlocksPlugin } from './cm-wysiwyg-blocks' + +const DOC = `# Title + +- first +- second + +> a quote line + +--- + +End.` + +function mount(doc: string): EditorView { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + // Caret at the very end, away from the list / quote / hr. + state: EditorState.create({ + doc, + selection: { anchor: doc.length }, + extensions: [markdown({ base: markdownLanguage }), wysiwygBlocksPlugin] + }) + }) + forceParsing(view, doc.length, 5000) + view.dispatch({ changes: { from: doc.length, insert: ' ' } }) + view.dispatch({ changes: { from: doc.length, to: doc.length + 1 } }) + return view +} + +describe('wysiwygBlocksPlugin', () => { + it('renders bullets, an hr, and a blockquote bar without throwing', () => { + const view = mount(DOC) + expect(view.dom.querySelectorAll('.cm-wq-bullet').length).toBe(2) + expect(view.dom.querySelector('.cm-wq-hr')).toBeTruthy() + expect(view.dom.querySelector('.cm-wq-quote')).toBeTruthy() + view.destroy() + }) + + it('hides the ``` fences when the cursor is outside the code block', () => { + const view = mount('before\n\n```js\nconst x = 1\n```\n\nafter') + expect(view.dom.textContent).not.toContain('```js') + expect(view.dom.textContent).not.toMatch(/```\s*$/) + expect(view.dom.textContent).toContain('const x = 1') + view.destroy() + }) + + it('reveals the raw list marker on the active line', () => { + const view = mount(DOC) + const firstItem = DOC.indexOf('- first') + view.dispatch({ selection: { anchor: firstItem + 3 } }) + // The active list line shows its `-`; only the second item stays a bullet. + expect(view.dom.querySelectorAll('.cm-wq-bullet').length).toBe(1) + view.destroy() + }) + + it('renders a callout as a colored card with its custom title', () => { + const view = mount('# T\n\n> [!warning] Heads up\n> body line\n\nEnd.') + expect(view.dom.querySelectorAll('.cm-callout').length).toBeGreaterThanOrEqual(2) + expect(view.dom.querySelector('.cm-callout-warning')).toBeTruthy() + expect(view.dom.querySelector('.cm-callout-head')).toBeTruthy() + expect(view.dom.querySelector('.cm-callout-foot')).toBeTruthy() + // The `[!warning]` token is hidden; the custom title stays. + expect(view.dom.textContent).not.toContain('[!warning]') + expect(view.dom.textContent).toContain('Heads up') + view.destroy() + }) + + it('shows the type name as the title when a callout has no custom title', () => { + const view = mount('# T\n\n> [!note]\n> body\n\nEnd.') + expect(view.dom.querySelector('.cm-callout-title')?.textContent).toBe('Note') + expect(view.dom.textContent).not.toContain('[!note]') + view.destroy() + }) + + it('hides the marker on task-list items (only the plain item gets a bullet)', () => { + // `- [ ]` / `- [x]` are task items — their `-` is hidden (the checkbox from + // the live-preview plugin stands in), matching Obsidian. Only the plain + // `- item` becomes a bullet. + const view = mount('# T\n\n- [ ] open\n- [x] done\n- plain item\n\nEnd.') + expect(view.dom.querySelectorAll('.cm-wq-bullet').length).toBe(1) + // The task markers' dashes are hidden (no "- [" left in the rendered text). + expect(view.dom.textContent).not.toContain('- [') + view.destroy() + }) +}) diff --git a/packages/app-core/src/lib/cm-wysiwyg-blocks.ts b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts new file mode 100644 index 00000000..0a73317c --- /dev/null +++ b/packages/app-core/src/lib/cm-wysiwyg-blocks.ts @@ -0,0 +1,278 @@ +/** + * WYSIWYG block rendering that the base live-preview plugin doesn't cover: + * Obsidian-style blockquote bars, unordered-list bullets, and horizontal + * rules. Like the rest of live preview, the raw source is revealed on the + * line the cursor is on; everything else renders. + * + * WYSIWYG-only: registered via `wysiwygExtensions()`; never loads in Split. + */ +import { syntaxTree } from '@codemirror/language' +import { RangeSetBuilder, type EditorState } from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType +} from '@codemirror/view' +/** Line number (1-based) of the closing `---` of leading YAML frontmatter, + * or -1 when there is none. Lets us leave the frontmatter fences to the + * frontmatter styling rather than rendering them as horizontal rules. + * (Inlined: the PR's full frontmatter-properties module isn't ported.) */ +function frontmatterEndLine(state: EditorState): number { + const doc = state.doc + if (doc.lines < 2 || doc.line(1).text.trim() !== '---') return -1 + for (let i = 2; i <= doc.lines; i++) { + if (doc.line(i).text.trim() === '---') return i + } + return -1 +} + +const quoteLine = Decoration.line({ class: 'cm-wq-quote' }) + +/** A round bullet that replaces a `-` / `*` / `+` list marker. */ +class BulletWidget extends WidgetType { + eq(): boolean { + return true + } + toDOM(): HTMLElement { + const span = document.createElement('span') + span.className = 'cm-wq-bullet' + span.textContent = '•' + return span + } + ignoreEvent(): boolean { + return true + } +} + +/** A horizontal rule rendered in place of `---` / `***` / `___`. */ +class HrWidget extends WidgetType { + eq(): boolean { + return true + } + toDOM(): HTMLElement { + const span = document.createElement('span') + span.className = 'cm-wq-hr' + return span + } + ignoreEvent(): boolean { + return true + } +} + +const bullet = Decoration.replace({ widget: new BulletWidget() }) +const hrRule = Decoration.replace({ widget: new HrWidget() }) +/** Hide a fence line's ``` ```lang ``` / ``` ``` ``` text (inline, keeps the + * line + its card styling) when the cursor is outside the code block. */ +const hideInline = Decoration.replace({}) + +/** Header of an Obsidian-style callout: `> [!type] optional title`. */ +const CALLOUT_RE = /^(\s*>\s?)\[!(\w+)\]\s?(.*)$/ + +/** Collapse callout type aliases onto one color group, mirroring the Preview + * renderer (markdown.ts). Unknown types fall back to the neutral group. */ +function calloutGroup(type: string): string { + const t = type.toLowerCase() + if (t === 'note' || t === 'info' || t === 'abstract' || t === 'summary') return 'note' + if (t === 'tip' || t === 'hint' || t === 'success' || t === 'check' || t === 'done') + return 'tip' + if (t === 'warning' || t === 'warn' || t === 'caution' || t === 'attention') return 'warning' + if (t === 'danger' || t === 'error' || t === 'bug' || t === 'fail' || t === 'failure') + return 'danger' + if (t === 'quote' || t === 'cite') return 'quote' + return 'note' +} + +/** Default title for a callout with no custom title: the capitalized type. */ +function calloutTitle(type: string): string { + return type.charAt(0).toUpperCase() + type.slice(1).toLowerCase() +} + +/** Styled label shown in place of a `[!type]` token when the callout has no + * custom title (e.g. renders "Note"). */ +class CalloutTitleWidget extends WidgetType { + constructor( + private readonly label: string, + private readonly group: string + ) { + super() + } + eq(other: CalloutTitleWidget): boolean { + return other.label === this.label && other.group === this.group + } + toDOM(): HTMLElement { + const span = document.createElement('span') + span.className = `cm-callout-title cm-callout-${this.group}` + span.textContent = this.label + return span + } + ignoreEvent(): boolean { + return true + } +} + +function activeLineSet(view: EditorView): Set<number> { + const lines = new Set<number>() + for (const r of view.state.selection.ranges) { + const from = view.state.doc.lineAt(r.from).number + const to = view.state.doc.lineAt(r.to).number + for (let l = from; l <= to; l++) lines.add(l) + } + return lines +} + +type Pending = { from: number; to: number; deco: Decoration; line: boolean } + +function buildDecorations(view: EditorView): DecorationSet { + const { state } = view + const active = activeLineSet(view) + const pending: Pending[] = [] + const quotedLines = new Set<number>() + // The properties widget owns the leading frontmatter (its `---` fences parse + // as HorizontalRule); skip that range so we don't emit an overlapping + // replace decoration over the same lines. + const fmEnd = frontmatterEndLine(state) + + for (const { from, to } of view.visibleRanges) { + syntaxTree(state).iterate({ + from, + to, + enter: (node) => { + if (node.name === 'Blockquote') { + const first = state.doc.lineAt(node.from).number + const last = state.doc.lineAt(Math.max(node.from, node.to - 1)).number + const firstLine = state.doc.line(first) + const callout = firstLine.text.match(CALLOUT_RE) + if (callout) { + // Obsidian-style callout: a colored card with a typed title. Tag + // each line for the box (head/foot round the top/bottom). + const group = calloutGroup(callout[2]) + for (let n = first; n <= last; n++) { + if (quotedLines.has(n)) continue + quotedLines.add(n) + const ln = state.doc.line(n) + let cls = `cm-callout cm-callout-${group}` + if (n === first) cls += ' cm-callout-head' + if (n === last) cls += ' cm-callout-foot' + pending.push({ + from: ln.from, + to: ln.from, + deco: Decoration.line({ class: cls }), + line: true + }) + } + // Header: render the title. Off the line, hide the `[!type]` token — + // the custom title stays (styled), or we show the type name. + if (!active.has(first)) { + const bStart = firstLine.from + callout[1].length + const bEnd = bStart + `[!${callout[2]}]`.length + if (callout[3].trim()) { + let to = bEnd + if (state.doc.sliceString(to, to + 1) === ' ') to += 1 + pending.push({ from: bStart, to, deco: hideInline, line: false }) + } else { + pending.push({ + from: bStart, + to: bEnd, + deco: Decoration.replace({ + widget: new CalloutTitleWidget(calloutTitle(callout[2]), group) + }), + line: false + }) + } + } + return + } + // Plain blockquote: a left bar on every line it spans. + for (let n = first; n <= last; n++) { + if (quotedLines.has(n)) continue + quotedLines.add(n) + const line = state.doc.line(n) + pending.push({ from: line.from, to: line.from, deco: quoteLine, line: true }) + } + return + } + if (node.name === 'HorizontalRule') { + const lineNo = state.doc.lineAt(node.from).number + if (fmEnd >= 1 && lineNo <= fmEnd) return // leave frontmatter to the properties widget + if (active.has(lineNo)) return // reveal `---` source on the active line + pending.push({ from: node.from, to: node.to, deco: hrRule, line: false }) + return + } + if (node.name === 'FencedCode') { + // Hide the ``` fence lines when the cursor is outside the block, so + // it reads as a clean card (the language flair still shows the lang). + // Clicking into the block reveals the fences for editing. + const firstLine = state.doc.lineAt(node.from) + const lastLine = state.doc.lineAt(Math.max(node.from, node.to - 1)) + let blockActive = false + for (let n = firstLine.number; n <= lastLine.number; n++) { + if (active.has(n)) { + blockActive = true + break + } + } + if (!blockActive) { + if (firstLine.to > firstLine.from) { + pending.push({ from: firstLine.from, to: firstLine.to, deco: hideInline, line: false }) + } + // Only hide the last line if it's actually a closing fence — an + // unclosed block at EOF ends on a content line we must keep. + const closesWithFence = /^\s*(?:`{3,}|~{3,})\s*$/.test(lastLine.text) + if ( + closesWithFence && + lastLine.number !== firstLine.number && + lastLine.to > lastLine.from + ) { + pending.push({ from: lastLine.from, to: lastLine.to, deco: hideInline, line: false }) + } + } + return false // don't descend into the code content + } + if (node.name === 'ListMark') { + const lineNo = state.doc.lineAt(node.from).number + if (active.has(lineNo)) return + const text = state.doc.sliceString(node.from, node.to) + // Only unordered bullets become a •; ordered markers (`1.`) stay. + if (!/^[-*+]$/.test(text)) return + // Task-list items render a checkbox (from the live-preview plugin) in + // place of the marker, so HIDE the `-`/`*`/`+` (and its trailing + // space) rather than bulleting it — the line reads "☐ task" like + // Obsidian, not "- ☐ task" or "• ☐ task". + const afterMark = state.doc.sliceString(node.to, state.doc.lineAt(node.from).to) + if (/^\s*\[[ xX]\]/.test(afterMark)) { + let to = node.to + if (state.doc.sliceString(to, to + 1) === ' ') to += 1 + pending.push({ from: node.from, to, deco: hideInline, line: false }) + return + } + pending.push({ from: node.from, to: node.to, deco: bullet, line: false }) + } + } + }) + } + + // RangeSetBuilder needs ascending order; line decorations sort before + // content decorations at the same position. + pending.sort((a, b) => a.from - b.from || (a.line === b.line ? 0 : a.line ? -1 : 1)) + const builder = new RangeSetBuilder<Decoration>() + for (const p of pending) builder.add(p.from, p.to, p.deco) + return builder.finish() +} + +export const wysiwygBlocksPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + constructor(view: EditorView) { + this.decorations = buildDecorations(view) + } + update(update: ViewUpdate): void { + if (update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = buildDecorations(update.view) + } + } + }, + { decorations: (p) => p.decorations } +) diff --git a/packages/app-core/src/lib/cm-wysiwyg-compose.test.ts b/packages/app-core/src/lib/cm-wysiwyg-compose.test.ts new file mode 100644 index 00000000..ab364622 --- /dev/null +++ b/packages/app-core/src/lib/cm-wysiwyg-compose.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment jsdom + +// Integration check: the WYSIWYG plugins (ported from PR #185) are loaded +// together by `wysiwygExtensions()` in EditorPane. Each has its own unit +// test; this verifies they COMPOSE on one document without conflicting +// (e.g. overlapping decorations) and that every block renders at once. + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it, vi } from 'vitest' + +import { livePreviewPlugin } from './cm-live-preview' +import { codeBlockFlairPlugin } from './cm-code-block-flair' +import { tablePlugin } from './cm-table' +import { wysiwygBlocksPlugin } from './cm-wysiwyg-blocks' +import { hashtagExtension } from './cm-hashtags' +import { wikilinkRenderExtension } from './cm-wikilink-render' + +vi.mock('../store', () => { + const state = { + activeNote: null, + assetFiles: [], + notes: [], + noteRefs: {}, + pdfEmbedInEditMode: 'compact', + pinnedRefKind: 'note', + pinnedRefPath: null, + vault: null + } + const useStore = Object.assign(() => null, { + getState: () => state, + subscribe: () => () => {} + }) + return { useStore } +}) + +const RICH_DOC = [ + '# Title', + '', + 'Body with **bold**, ~~struck~~, a #tag and a [[WikiLink]].', + '', + '> a blockquote line', + '', + '- bullet one', + '- bullet two', + '', + '---', + '', + '| A | B |', + '| - | - |', + '| 1 | 2 |', + '', + '```js', + 'const x = 1', + '```', + '' +].join('\n') + +describe('wysiwyg plugin composition', () => { + it('renders every block construct together without conflicting', () => { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + // Cursor on the title line so every block below is inactive (rendered). + doc: RICH_DOC, + selection: { anchor: 0 }, + extensions: [ + markdown({ base: markdownLanguage }), + livePreviewPlugin, + codeBlockFlairPlugin, + tablePlugin, + wysiwygBlocksPlugin, + hashtagExtension, + wikilinkRenderExtension + ] + }) + }) + // Force a full parse + a no-op edit so every viewport-driven plugin emits. + forceParsing(view, RICH_DOC.length, 5000) + view.dispatch({ changes: { from: RICH_DOC.length, insert: ' ' } }) + view.dispatch({ changes: { from: RICH_DOC.length, to: RICH_DOC.length + 1 } }) + + expect(view.dom.querySelectorAll('.cm-table-widget').length).toBeGreaterThanOrEqual(1) + expect(view.dom.querySelectorAll('.cm-wq-hr').length).toBeGreaterThanOrEqual(1) + expect(view.dom.querySelectorAll('.cm-wq-bullet').length).toBeGreaterThanOrEqual(2) + expect(view.dom.querySelectorAll('.cm-wq-quote').length).toBeGreaterThanOrEqual(1) + expect(view.dom.querySelectorAll('.cm-hashtag').length).toBeGreaterThanOrEqual(1) + expect(view.dom.querySelectorAll('.cm-wikilink').length).toBeGreaterThanOrEqual(1) + expect(view.dom.querySelectorAll('.cm-code-flair').length).toBeGreaterThanOrEqual(1) + + view.destroy() + }) +}) diff --git a/packages/app-core/src/lib/cm-yank-highlight.ts b/packages/app-core/src/lib/cm-yank-highlight.ts new file mode 100644 index 00000000..5a6ff42b --- /dev/null +++ b/packages/app-core/src/lib/cm-yank-highlight.ts @@ -0,0 +1,101 @@ +/** + * Brief highlight over the just-yanked text, like Neovim's + * `vim.highlight.on_yank()`. codemirror-vim sets the editor selection to the + * operator range while a yank runs, so the range is read from the active view's + * selection at yank time (see cm-vim-clipboard's yank hook) and flashed here. + */ +import { StateEffect, StateField, type Extension } from '@codemirror/state' +import { Decoration, type DecorationSet, EditorView } from '@codemirror/view' +import { useStore } from '../store' +import { setVimYankHandler } from './cm-vim-clipboard' + +const FLASH_MS = 160 + +const setYankHighlight = StateEffect.define<readonly { from: number; to: number }[] | null>() + +const yankMark = Decoration.mark({ class: 'cm-yank-highlight' }) + +const yankHighlightField = StateField.define<DecorationSet>({ + create: () => Decoration.none, + update(deco, tr) { + deco = deco.map(tr.changes) + for (const effect of tr.effects) { + if (!effect.is(setYankHighlight)) continue + deco = effect.value + ? Decoration.set(effect.value.map((r) => yankMark.range(r.from, r.to))) + : Decoration.none + } + return deco + }, + provide: (f) => EditorView.decorations.from(f) +}) + +const yankHighlightTheme = EditorView.baseTheme({ + '.cm-yank-highlight': { + backgroundColor: 'rgb(var(--z-accent) / 0.3)', + borderRadius: '2px' + } +}) + +export const yankHighlightExtension: Extension = [yankHighlightField, yankHighlightTheme] + +const clearTimers = new WeakMap<EditorView, ReturnType<typeof setTimeout>>() + +/** + * Flash a highlight over `ranges`, then clear it after a short delay. The + * dispatch is deferred because the yank runs inside codemirror-vim's own update, + * where a synchronous re-dispatch would throw. + */ +function flashYankHighlight( + view: EditorView, + ranges: readonly { from: number; to: number }[] +): void { + const doc = view.state.doc + const trimmed = ranges + .map((r) => { + let to = Math.min(r.to, doc.length) + const from = Math.min(r.from, to) + // Drop trailing newlines so a linewise yank doesn't bleed into the next line. + while (to > from && doc.sliceString(to - 1, to) === '\n') to-- + return { from, to } + }) + .filter((r) => r.to > r.from) + if (trimmed.length === 0) return + setTimeout(() => { + try { + view.dispatch({ effects: setYankHighlight.of(trimmed) }) + } catch { + return + } + const prev = clearTimers.get(view) + if (prev) clearTimeout(prev) + clearTimers.set( + view, + setTimeout(() => { + clearTimers.delete(view) + try { + view.dispatch({ effects: setYankHighlight.of(null) }) + } catch { + /* view already destroyed */ + } + }, FLASH_MS) + ) + }, 0) +} + +let wired = false + +/** + * Install the yank handler that flashes the active editor on every Vim yank. + * Idempotent — safe to call from each editor pane's mount. + */ +export function wireYankHighlight(): void { + if (wired) return + wired = true + setVimYankHandler(() => { + const view = useStore.getState().editorViewRef + if (!view) return + const ranges = view.state.selection.ranges.map((r) => ({ from: r.from, to: r.to })) + flashYankHighlight(view, ranges) + }) +} diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 63e703ac..abf94645 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -118,6 +118,16 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => getState().vaultSettings.dailyNotes.enabled, run: () => getState().openTodayDailyNote() }, + { + id: 'note.daily.rollover', + title: 'Roll Over Unfinished Tasks to Today', + category: 'Note', + keywords: 'daily tasks rollover roll over migrate unfinished carry forward today', + when: () => getState().vaultSettings.dailyNotes.enabled, + run: () => { + void getState().rolloverUnfinishedTasksIntoToday({ force: true, open: true }) + } + }, { id: 'note.weekly.thisWeek', title: "Open This Week's Note", @@ -273,6 +283,16 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma window.zen.clipboardWriteText(`[[${title}]]`) } }, + { + id: 'note.copy-markdown', + title: 'Copy Note as Markdown', + category: 'Note', + keywords: 'copy clipboard markdown source document whole content text yank', + when: () => !!getState().activeNote, + run: async () => { + await getState().copyActiveNoteAsMarkdown() + } + }, { id: 'note.export-pdf', title: 'Export Note as PDF…', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 0e7e38d7..d319cfcf 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -53,6 +53,11 @@ export const HELP_QUICK_START: HelpCard[] = [ body: 'Type `/` to insert headings, lists, callouts, code blocks, tables, links, images, and other markdown structures. Type `@` to insert date shortcuts like Today and Tomorrow as ISO dates.' }, + { + title: 'Format a selection', + body: + 'Select text to pop up a formatting toolbar — bold, italic, strikethrough, highlight, code, math, link, comment, and a “Turn into” menu that re-types the block (Text, Heading 1–3, lists, quote, code). The same actions have keyboard shortcuts that work on every platform, in or out of Vim mode: Mod+B bold, Mod+I italic, Mod+E code, Mod+K link, Shift+Mod+S strikethrough, Shift+Mod+H highlight, Shift+Mod+M math (Mod is ⌘ on macOS, Ctrl on Windows/Linux). Press Mod+/ to focus the toolbar and walk it with the arrow keys; Enter applies, Esc returns to the text.' + }, { title: 'Switch between write and read modes', body: @@ -106,6 +111,11 @@ export const HELP_HOW_TO_GUIDES: HelpCard[] = [ body: 'Open Settings → Templates. Press “New template” to author one: a template is just markdown with optional YAML frontmatter (`name`, `description`, `category`, `titleTemplate`, `targetFolder`, `targetSubpath`) and a body. Use the variables `{{title}}`, `{{date}}`, `{{date:YYYY-MM-DD}}` (any moment-style format), `{{time}}`, `{{week}}`, and `{{cursor}}` (where the caret lands). Custom templates are saved as plain `.md` files under `.zennotes/templates/`. You can also fork a built-in by pressing Edit on it — that creates an editable copy that shadows the original, and Reset restores the built-in. From any note, the “Save Current Note as Template…” command captures it as a new template.' }, + { + title: 'Turn a CSV into a database', + body: + 'Run “New Database” from the command palette (or right-click a folder in the sidebar → New database) to create one, or just open an existing `.csv` file from the vault. ZenNotes stores the data as `<Name>.csv` plus a small `<Name>.csv.base.json` sidecar that holds field types, select options, and your saved views. Edit cells inline in the Table view, group records in a Board by any select field, switch the raw-CSV toggle to see the underlying file, and press `o` on a row to open it as a full Markdown page whose frontmatter mirrors the row’s properties. The whole grid is keyboard-driven — see the Database grid shortcuts.' + }, { title: 'Move a note without dragging', body: @@ -177,7 +187,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Comments attach to selected text', body: - 'Select text in the editor and use the text menu to add a comment. ZenNotes stores note comments beside the note in vault metadata, then highlights the anchored text and line when the comment is active.' + 'Select text in the editor and press `Mod+Alt+M` — or open the text menu with `m` — to start a comment, and toggle the Comments panel itself with `Mod+Shift+C`. ZenNotes stores note comments beside the note in vault metadata, then highlights the anchored text and line when the comment is active. In the panel, move with `j` / `k` and use `e` to edit, `r` to resolve, and `d` to delete.' }, { title: 'Sessions restore on relaunch', @@ -194,6 +204,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'Tasks scans every note for checkboxes, Tags lets you browse notes matching any selected tag, Archive gives you a dedicated list of cold-storage notes, and Trash gives you a recovery surface for deleted notes without turning the left rail into a second browser.' }, + { + title: 'The Tasks calendar schedules and reschedules', + body: + "Switch Tasks to Calendar (button or `2`) to see tasks laid out by due date. A task written inside a daily note automatically shows on that day — no `due:` needed — so the day you wrote it on is the day it lands. Type in the box under the grid to add a task to the selected day (it’s created in that day’s daily note, offering to create the note first for a day that has none). Reschedule by dragging a task onto another day, or from the keyboard: `Tab` picks a task in the day list, `<` / `>` shifts it a day earlier/later, and `T` moves it to today." + }, { title: 'Moving notes is path-first', body: @@ -239,6 +254,11 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ body: 'Drop files into a note to insert local files. By default, ZenNotes keeps them as ordinary files in the vault root, can reveal them from the app, and opens images, SVGs, PDFs, audio, video, and generic files inside ZenNotes tabs or reference panes where possible.' }, + { + title: 'Any CSV is a database', + body: + 'A `.csv` file in your vault is a full Notion-style database, with zero new dependencies. The same data shows up as an editable Table (inline cell editing) and as a Board grouped by a select field; add and switch views freely. Fields are typed — text, number, checkbox, date, select, multi-select — and support sort, filter, and a raw-CSV toggle, while every row keeps a stable id so external edits round-trip cleanly. Open any row as a real Markdown note — a “record page” in a per-database folder — whose frontmatter mirrors the row’s properties and whose body is a freeform page. Create one with “New Database” in the command palette or by right-clicking a folder → New database.' + }, { title: 'The CLI is the bridge to launchers', body: @@ -280,6 +300,9 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+,', action: 'Open Settings', detail: 'Open settings for appearance, editor behavior, fonts, vault controls, and app details.' }, { keys: 'Mod+1', action: 'Toggle sidebar', detail: 'Hide or show the left sidebar.' }, { keys: 'Mod+2', action: 'Toggle connections', detail: 'Toggle the connections panel for the active editor pane.' }, + { keys: 'Mod+Shift+C', action: 'Toggle comments panel', detail: 'Show or hide the Comments panel for the active pane.' }, + { keys: 'Mod+Alt+M', action: 'Add comment', detail: 'Start a comment on the selected text (or the current line) without reaching for the mouse.' }, + { keys: 'Alt+H / Alt+J / Alt+K / Alt+L', action: 'Focus pane left / down / up / right', detail: 'Always-on pane-focus motions — they work even with Vim mode off and skip the Ctrl+W prefix some Linux setups intercept. (Ctrl+W h/j/k/l still works in Vim mode.)' }, { keys: 'Mod+.', action: 'Toggle Zen mode', detail: 'Hide or restore the app chrome so only the active editor, preview, or split view stays on screen.' }, { keys: 'Mod+W', action: 'Close active tab', detail: 'Close the current note or virtual tab.' }, { keys: 'Shift+Mod+E', action: 'Export note as PDF', detail: 'Export the active note as a PDF file.' }, @@ -323,7 +346,20 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'zM / zR', action: 'Fold / unfold all', detail: 'Collapse or expand every heading section in the note.' }, { keys: 'Ctrl-o', action: 'Go back', detail: 'Jump to the previous note location in history.' }, { keys: 'Ctrl-i', action: 'Go forward', detail: 'Jump forward in note history.' }, - { keys: 'f', action: 'Hint mode', detail: 'Show jump labels for clickable targets when you are not in insert mode.' } + { keys: 'Space h', action: 'Hint mode', detail: 'Show jump labels over clickable targets — links, buttons, sidebar rows, tabs — so you can activate any of them from the keyboard. Works outside insert mode, including in the Tasks and Tags views.' } + ] + }, + { + id: 'palettes-and-pickers', + title: 'Palettes and pickers', + description: + 'These apply once a palette, search overlay, or picker already has focus — the command palette, note search, vault text search, outline, buffer switcher, the [[ reference picker, the / slash menu, and the date and template pickers.', + items: [ + { keys: 'ArrowDown / Ctrl+N / Ctrl+J', action: 'Next result', detail: 'Move the selection down. Ctrl+J / Ctrl+K behave the same in every picker, so they no longer collide with the global Search-notes shortcut on Windows and Linux.' }, + { keys: 'ArrowUp / Ctrl+P / Ctrl+K', action: 'Previous result', detail: 'Move the selection up.' }, + { keys: 'Enter', action: 'Run or open', detail: 'Open the selected note, heading, buffer, command, or search hit.' }, + { keys: 'Type to filter', action: 'Narrow the list', detail: 'Each picker filters its own data live as you type.' }, + { keys: 'Esc', action: 'Close the picker', detail: 'Dismiss the overlay and return focus to the previous surface.' } ] }, { @@ -397,17 +433,32 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { id: 'tasks-tags-trash', title: 'Tasks, tags, and trash views', - description: 'These virtual views each run their own keyboard loop in the main pane.', + description: 'These virtual views each run their own keyboard loop in the main pane, and the Vim leader works here too (for example Space h for hint mode).', items: [ { keys: 'j / k', action: 'Move row cursor', detail: 'Step through task rows, tagged notes, or trashed notes.' }, { keys: 'g g / G', action: 'Jump to top or bottom', detail: 'Move to the first or last visible result.' }, { keys: 'Enter / o', action: 'Open current result', detail: 'Open the selected task source note, tagged note, or trashed note.' }, - { keys: 'Space / x', action: 'Toggle task', detail: 'Tasks view only: check or uncheck the selected task.' }, + { keys: 'x', action: 'Toggle task', detail: 'Tasks view only: check or uncheck the selected task. Space also toggles unless Space is your Vim leader key, in which case it starts a leader sequence.' }, { keys: 'r', action: 'Restore trashed note', detail: 'Trash view only: restore the selected trashed note.' }, { keys: 'x / d', action: 'Delete forever', detail: 'Trash view only: permanently delete the selected trashed note after confirmation.' }, { keys: '/', action: 'Filter the view', detail: 'Focus the local filter box for tasks, tag matches, or trashed notes.' }, { keys: ':', action: 'Open local ex prompt', detail: 'Run the view-specific command line inside Tasks or Tags.' }, - { keys: 'Esc', action: 'Close or clear', detail: 'Clear the filter first, then close the active virtual view on a second press.' } + { keys: 'Esc', action: 'Clear the filter', detail: 'Clears an active filter. These views are tabs, so Esc no longer closes them — close with :q or the ✕ in the tab header.' } + ] + }, + { + id: 'database-grid', + title: 'Database grid (Table view)', + description: 'Vim-style motions when a CSV database table has focus. The grid yields to these keys so they do not collide with global motions.', + items: [ + { keys: 'h / j / k / l', action: 'Move the cell cursor', detail: 'Arrow keys also work. 0 / ^ jump to the first column, $ to the last.' }, + { keys: 'g g / G', action: 'Jump to first / last row', detail: 'Fast travel within the current column.' }, + { keys: 'i / Enter', action: 'Edit the cell', detail: 'On a checkbox cell this toggles it instead of opening an editor.' }, + { keys: 'Space / x', action: 'Select the row', detail: 'Toggle the row’s selection for bulk actions.' }, + { keys: 'o', action: 'Open the record page', detail: 'Open the row as a Markdown note in the per-database folder.' }, + { keys: 'a', action: 'Add a row', detail: 'Append a new empty record and move the cursor to it.' }, + { keys: 'd d', action: 'Delete the row', detail: 'Remove the record at the cursor.' }, + { keys: 'Esc', action: 'Clear selection / leave the grid', detail: 'Clears a multi-row selection first, then blurs the grid.' } ] } ] @@ -451,7 +502,7 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ { command: ':weekly', summary: "Open this week's note", - detail: 'Open or create this week’s note with a YYYY-Www title (requires weekly notes enabled in Settings → Vault). Uses the assigned weekly template if one is set.' + detail: 'Open or create this week’s note with the configured weekly note pattern (requires weekly notes enabled in Settings → Vault). Uses the assigned weekly template if one is set.' }, { command: ':tag foo bar', @@ -543,11 +594,21 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ summary: 'Leader-format in normal mode', detail: 'A quick keyboard path to format the active note from the editor.' }, + { + command: '<Space> l y', + summary: 'Leader-copy note as Markdown', + detail: "Copy the whole note's Markdown source to the clipboard from the editor (also available as the “Copy Note as Markdown” command)." + }, { command: '<Space> (pause)', summary: 'Show leader hints', detail: 'When Leader key hints are enabled, pressing the configured Leader key shows a which-key style overlay for the next available leader actions. Settings let you choose a timed timeout or a sticky mode that stays open until you dismiss it. Turning Vim mode off disables the leader system too.' }, + { + command: '<Space> h', + summary: 'Leader hint mode', + detail: 'Show jump labels over clickable targets — links, buttons, sidebar rows, tabs — to activate any of them from the keyboard. Works in the editor, sidebar, and the Tasks and Tags views.' + }, { command: '<Space> o', summary: 'Leader buffer switcher', @@ -624,7 +685,7 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { title: 'Appearance', items: [ - { label: 'Theme, mode, and variant', detail: 'Pick a theme family, light or dark mode, and the active flavor or contrast where the theme supports it.' }, + { label: 'Theme, mode, and variant', detail: 'Pick a theme family — Apple, Gruvbox, Catppuccin, GitHub, Solarized, One, Nord, Tokyo Night, or the monochrome, true-black (OLED-friendly) Black Metal — plus light or dark mode and the active flavor or contrast where the theme supports it.' }, { label: 'Dark sidebar', detail: 'Tint the sidebar slightly darker than the canvas so the chrome reads as a distinct surface.' }, { label: 'Sidebar arrows', detail: 'Show or hide disclosure arrows for collapsible sidebar folders and sections.' } ] @@ -669,8 +730,8 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ items: [ { label: 'Vault location', detail: 'Reveal or change the root folder ZenNotes treats as the active vault.' }, { label: 'Primary notes location', detail: 'Treat `inbox/` as the main notes area, or use the vault root directly for an Obsidian-style flat vault.' }, - { label: 'Daily notes', detail: 'Enable a daily-notes workflow, choose its directory, and assign a template so each day’s note starts pre-filled. Open today’s note with `Space d`, `:daily`, or the command palette.' }, - { label: 'Weekly notes', detail: 'Enable weekly notes with a YYYY-Www title, choose a directory, and assign a template. Open this week’s note with `Space w`, `:weekly`, or the command palette.' }, + { label: 'Daily notes', detail: "Enable a daily-notes workflow, choose a directory pattern, naming pattern, locale, and template so each day’s note starts in the right place. Supported tokens are `yyyy`, `yy`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `EEE`, `EEEE`, `w`, and `ww`; quote literal words like `'Daily Notes'/yyyy/MM-MMM`. Open today’s note with `Space d`, `:daily`, or the command palette. Two task options live here too: “Tasks are due on the note’s date” makes tasks in a daily note show on the calendar for that day (on by default), and “Roll over unfinished tasks to today” moves every unchecked task from past daily notes into today when you open it (off by default; also runnable from the command palette)." }, + { label: 'Weekly notes', detail: "Enable weekly notes with a directory pattern, naming pattern, locale, and template. Weekly patterns support the same tokens as daily notes plus ISO week `w` and `ww`; the default title pattern is `yyyy-'W'ww`. Open this week’s note with `Space w`, `:weekly`, or the command palette." }, { label: 'System folder labels', detail: 'Rename how Inbox, Quick Notes, Archive, and Trash appear in the UI without renaming the real folders on disk.' } ] }, diff --git a/packages/app-core/src/lib/ime.test.ts b/packages/app-core/src/lib/ime.test.ts new file mode 100644 index 00000000..88d0ef1f --- /dev/null +++ b/packages/app-core/src/lib/ime.test.ts @@ -0,0 +1,32 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' +import { isImeComposing } from './ime' + +// React synthetic events expose composition state on `.nativeEvent`; raw DOM +// listeners (window/document) get a native KeyboardEvent with `.isComposing`. +function reactEvent(isComposing: boolean, keyCode = 13): ReactKeyboardEvent { + return { nativeEvent: { isComposing } as KeyboardEvent, keyCode } as ReactKeyboardEvent +} +function nativeEvent(isComposing: boolean, keyCode = 13): KeyboardEvent { + return { isComposing, keyCode } as KeyboardEvent +} + +describe('isImeComposing (#183)', () => { + it('is true while a React synthetic event is composing', () => { + expect(isImeComposing(reactEvent(true))).toBe(true) + }) + + it('is false for a React Enter that is not composing', () => { + expect(isImeComposing(reactEvent(false))).toBe(false) + }) + + it('reads composition state from a native KeyboardEvent (no nativeEvent field)', () => { + expect(isImeComposing(nativeEvent(true))).toBe(true) + expect(isImeComposing(nativeEvent(false))).toBe(false) + }) + + it('honors the legacy keyCode === 229 fallback for both event shapes', () => { + expect(isImeComposing(reactEvent(false, 229))).toBe(true) + expect(isImeComposing(nativeEvent(false, 229))).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/ime.ts b/packages/app-core/src/lib/ime.ts new file mode 100644 index 00000000..1e9b7ecf --- /dev/null +++ b/packages/app-core/src/lib/ime.ts @@ -0,0 +1,17 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +/** + * True while an IME (Japanese / Chinese / Korean, etc.) composition is in + * progress. The keystroke that confirms an IME conversion is usually Enter (and + * Space/Arrows/Tab navigate candidates), so our own keydown handlers must ignore + * those keys while composing — otherwise pressing Enter to accept a conversion + * also submits the rename, selects the search result, blurs the field, and so + * on. + * + * `isComposing` is the standard signal; `keyCode === 229` is the legacy fallback + * some browsers/IMEs still report during composition. (#183) + */ +export function isImeComposing(e: ReactKeyboardEvent | KeyboardEvent): boolean { + const composing = 'nativeEvent' in e ? e.nativeEvent.isComposing : e.isComposing + return composing || e.keyCode === 229 +} diff --git a/packages/app-core/src/lib/inline-markdown.tsx b/packages/app-core/src/lib/inline-markdown.tsx index 4419b82a..0533fd17 100644 --- a/packages/app-core/src/lib/inline-markdown.tsx +++ b/packages/app-core/src/lib/inline-markdown.tsx @@ -96,7 +96,7 @@ const MATCHERS: Matcher[] = [ // `#tag` tokens are pulled out of plain-text runs only — never from inside // code spans or URLs. Mirrors the boundary rule the note renderer uses. -const TAG_RE = /(^|\s)#([a-zA-Z][\w/-]*)/g +const TAG_RE = /(^|\s)#(\p{L}[\p{L}\d_/-]*)/gu function pushText(tokens: InlineToken[], text: string): void { if (!text) return diff --git a/packages/app-core/src/lib/internal-links.test.ts b/packages/app-core/src/lib/internal-links.test.ts new file mode 100644 index 00000000..cdde4ea0 --- /dev/null +++ b/packages/app-core/src/lib/internal-links.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { + externalLinkUrl, + extractLinkAtCursor, + markdownLinkAt, + resolveInternalNoteHref +} from './internal-links' + +const NOTES = [ + { path: 'Work/Documentation/Vault CLI Cheatsheet.md', folder: 'inbox' }, + { path: 'Work/Documentation/Another Note.md', folder: 'inbox' }, + { path: 'Work/Projects/plan.md', folder: 'inbox' }, + { path: 'index.md', folder: 'inbox' }, + { path: 'Archive/old plan.md', folder: 'trash' } +] + +describe('resolveInternalNoteHref', () => { + const from = 'Work/Documentation/Vault CLI Cheatsheet.md' + + it('resolves a same-folder relative link', () => { + expect(resolveInternalNoteHref(from, 'Another Note.md', NOTES)).toEqual({ + path: 'Work/Documentation/Another Note.md', + heading: null + }) + }) + + it('resolves a `../` relative link', () => { + expect(resolveInternalNoteHref(from, '../Projects/plan.md', NOTES)?.path).toBe( + 'Work/Projects/plan.md' + ) + }) + + it('resolves a vault-absolute (leading slash) link', () => { + expect(resolveInternalNoteHref(from, '/index.md', NOTES)?.path).toBe('index.md') + }) + + it('decodes percent-encoded spaces (Obsidian Markdown-link style)', () => { + expect(resolveInternalNoteHref(from, 'Another%20Note.md', NOTES)?.path).toBe( + 'Work/Documentation/Another Note.md' + ) + }) + + it('carries a #heading anchor', () => { + expect(resolveInternalNoteHref(from, 'Another%20Note.md#My%20Heading', NOTES)).toEqual({ + path: 'Work/Documentation/Another Note.md', + heading: 'My Heading' + }) + }) + + it('tolerates a missing .md extension', () => { + expect(resolveInternalNoteHref(from, 'Another Note', NOTES)?.path).toBe( + 'Work/Documentation/Another Note.md' + ) + }) + + it('falls back to a unique basename match when the path is off', () => { + // `plan.md` doesn't exist in this folder, but there's exactly one elsewhere. + expect(resolveInternalNoteHref(from, 'plan.md', NOTES)?.path).toBe('Work/Projects/plan.md') + }) + + it('returns null for external and in-page links', () => { + for (const href of [ + 'https://example.com', + 'http://x.test/a.md', + 'mailto:a@b.com', + '#heading', + '//cdn.test/x' + ]) { + expect(resolveInternalNoteHref(from, href, NOTES), href).toBeNull() + } + }) + + it('returns null when nothing matches (e.g. an asset or missing note)', () => { + expect(resolveInternalNoteHref(from, 'diagram.png', NOTES)).toBeNull() + expect(resolveInternalNoteHref(from, 'Nope.md', NOTES)).toBeNull() + }) + + it('never resolves to a trashed note', () => { + expect(resolveInternalNoteHref(from, '../../Archive/old plan.md', NOTES)).toBeNull() + }) + + it('returns null when the path escapes the vault', () => { + expect(resolveInternalNoteHref('index.md', '../secrets.md', NOTES)).toBeNull() + }) +}) + +describe('extractLinkAtCursor', () => { + it('pulls a Markdown link url under the cursor', () => { + const doc = 'see [the plan](Work/Projects/plan.md) here' + expect(extractLinkAtCursor(doc, doc.indexOf('plan.md'))).toBe('Work/Projects/plan.md') + }) + + it('pulls a wikilink target under the cursor', () => { + const doc = 'see [[Another Note]] here' + expect(extractLinkAtCursor(doc, doc.indexOf('Another'))).toBe('Another Note') + }) + + it('unwraps an angle-bracketed url with spaces', () => { + const doc = '[x](<a b.md>)' + expect(extractLinkAtCursor(doc, 2)).toBe('a b.md') + }) + + it('returns null when not inside a link', () => { + expect(extractLinkAtCursor('just text', 3)).toBeNull() + }) +}) + +describe('externalLinkUrl', () => { + it('keeps explicit web/scheme URLs as-is', () => { + expect(externalLinkUrl('https://google.com')).toBe('https://google.com') + expect(externalLinkUrl('http://x.test/a')).toBe('http://x.test/a') + expect(externalLinkUrl('mailto:a@b.com')).toBe('mailto:a@b.com') + }) + + it('promotes a bare domain a user typed without a scheme', () => { + expect(externalLinkUrl('google.com')).toBe('https://google.com') + expect(externalLinkUrl('www.example.com/path?q=1')).toBe('https://www.example.com/path?q=1') + }) + + it('is null for note links, relative paths, files and anchors', () => { + for (const href of [ + 'Another Note.md', + 'folder/Note.md', + '../x.md', + '/index.md', + 'image.png', + 'report.pdf', + '#heading' + ]) { + expect(externalLinkUrl(href), href).toBeNull() + } + }) +}) + +describe('markdownLinkAt', () => { + it('returns the href and source range of the link under the cursor', () => { + const doc = 'a [test](google.com) b' + const at = markdownLinkAt(doc, doc.indexOf('test')) + expect(at).toEqual({ href: 'google.com', from: 2, to: 2 + '[test](google.com)'.length }) + }) + + it('returns null when the cursor is outside any link', () => { + expect(markdownLinkAt('a [test](google.com) b', 0)).toBeNull() + }) +}) diff --git a/packages/app-core/src/lib/internal-links.ts b/packages/app-core/src/lib/internal-links.ts new file mode 100644 index 00000000..3027c1db --- /dev/null +++ b/packages/app-core/src/lib/internal-links.ts @@ -0,0 +1,199 @@ +/** + * Standard-Markdown links to other notes — `[text](path/to/Note.md)` — should + * navigate the same way `[[wikilinks]]` do (#201). Unlike a wikilink (resolved + * by global note name), a Markdown link's href is a path resolved RELATIVE to + * the note that contains it, exactly like Markdown / Obsidian's "Markdown + * links" mode. This module holds the pure resolution so the editor (`gd`, + * Cmd/Ctrl-click) and the rendered preview can all share it. + */ + +export interface InternalNoteLink { + /** Vault-relative path of the resolved note. */ + path: string + /** A `#heading` anchor carried by the link, or null. */ + heading: string | null +} + +interface NoteRef { + path: string + folder?: string +} + +function decode(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function posixJoin(a: string, b: string): string { + if (!a) return b + if (!b) return a + return a.endsWith('/') ? `${a}${b}` : `${a}/${b}` +} + +function posixNormalize(input: string): string { + const out: string[] = [] + for (const part of input.split('/')) { + if (!part || part === '.') continue + if (part === '..') { + if (out.length === 0 || out[out.length - 1] === '..') out.push('..') + else out.pop() + } else { + out.push(part) + } + } + return out.join('/') +} + +const lc = (s: string): string => s.toLowerCase() + +/** External / non-note targets we must leave to their existing handlers. */ +function isExternalHref(href: string): boolean { + return ( + href.startsWith('#') || // same-note anchor + href.startsWith('//') || // protocol-relative URL + /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href) // scheme: http:, mailto:, zen-asset:, … + ) +} + +function matchNote(notes: NoteRef[], target: string): string | null { + const visible = notes.filter((n) => n.folder !== 'trash') + // Markdown links usually carry the `.md`; tolerate links that omit it. + const candidates = /\.md$/i.test(target) ? [target] : [`${target}.md`, target] + for (const cand of candidates) { + const exact = visible.find((n) => lc(n.path) === lc(cand)) + if (exact) return exact.path + } + // Basename fallback for a single unambiguous match — tolerates a link + // written before the note moved, mirroring wikilink path-suffix resolution. + const wantBase = lc(candidates[0].split('/').pop() ?? '') + if (!wantBase) return null + const baseMatches = visible.filter((n) => lc(n.path.split('/').pop() ?? '') === wantBase) + return baseMatches.length === 1 ? baseMatches[0].path : null +} + +/** + * Resolve a Markdown link href to an internal note, relative to `notePath`. + * Returns null for external links, in-page anchors, assets, or no match. + */ +export function resolveInternalNoteHref( + notePath: string | null | undefined, + href: string, + notes: NoteRef[] +): InternalNoteLink | null { + if (!notePath) return null + const raw = href.trim() + if (!raw || isExternalHref(raw)) return null + + const hashIdx = raw.indexOf('#') + const rawPath = hashIdx >= 0 ? raw.slice(0, hashIdx) : raw + if (!rawPath) return null // pure "#heading" — same note, handled elsewhere + const heading = hashIdx >= 0 ? decode(raw.slice(hashIdx + 1)).trim() || null : null + + const decoded = decode(rawPath) + const noteDir = notePath.includes('/') ? notePath.slice(0, notePath.lastIndexOf('/')) : '' + let target = decoded.startsWith('/') + ? decoded.replace(/^\/+/, '') + : noteDir + ? posixJoin(noteDir, decoded) + : decoded + target = posixNormalize(target) + if (!target || target === '..' || target.startsWith('../')) return null + + const match = matchNote(notes, target) + return match ? { path: match, heading } : null +} + +function unwrapMdUrl(url: string): string { + // Markdown wraps URLs containing spaces in angle brackets: `[x](<a b.pdf>)`. + const trimmed = url.trim() + if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1) + return trimmed +} + +const LOCAL_FILE_EXT_RE = + /\.(md|markdown|txt|png|apng|avif|gif|jpe?g|svg|webp|pdf|mp3|m4a|aac|flac|ogg|wav|mp4|m4v|mov|ogv|webm|canvas|excalidraw)$/i + +/** + * A fully-qualified URL to open in the browser, or null. Handles explicit + * `http(s)://` / `mailto:` / `tel:` links AND bare domains a user typed without + * a scheme — e.g. `[site](google.com)` or `[docs](example.com/path)` — which + * Markdown would otherwise treat as a dead relative link. Returns null for note + * links, relative paths, in-page anchors, and local files. (#201) + */ +export function externalLinkUrl(href: string): string | null { + const h = href.trim() + if (!h) return null + if (/^(https?:|mailto:|tel:)/i.test(h)) return h + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(h)) return null // another scheme — not ours + if (h.startsWith('#') || h.startsWith('/') || h.startsWith('.') || h.startsWith('//')) return null + // Bare domain heuristic: `host.tld` (one or more labels) optionally followed + // by a /path, ?query, or #fragment — but not something that looks like a + // local note/asset file. + const host = h.split(/[/?#]/)[0] ?? '' + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(host)) return null + if (LOCAL_FILE_EXT_RE.test(host)) return null + return `https://${h}` +} + +/** + * The link target at a document offset — a `[[wikilink]]` name, a Markdown + * link's URL, or a bare URL. Returns null when the offset isn't inside a link. + */ +export function extractLinkAtCursor(doc: string, pos: number): string | null { + const lineStart = doc.lastIndexOf('\n', pos - 1) + 1 + const lineEnd = doc.indexOf('\n', pos) + const line = doc.slice(lineStart, lineEnd === -1 ? undefined : lineEnd) + const col = pos - lineStart + const wikiRe = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g + let m: RegExpExecArray | null + while ((m = wikiRe.exec(line)) !== null) { + if (col >= m.index && col < m.index + m[0].length) return m[1] + } + // Angle-bracketed URLs can contain `)` so match them specifically first. + const mdAngleRe = /\[([^\]]*)\]\(<([^>]+)>\)/g + while ((m = mdAngleRe.exec(line)) !== null) { + if (col >= m.index && col < m.index + m[0].length) return m[2] + } + const mdRe = /\[([^\]]*)\]\(([^)]+)\)/g + while ((m = mdRe.exec(line)) !== null) { + if (col >= m.index && col < m.index + m[0].length) return unwrapMdUrl(m[2]) + } + const urlRe = /https?:\/\/[^\s)>\]]+/g + while ((m = urlRe.exec(line)) !== null) { + if (col >= m.index && col < m.index + m[0].length) return m[0] + } + return null +} + +/** + * The Markdown link `[label](href)` covering a document offset, with its full + * source range. Used to follow a *rendered* link on a plain click (when the + * selection is outside the range) while still allowing edits when the cursor is + * inside it — mirroring how `[[wikilinks]]` behave in the editor. (#201) + */ +export function markdownLinkAt( + doc: string, + pos: number +): { href: string; from: number; to: number } | null { + const lineStart = doc.lastIndexOf('\n', pos - 1) + 1 + const lineEnd = doc.indexOf('\n', pos) + const line = doc.slice(lineStart, lineEnd === -1 ? undefined : lineEnd) + const col = pos - lineStart + const angleRe = /\[[^\]]*\]\(<([^>]+)>\)/g + let m: RegExpExecArray | null + while ((m = angleRe.exec(line)) !== null) { + if (col >= m.index && col < m.index + m[0].length) { + return { href: m[1], from: lineStart + m.index, to: lineStart + m.index + m[0].length } + } + } + const re = /\[[^\]]*\]\(([^)]+)\)/g + while ((m = re.exec(line)) !== null) { + if (col >= m.index && col < m.index + m[0].length) { + return { href: unwrapMdUrl(m[1]), from: lineStart + m.index, to: lineStart + m.index + m[0].length } + } + } + return null +} diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 4a29a7ba..0762727c 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -33,6 +33,8 @@ export type KeymapId = | "global.zoomIn" | "global.zoomOut" | "global.zoomReset" + | "global.historyBack" + | "global.historyForward" | "vim.leaderPrefix" | "vim.leaderOpenBuffers" | "vim.leaderSearchNotes" @@ -43,6 +45,8 @@ export type KeymapId = | "vim.leaderSwitchVault" | "vim.leaderNoteActions" | "vim.leaderFormatNote" + | "vim.leaderCopyMarkdown" + | "vim.leaderToggleFavorite" | "vim.leaderQuickCapture" | "vim.leaderTemplatePicker" | "vim.leaderInsertTemplate" @@ -321,6 +325,24 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ description: "Restore the app zoom factor to its default size.", defaultBinding: "Mod+0", }, + { + id: "global.historyBack", + kind: "shortcut", + scope: "app", + group: "global", + title: "Go back in note history", + description: "Jump to the previous note location in history. Works in any mode.", + defaultBinding: "Alt+ArrowLeft", + }, + { + id: "global.historyForward", + kind: "shortcut", + scope: "app", + group: "global", + title: "Go forward in note history", + description: "Jump forward in note history. Works in any mode.", + defaultBinding: "Alt+ArrowRight", + }, { id: "vim.leaderPrefix", kind: "sequence", @@ -431,6 +453,28 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderCopyMarkdown", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader note action: copy note as Markdown", + description: "Copy the whole note's Markdown source to the clipboard.", + defaultBinding: "y", + vimOnly: true, + maxTokens: 1, + }, + { + id: "vim.leaderToggleFavorite", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader note action: toggle favorite", + description: "Add or remove the active note from Favorites.", + defaultBinding: "s", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.leaderQuickCapture", kind: "sequence", diff --git a/packages/app-core/src/lib/local-assets-md-link.test.ts b/packages/app-core/src/lib/local-assets-md-link.test.ts new file mode 100644 index 00000000..c0519338 --- /dev/null +++ b/packages/app-core/src/lib/local-assets-md-link.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Regression guard for #201: a standard-Markdown link to another note — +// `[text](Note.md)` — must NOT be rewritten into a `zen-asset://` URL by the +// asset enhancer (which made the preview open note links as broken asset tabs). +// Real asset links (images/PDFs) still get tagged. + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getCapabilities: vi.fn().mockReturnValue({ + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false + }), + resolveLocalAssetUrl: vi.fn((_root: string, _note: string, href: string) => `zen-asset://v/${href}`), + resolveVaultAssetUrl: vi.fn((_root: string, rel: string) => `zen-asset://v/${rel}`) + } + }) +} + +async function load() { + vi.resetModules() + localStorage.clear() + installZen() + const { useStore } = await import('../store') + const { enhanceLocalAssetNodes } = await import('./local-assets') + return { useStore, enhanceLocalAssetNodes } +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('#201 — enhanceLocalAssetNodes leaves note links alone', () => { + it('tags a real asset link but not a `.md` note link', async () => { + const { useStore, enhanceLocalAssetNodes } = await load() + useStore.setState({ assetFiles: [{ path: 'Folder/pic.png' }] as never }) + + const root = document.createElement('div') + root.innerHTML = [ + '<a id="note" href="Another Note.md">a note</a>', + '<a id="noteh" href="Another%20Note.md#Heading">with heading</a>', + '<a id="asset" href="pic.png">an image</a>' + ].join('') + + enhanceLocalAssetNodes(root, { vaultRoot: '/v', notePath: 'Folder/b.md' }) + + const note = root.querySelector<HTMLAnchorElement>('#note')! + const noteh = root.querySelector<HTMLAnchorElement>('#noteh')! + const asset = root.querySelector<HTMLAnchorElement>('#asset')! + + // Note links keep their original href and are NOT tagged as assets. + expect(note.dataset.localAssetUrl).toBeUndefined() + expect(note.getAttribute('href')).toBe('Another Note.md') + expect(noteh.dataset.localAssetUrl).toBeUndefined() + + // The real image link still gets resolved + tagged. + expect(asset.dataset.localAssetUrl).toBe('zen-asset://v/Folder/pic.png') + }) +}) diff --git a/packages/app-core/src/lib/local-assets.ts b/packages/app-core/src/lib/local-assets.ts index c44b4dc7..5b99c7f1 100644 --- a/packages/app-core/src/lib/local-assets.ts +++ b/packages/app-core/src/lib/local-assets.ts @@ -1,4 +1,5 @@ import { useStore } from '../store' +import { externalLinkUrl } from './internal-links' const IMAGE_EXTENSIONS = new Set([ '.apng', @@ -398,6 +399,10 @@ export function enhanceLocalAssetNodes( root.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((anchor) => { if (anchor.classList.contains('wikilink') || anchor.classList.contains('hashtag')) return const raw = anchor.getAttribute('href') || '' + // A `.md` link is a note link, and an external web link (`google.com`, + // `https://…`) isn't a vault asset — leave both for the link-navigation + // handlers instead of rewriting them to a zen-asset URL. (#201) + if (/\.md(?:[#?].*)?$/i.test(raw.trim()) || externalLinkUrl(raw)) return const resolved = resolveLocalAssetUrl(vaultRoot, notePath, raw) if (!resolved) return diff --git a/packages/app-core/src/lib/markdown-snippets-config.ts b/packages/app-core/src/lib/markdown-snippets-config.ts new file mode 100644 index 00000000..72d98018 --- /dev/null +++ b/packages/app-core/src/lib/markdown-snippets-config.ts @@ -0,0 +1,22 @@ +import type { Extension } from '@codemirror/state' +import type { EditorView } from '@codemirror/view' +import { markdownSnippetExtension } from './cm-markdown-snippets' +import { isEditorInsertMode } from './vim-nav' +import { useStore } from '../store' + +/** + * Markdown snippet auto-close, wired to app state. Single source of truth for + * *when* snippets fire, shared by every editor surface: + * - respects the `markdownSnippets` pref (Settings → Writing), and + * - only fires while actually typing — Vim off, or Vim *insert* mode — never + * in Vim normal/visual mode, where Space/Enter belong to Vim. (songgenqing) + */ +export function appMarkdownSnippetExtension(): Extension { + return markdownSnippetExtension({ + shouldHandle: (view: EditorView) => { + const s = useStore.getState() + if (!s.markdownSnippets) return false + return !s.vimMode || isEditorInsertMode(view, s.vimMode) + } + }) +} diff --git a/packages/app-core/src/lib/markdown-table.test.ts b/packages/app-core/src/lib/markdown-table.test.ts new file mode 100644 index 00000000..4c4092ed --- /dev/null +++ b/packages/app-core/src/lib/markdown-table.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest' +import { + parseTable, + serializeTable, + insertRow, + deleteRow, + duplicateRow, + moveRow, + insertColumn, + deleteColumn, + duplicateColumn, + moveColumn, + setColumnAlign, + sortByColumn, + setCell, + clearCells, + cellWidth, + type MarkdownTable +} from './markdown-table' + +const SIMPLE = `| A | B | +| --- | --- | +| 1 | 2 | +| 3 | 4 |` + +function parse(src: string): MarkdownTable { + const t = parseTable(src) + expect(t).not.toBeNull() + return t as MarkdownTable +} + +describe('parseTable', () => { + it('parses headers, aligns, and rows', () => { + const t = parse(SIMPLE) + expect(t.headers).toEqual(['A', 'B']) + expect(t.aligns).toEqual(['none', 'none']) + expect(t.rows).toEqual([ + ['1', '2'], + ['3', '4'] + ]) + }) + + it('reads column alignments', () => { + const t = parse(`| a | b | c | +| :-- | :-: | --: | +| x | y | z |`) + expect(t.aligns).toEqual(['left', 'center', 'right']) + }) + + it('pads short rows and truncates long rows to header width', () => { + const t = parse(`| a | b | c | +| - | - | - | +| 1 | +| 1 | 2 | 3 | 4 |`) + expect(t.rows).toEqual([ + ['1', '', ''], + ['1', '2', '3'] + ]) + }) + + it('handles escaped pipes inside cells', () => { + const t = parse(`| a | b | +| - | - | +| x \\| y | z |`) + expect(t.rows[0]).toEqual(['x | y', 'z']) + }) + + it('tolerates missing leading/trailing pipes', () => { + const t = parse(`a | b +- | - +1 | 2`) + expect(t.headers).toEqual(['a', 'b']) + expect(t.rows).toEqual([['1', '2']]) + }) + + it('returns null when the second line is not a delimiter', () => { + expect(parseTable(`| a | b |\n| 1 | 2 |`)).toBeNull() + expect(parseTable(`just text`)).toBeNull() + }) +}) + +describe('serializeTable round-trip', () => { + it('produces aligned, padded markdown', () => { + const t = parse(SIMPLE) + expect(serializeTable(t)).toBe(`| A | B | +| --- | --- | +| 1 | 2 | +| 3 | 4 |`) + }) + + it('re-parses to an equal model (stable round-trip)', () => { + const t = parse(SIMPLE) + const again = parse(serializeTable(t)) + expect(again).toEqual(t) + }) + + it('keeps alignment markers in the delimiter', () => { + const t = parse(`| a | b | c | +| :-- | :-: | --: | +| x | y | z |`) + const out = serializeTable(t) + expect(out.split('\n')[1]).toBe('| :-- | :-: | --: |') + expect(parse(out).aligns).toEqual(['left', 'center', 'right']) + }) + + it('escapes pipes on the way out and round-trips them', () => { + const t = setCell(parse(SIMPLE), { row: 0, col: 0 }, 'a | b') + const out = serializeTable(t) + expect(out).toContain('a \\| b') + expect(parse(out).rows[0][0]).toBe('a | b') + }) +}) + +describe('row operations', () => { + it('inserts an empty row', () => { + const t = insertRow(parse(SIMPLE), 1) + expect(t.rows).toEqual([ + ['1', '2'], + ['', ''], + ['3', '4'] + ]) + }) + + it('deletes a row', () => { + expect(deleteRow(parse(SIMPLE), 0).rows).toEqual([['3', '4']]) + }) + + it('duplicates a row right below', () => { + expect(duplicateRow(parse(SIMPLE), 0).rows).toEqual([ + ['1', '2'], + ['1', '2'], + ['3', '4'] + ]) + }) + + it('moves a row', () => { + expect(moveRow(parse(SIMPLE), 0, 1).rows).toEqual([ + ['3', '4'], + ['1', '2'] + ]) + }) +}) + +describe('column operations', () => { + it('inserts a column with alignment', () => { + const t = insertColumn(parse(SIMPLE), 1, 'center') + expect(t.headers).toEqual(['A', '', 'B']) + expect(t.aligns).toEqual(['none', 'center', 'none']) + expect(t.rows[0]).toEqual(['1', '', '2']) + }) + + it('deletes a column but never the last one', () => { + const t = deleteColumn(parse(SIMPLE), 0) + expect(t.headers).toEqual(['B']) + expect(t.rows).toEqual([['2'], ['4']]) + const single = parse(`| only |\n| - |\n| v |`) + expect(deleteColumn(single, 0)).toBe(single) // unchanged + }) + + it('duplicates a column', () => { + const t = duplicateColumn(parse(SIMPLE), 0) + expect(t.headers).toEqual(['A', 'A', 'B']) + expect(t.rows[0]).toEqual(['1', '1', '2']) + }) + + it('moves a column', () => { + const t = moveColumn(parse(SIMPLE), 0, 1) + expect(t.headers).toEqual(['B', 'A']) + expect(t.rows[0]).toEqual(['2', '1']) + }) + + it('sets column alignment', () => { + expect(setColumnAlign(parse(SIMPLE), 1, 'right').aligns).toEqual([ + 'none', + 'right' + ]) + }) +}) + +describe('sortByColumn', () => { + it('sorts numerically when all values are numbers', () => { + const t = parse(`| n | +| - | +| 10 | +| 2 | +| 1 |`) + expect(sortByColumn(t, 0, 'asc').rows.map((r) => r[0])).toEqual([ + '1', + '2', + '10' + ]) + expect(sortByColumn(t, 0, 'desc').rows.map((r) => r[0])).toEqual([ + '10', + '2', + '1' + ]) + }) + + it('sorts as strings otherwise', () => { + const t = parse(`| s | +| - | +| banana | +| apple | +| cherry |`) + expect(sortByColumn(t, 0, 'asc').rows.map((r) => r[0])).toEqual([ + 'apple', + 'banana', + 'cherry' + ]) + }) +}) + +describe('cell editing', () => { + it('sets a header cell with row === -1', () => { + expect(setCell(parse(SIMPLE), { row: -1, col: 1 }, 'Z').headers).toEqual([ + 'A', + 'Z' + ]) + }) + + it('clears a list of cells', () => { + const t = clearCells(parse(SIMPLE), [ + { row: 0, col: 0 }, + { row: 1, col: 1 } + ]) + expect(t.rows).toEqual([ + ['', '2'], + ['3', ''] + ]) + }) +}) + +describe('cellWidth', () => { + it('counts CJK as double width', () => { + expect(cellWidth('ab')).toBe(2) + expect(cellWidth('中文')).toBe(4) + expect(cellWidth('a中')).toBe(3) + }) +}) diff --git a/packages/app-core/src/lib/markdown-table.ts b/packages/app-core/src/lib/markdown-table.ts new file mode 100644 index 00000000..7294da1b --- /dev/null +++ b/packages/app-core/src/lib/markdown-table.ts @@ -0,0 +1,376 @@ +/** + * Pure model for GFM pipe tables: parse a table block into a structured + * form, run structural edits (insert/move/duplicate/delete rows & columns, + * alignment, sort, clear), and serialize back to nicely-padded markdown. + * + * The on-disk markdown stays the single source of truth — the live-preview + * table widget (`cm-table.ts`) parses the block under the cursor, applies an + * op here, and writes the serialized result back as one CodeMirror change. + * Keeping all of this side-effect-free makes it trivially unit-testable and + * keeps undo/redo, autosave, and multi-pane sync working for free. + */ + +export type ColumnAlign = 'none' | 'left' | 'center' | 'right' + +export interface MarkdownTable { + /** Header cell text, one per column. */ + headers: string[] + /** Body rows; each row has exactly `headers.length` cells (padded). */ + rows: string[][] + /** Per-column alignment, length === headers.length. */ + aligns: ColumnAlign[] +} + +/** A cell address. `row === -1` denotes the header row; body rows are 0-based. */ +export interface CellRef { + row: number + col: number +} + +// --------------------------------------------------------------------------- +// Width helpers — pad source columns so the raw markdown lines up in a +// monospace view (and reads tidily when Live Preview is off). CJK and other +// fullwidth glyphs count as two columns. +// --------------------------------------------------------------------------- + +function isWide(codePoint: number): boolean { + return ( + codePoint >= 0x1100 && + (codePoint <= 0x115f || // Hangul Jamo + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || // CJK..Yi + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables + (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs + (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms + (codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji & symbols + (codePoint >= 0x20000 && codePoint <= 0x3fffd)) // CJK Ext B+ + ) +} + +export function cellWidth(text: string): number { + let width = 0 + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0 + width += isWide(cp) ? 2 : 1 + } + return width +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/** Split one `| a | b |` row into trimmed, unescaped cell strings. Honors + * `\|` (escaped pipe) and `\\` so cells can contain literal pipes. */ +function splitRow(line: string): string[] { + let trimmed = line.trim() + // Drop a single leading / trailing unescaped pipe (the common form). + if (trimmed.startsWith('|')) trimmed = trimmed.slice(1) + if (endsWithUnescapedPipe(trimmed)) trimmed = trimmed.slice(0, -1) + + const cells: string[] = [] + let current = '' + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i] + if (ch === '\\' && i + 1 < trimmed.length) { + // Preserve the escape sequence verbatim for an exact round-trip; + // unescaping happens in `unescapeCell` for display. + current += ch + trimmed[i + 1] + i++ + continue + } + if (ch === '|') { + cells.push(current) + current = '' + continue + } + current += ch + } + cells.push(current) + return cells.map((c) => unescapeCell(c.trim())) +} + +function endsWithUnescapedPipe(text: string): boolean { + if (!text.endsWith('|')) return false + // Count trailing backslashes before the final pipe; even count → unescaped. + let backslashes = 0 + for (let i = text.length - 2; i >= 0 && text[i] === '\\'; i--) backslashes++ + return backslashes % 2 === 0 +} + +function unescapeCell(text: string): string { + return text.replace(/\\([|\\])/g, '$1') +} + +function escapeCell(text: string): string { + return text.replace(/([|\\])/g, '\\$1') +} + +function parseAlign(spec: string): ColumnAlign { + const s = spec.trim() + const left = s.startsWith(':') + const right = s.endsWith(':') + if (left && right) return 'center' + if (right) return 'right' + if (left) return 'left' + return 'none' +} + +/** Returns true when `line` looks like a GFM delimiter row (`|---|:--:|`). */ +function isDelimiterRow(line: string): boolean { + const cells = splitRow(line) + if (cells.length === 0) return false + return cells.every((c) => /^:?-+:?$/.test(c.trim())) +} + +/** + * Parse a markdown table block. Returns null when the text is not a + * well-formed table (header + delimiter + zero or more body rows). + */ +export function parseTable(block: string): MarkdownTable | null { + const lines = block.replace(/\n+$/, '').split('\n') + if (lines.length < 2) return null + if (!isDelimiterRow(lines[1])) return null + + const headers = splitRow(lines[0]) + const alignCells = splitRow(lines[1]) + const colCount = headers.length + if (colCount === 0) return null + + const aligns: ColumnAlign[] = [] + for (let c = 0; c < colCount; c++) aligns.push(parseAlign(alignCells[c] ?? '')) + + const rows: string[][] = [] + for (let i = 2; i < lines.length; i++) { + if (lines[i].trim() === '') continue + rows.push(normalizeRow(splitRow(lines[i]), colCount)) + } + + return { headers, rows, aligns } +} + +function normalizeRow(cells: string[], colCount: number): string[] { + const out = cells.slice(0, colCount) + while (out.length < colCount) out.push('') + return out +} + +// --------------------------------------------------------------------------- +// Serialization (padded, Obsidian/prettier-ish) +// --------------------------------------------------------------------------- + +function padCell(text: string, width: number, align: ColumnAlign): string { + const pad = Math.max(0, width - cellWidth(text)) + if (align === 'right') return ' '.repeat(pad) + text + if (align === 'center') { + const leftPad = Math.floor(pad / 2) + return ' '.repeat(leftPad) + text + ' '.repeat(pad - leftPad) + } + return text + ' '.repeat(pad) +} + +function delimiterCell(width: number, align: ColumnAlign): string { + // Width here is the inner content width; dashes fill it (min 3 visually + // with the colons). Keep at least one dash. + const dashes = Math.max(1, width - (align === 'center' ? 2 : align === 'none' ? 0 : 1)) + const bar = '-'.repeat(dashes) + if (align === 'center') return `:${bar}:` + if (align === 'left') return `:${bar}` + if (align === 'right') return `${bar}:` + return bar +} + +export function serializeTable(table: MarkdownTable): string { + const colCount = table.headers.length + const widths: number[] = [] + for (let c = 0; c < colCount; c++) { + let w = cellWidth(escapeCell(table.headers[c] ?? '')) + for (const row of table.rows) w = Math.max(w, cellWidth(escapeCell(row[c] ?? ''))) + // Delimiter needs room for `:` markers and at least one dash. + const align = table.aligns[c] ?? 'none' + const minDelim = align === 'center' ? 3 : align === 'none' ? 1 : 2 + widths.push(Math.max(3, w, minDelim)) + } + + const renderRow = (cells: string[]): string => { + const padded = cells.map((cell, c) => + padCell(escapeCell(cell ?? ''), widths[c], table.aligns[c] ?? 'none') + ) + return `| ${padded.join(' | ')} |` + } + + const header = renderRow(table.headers) + const delim = `| ${table.aligns + .map((a, c) => delimiterCell(widths[c], a)) + .join(' | ')} |` + const body = table.rows.map(renderRow) + return [header, delim, ...body].join('\n') +} + +// --------------------------------------------------------------------------- +// Structural operations — all return a new table, never mutate the input. +// --------------------------------------------------------------------------- + +function clone(table: MarkdownTable): MarkdownTable { + return { + headers: [...table.headers], + rows: table.rows.map((r) => [...r]), + aligns: [...table.aligns] + } +} + +export function columnCount(table: MarkdownTable): number { + return table.headers.length +} + +export function setCell( + table: MarkdownTable, + ref: CellRef, + value: string +): MarkdownTable { + const next = clone(table) + if (ref.row < 0) { + if (ref.col >= 0 && ref.col < next.headers.length) next.headers[ref.col] = value + } else if (ref.row < next.rows.length) { + const row = next.rows[ref.row] + if (ref.col >= 0 && ref.col < row.length) row[ref.col] = value + } + return next +} + +function emptyRow(colCount: number): string[] { + return Array.from({ length: colCount }, () => '') +} + +/** Insert an empty body row at `index` (clamped). */ +export function insertRow(table: MarkdownTable, index: number): MarkdownTable { + const next = clone(table) + const at = Math.max(0, Math.min(index, next.rows.length)) + next.rows.splice(at, 0, emptyRow(columnCount(next))) + return next +} + +export function deleteRow(table: MarkdownTable, index: number): MarkdownTable { + if (index < 0 || index >= table.rows.length) return table + const next = clone(table) + next.rows.splice(index, 1) + return next +} + +export function duplicateRow(table: MarkdownTable, index: number): MarkdownTable { + if (index < 0 || index >= table.rows.length) return table + const next = clone(table) + next.rows.splice(index + 1, 0, [...next.rows[index]]) + return next +} + +export function moveRow( + table: MarkdownTable, + from: number, + to: number +): MarkdownTable { + if (from < 0 || from >= table.rows.length) return table + const next = clone(table) + const [row] = next.rows.splice(from, 1) + const at = Math.max(0, Math.min(to, next.rows.length)) + next.rows.splice(at, 0, row) + return next +} + +/** Insert an empty column at `index` (clamped) with the given alignment. */ +export function insertColumn( + table: MarkdownTable, + index: number, + align: ColumnAlign = 'none' +): MarkdownTable { + const next = clone(table) + const at = Math.max(0, Math.min(index, columnCount(next))) + next.headers.splice(at, 0, '') + next.aligns.splice(at, 0, align) + for (const row of next.rows) row.splice(at, 0, '') + return next +} + +export function deleteColumn(table: MarkdownTable, index: number): MarkdownTable { + if (index < 0 || index >= columnCount(table)) return table + if (columnCount(table) <= 1) return table // never leave a 0-column table + const next = clone(table) + next.headers.splice(index, 1) + next.aligns.splice(index, 1) + for (const row of next.rows) row.splice(index, 1) + return next +} + +export function duplicateColumn( + table: MarkdownTable, + index: number +): MarkdownTable { + if (index < 0 || index >= columnCount(table)) return table + const next = clone(table) + next.headers.splice(index + 1, 0, next.headers[index]) + next.aligns.splice(index + 1, 0, next.aligns[index]) + for (const row of next.rows) row.splice(index + 1, 0, row[index]) + return next +} + +export function moveColumn( + table: MarkdownTable, + from: number, + to: number +): MarkdownTable { + if (from < 0 || from >= columnCount(table)) return table + const next = clone(table) + const moveItem = <T>(arr: T[]): void => { + const [item] = arr.splice(from, 1) + const at = Math.max(0, Math.min(to, arr.length)) + arr.splice(at, 0, item) + } + moveItem(next.headers) + moveItem(next.aligns) + for (const row of next.rows) moveItem(row) + return next +} + +export function setColumnAlign( + table: MarkdownTable, + index: number, + align: ColumnAlign +): MarkdownTable { + if (index < 0 || index >= columnCount(table)) return table + const next = clone(table) + next.aligns[index] = align + return next +} + +/** Sort body rows by a column. Numeric when every value parses as a number; + * otherwise a locale-aware string compare. */ +export function sortByColumn( + table: MarkdownTable, + index: number, + direction: 'asc' | 'desc' +): MarkdownTable { + if (index < 0 || index >= columnCount(table)) return table + const next = clone(table) + const dir = direction === 'asc' ? 1 : -1 + const values = next.rows.map((r) => (r[index] ?? '').trim()) + const allNumeric = + values.length > 0 && + values.every((v) => v !== '' && !Number.isNaN(Number(v))) + next.rows.sort((a, b) => { + const av = (a[index] ?? '').trim() + const bv = (b[index] ?? '').trim() + if (allNumeric) return (Number(av) - Number(bv)) * dir + return av.localeCompare(bv) * dir + }) + return next +} + +/** Set a list of cells to empty strings (header cells use row === -1). */ +export function clearCells(table: MarkdownTable, cells: CellRef[]): MarkdownTable { + let next = table + for (const ref of cells) next = setCell(next, ref, '') + return next +} diff --git a/packages/app-core/src/lib/markdown.ts b/packages/app-core/src/lib/markdown.ts index b026ced2..7affc6dd 100644 --- a/packages/app-core/src/lib/markdown.ts +++ b/packages/app-core/src/lib/markdown.ts @@ -202,7 +202,7 @@ function remarkHashtags() { if (p.type === 'link' || p.type === 'linkReference' || p.type === 'heading') return const value = (node as { value: string }).value if (!value.includes('#')) return - const regex = /(^|\s)#([a-zA-Z][\w\-/]*)/g + const regex = /(^|\s)#(\p{L}[\p{L}\d_/-]*)/gu const next: AnyNode[] = [] let last = 0 let m: RegExpExecArray | null diff --git a/packages/app-core/src/lib/natural-sort.test.ts b/packages/app-core/src/lib/natural-sort.test.ts new file mode 100644 index 00000000..3d3fe1bb --- /dev/null +++ b/packages/app-core/src/lib/natural-sort.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { naturalCompare } from './natural-sort' + +describe('naturalCompare (#168 — numeric-aware name sorting)', () => { + it('orders numbers numerically, not lexically', () => { + const sorted = ['10-mango', '2-banana', '1-apple', '21-watermelon', '3-cherry'].sort( + naturalCompare + ) + expect(sorted).toEqual(['1-apple', '2-banana', '3-cherry', '10-mango', '21-watermelon']) + }) + + it('is case-insensitive', () => { + expect(naturalCompare('apple', 'Apple')).toBe(0) + expect(naturalCompare('ZEBRA', 'apple')).toBeGreaterThan(0) + }) + + it('sorts numeric names before alphabetic ones', () => { + expect(['z-note', '1-note', 'a-note'].sort(naturalCompare)).toEqual([ + '1-note', + 'a-note', + 'z-note' + ]) + }) + + it('handles embedded multi-digit numbers', () => { + expect(['file-9', 'file-10', 'file-1'].sort(naturalCompare)).toEqual([ + 'file-1', + 'file-9', + 'file-10' + ]) + }) +}) diff --git a/packages/app-core/src/lib/natural-sort.ts b/packages/app-core/src/lib/natural-sort.ts new file mode 100644 index 00000000..ee19f249 --- /dev/null +++ b/packages/app-core/src/lib/natural-sort.ts @@ -0,0 +1,8 @@ +/** + * Compare two display names "naturally": numeric-aware so "2 Foo" sorts before + * "10 Foo", and case-insensitive. Used for folder, note, and asset name + * sorting so leading numbers/letters order the way users expect. (#168) + */ +export function naturalCompare(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }) +} diff --git a/packages/app-core/src/lib/note-prefetch.test.ts b/packages/app-core/src/lib/note-prefetch.test.ts index 47813f8d..aafadcca 100644 --- a/packages/app-core/src/lib/note-prefetch.test.ts +++ b/packages/app-core/src/lib/note-prefetch.test.ts @@ -13,6 +13,7 @@ function note(folder: NoteFolder, index: number, overrides: Partial<NoteMeta> = size: 10, tags: [], wikilinks: [], + assetEmbeds: [], hasAttachments: false, excerpt: '', ...overrides diff --git a/packages/app-core/src/lib/note-search.test.ts b/packages/app-core/src/lib/note-search.test.ts index b0d50834..e0d15a33 100644 --- a/packages/app-core/src/lib/note-search.test.ts +++ b/packages/app-core/src/lib/note-search.test.ts @@ -21,6 +21,7 @@ function note( size: 10, tags: [], wikilinks: [], + assetEmbeds: [], hasAttachments: false, excerpt: '', ...overrides diff --git a/packages/app-core/src/lib/overlay-open.test.ts b/packages/app-core/src/lib/overlay-open.test.ts new file mode 100644 index 00000000..0488d7e8 --- /dev/null +++ b/packages/app-core/src/lib/overlay-open.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { isAppOverlayOpen } from './overlay-open' + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('isAppOverlayOpen', () => { + it('is false with no overlay in the DOM', () => { + expect(isAppOverlayOpen()).toBe(false) + }) + + it('is true while a prompt/confirm modal is open', () => { + const el = document.createElement('div') + el.setAttribute('data-prompt-modal', '') + document.body.appendChild(el) + expect(isAppOverlayOpen()).toBe(true) + }) + + it('is true while a context menu is open', () => { + const el = document.createElement('div') + el.setAttribute('data-ctx-menu', '') + document.body.appendChild(el) + expect(isAppOverlayOpen()).toBe(true) + }) +}) diff --git a/packages/app-core/src/lib/overlay-open.ts b/packages/app-core/src/lib/overlay-open.ts new file mode 100644 index 00000000..5223cc55 --- /dev/null +++ b/packages/app-core/src/lib/overlay-open.ts @@ -0,0 +1,15 @@ +/** + * True when a blocking overlay — a prompt/confirm modal or a context menu — is + * open. The built-in list views (Trash, Archive, Tasks, Tags, Quick Notes) + * register global capture-phase keydown handlers; they must bail while an + * overlay is open so it owns the keyboard. Otherwise keys fire *through* the + * overlay: e.g. the Trash delete-confirm dialog would be bypassed by Enter and + * repeated Enter would keep deleting. Both PromptModal and ConfirmModal carry + * `data-prompt-modal`; ContextMenu carries `data-ctx-menu`. + */ +export function isAppOverlayOpen(): boolean { + if (typeof document === 'undefined') return false + return !!( + document.querySelector('[data-prompt-modal]') || document.querySelector('[data-ctx-menu]') + ) +} diff --git a/packages/app-core/src/lib/pane-nav.ts b/packages/app-core/src/lib/pane-nav.ts index c834e5e8..26b1f458 100644 --- a/packages/app-core/src/lib/pane-nav.ts +++ b/packages/app-core/src/lib/pane-nav.ts @@ -176,10 +176,49 @@ export function focusPaneInDirection(direction: PaneDirection): boolean { return true } +/** Focus the editor pane at the left or right edge of the editor area. Used + * when crossing in from an edge panel (sidebar / note list / connections) so + * we land on the pane that actually sits next to it, not wherever + * `activePaneId` happened to be. */ +function focusEditorEdgePane(direction: PaneDirection): boolean { + const rects = getPaneRects().filter((p) => p.id !== PINNED_REF_PANE_ID) + if (rects.length === 0) return false + const sorted = rects.slice().sort((a, b) => a.rect.left - b.rect.left) + const target = direction === 'l' ? sorted[0] : sorted[sorted.length - 1] + const state = useStore.getState() + state.setActivePane(target.id) + state.setFocusedPanel('editor') + requestAnimationFrame(() => { + const cm = document.querySelector<HTMLElement>( + `[data-pane-id="${target.id}"] .cm-content` + ) + if (cm) cm.focus() + else useStore.getState().editorViewRef?.focus() + }) + return true +} + export function focusPaneOrEdgePanel(direction: PaneDirection): boolean { + const state = useStore.getState() + const focused = state.focusedPanel + + // When focus is on a horizontal edge panel, move relative to THAT panel + // instead of geometrically from activePaneId — otherwise `l` from the sidebar + // navigates from the last active pane and skips the pane that sits right next + // to the sidebar. (#124) + if ( + (direction === 'h' || direction === 'l') && + (focused === 'sidebar' || focused === 'notelist' || focused === 'connections') + ) { + const next = resolveNeighborEdgePanel(focused, direction, getVisibleEdgePanels(state)) + if (!next || next === focused) return false + if (next === 'editor') return focusEditorEdgePane(direction) + focusEdgePanel(next) + return true + } + if (focusPaneInDirection(direction)) return true - const state = useStore.getState() const next = resolveNeighborEdgePanel('editor', direction, getVisibleEdgePanels(state)) if (!next || next === 'editor') return false focusEdgePanel(next) diff --git a/packages/app-core/src/lib/tab-scroll-memory.test.ts b/packages/app-core/src/lib/tab-scroll-memory.test.ts index b6fca566..4307d2e7 100644 --- a/packages/app-core/src/lib/tab-scroll-memory.test.ts +++ b/packages/app-core/src/lib/tab-scroll-memory.test.ts @@ -24,6 +24,23 @@ describe('tab scroll memory', () => { expect(recallTabScroll('a.md')).toEqual({ editor: 11, preview: 22 }) }) + it('keeps a remembered editor selection when a later capture only updates scroll', () => { + rememberTabScroll('a.md', { + editor: 10, + preview: 20, + editorSelectionAnchor: 100, + editorSelectionHead: 120 + }) + rememberTabScroll('a.md', { editor: 11, preview: 22 }) + + expect(recallTabScroll('a.md')).toEqual({ + editor: 11, + preview: 22, + editorSelectionAnchor: 100, + editorSelectionHead: 120 + }) + }) + it('forgets a single path', () => { rememberTabScroll('a.md', { editor: 1, preview: 2 }) forgetTabScroll('a.md') diff --git a/packages/app-core/src/lib/tab-scroll-memory.ts b/packages/app-core/src/lib/tab-scroll-memory.ts index 00932efc..d8350817 100644 --- a/packages/app-core/src/lib/tab-scroll-memory.ts +++ b/packages/app-core/src/lib/tab-scroll-memory.ts @@ -4,8 +4,9 @@ * The pane reuses a single editor + preview surface and renders only the * active tab's content, so switching tabs (or opening a diagram in a tab and * coming back) otherwise snaps a note to the top. This is a small imperative - * cache — keyed by note path — that remembers each tab's editor and preview - * scroll offsets so re-activating the tab can restore its position. + * cache — keyed by note path — that remembers each tab's editor selection + * and preview/editor scroll offsets so re-activating the tab can restore its + * position. * * It's intentionally a module-level cache, not store state: nothing renders * from it, it's read/written imperatively during tab switches and scrolls, @@ -15,6 +16,8 @@ export interface TabScrollPosition { editor: number preview: number + editorSelectionAnchor?: number + editorSelectionHead?: number } const TAB_SCROLL_MEMORY_LIMIT = 60 @@ -22,9 +25,18 @@ const memory = new Map<string, TabScrollPosition>() export function rememberTabScroll(path: string, position: TabScrollPosition): void { if (!path) return + const previous = memory.get(path) + const next: TabScrollPosition = { + editor: position.editor, + preview: position.preview + } + const anchor = position.editorSelectionAnchor ?? previous?.editorSelectionAnchor + const head = position.editorSelectionHead ?? previous?.editorSelectionHead + if (anchor != null) next.editorSelectionAnchor = anchor + if (head != null) next.editorSelectionHead = head // Re-insert so the most recently touched entry is last (LRU ordering). memory.delete(path) - memory.set(path, position) + memory.set(path, next) while (memory.size > TAB_SCROLL_MEMORY_LIMIT) { const oldest = memory.keys().next().value if (oldest === undefined) break diff --git a/packages/app-core/src/lib/tab-strip-style.test.ts b/packages/app-core/src/lib/tab-strip-style.test.ts index 51d6011a..fcc769fc 100644 --- a/packages/app-core/src/lib/tab-strip-style.test.ts +++ b/packages/app-core/src/lib/tab-strip-style.test.ts @@ -16,10 +16,11 @@ const stylesSource = readFileSync(new URL('../styles/index.css', import.meta.url describe('workspace tab strip overflow styles', () => { it('keeps horizontal tab overflow visible without lifting tabs', () => { expect(editorPaneSource).toContain('workspace-tab-strip') + // Flat tabs stay at h-10 and scroll horizontally when overflowing (no lift). expect(editorPaneSource).toContain( - "tabStripOverflowing ? 'h-14 overflow-x-auto' : 'h-10 overflow-x-hidden'" + "tabStripOverflowing ? 'overflow-x-auto' : 'overflow-x-hidden'" ) - expect(editorPaneSource).toContain('items-start') + expect(editorPaneSource).toContain('items-stretch') expect(stylesSource).toMatch( /\.workspace-tab-strip::-webkit-scrollbar\s*\{[^}]*height:\s*6px/s ) diff --git a/packages/app-core/src/lib/tags.ts b/packages/app-core/src/lib/tags.ts index 1f981061..b1054463 100644 --- a/packages/app-core/src/lib/tags.ts +++ b/packages/app-core/src/lib/tags.ts @@ -7,8 +7,8 @@ * Rules: * - The hash must be preceded by start-of-line or whitespace (so * `me#tag` and `url.com/#x` don't match). - * - The first tag character must be a letter, the rest can be - * letters, digits, `_`, `-`, or `/`. + * - The first tag character must be a letter in any script (Cyrillic, + * CJK, … — #205), the rest can be letters, digits, `_`, `-`, or `/`. * - Fenced code blocks and inline code spans are stripped first. * - Heading markers (`#`, `##`, …) are not a hashtag because the * character after the hash is a space, not a letter. @@ -17,7 +17,7 @@ export function extractTags(body: string): string[] { const stripped = body .replace(/```[\s\S]*?```/g, ' ') .replace(/`[^`\n]*`/g, ' ') - const regex = /(?:^|\s)#([a-zA-Z][\w\-/]*)/g + const regex = /(?:^|\s)#(\p{L}[\p{L}\d_/-]*)/gu const seen = new Set<string>() let m: RegExpExecArray | null while ((m = regex.exec(stripped)) !== null) { diff --git a/packages/app-core/src/lib/vault-layout.test.ts b/packages/app-core/src/lib/vault-layout.test.ts new file mode 100644 index 00000000..caa224f9 --- /dev/null +++ b/packages/app-core/src/lib/vault-layout.test.ts @@ -0,0 +1,558 @@ +import { describe, expect, it } from 'vitest' +import type { NoteMeta, VaultSettings } from '@shared/ipc' +import { + assetBelongsToFolderView, + assetFolderSubpath, + classifyDateNote, + dailyNoteLocationForDate, + dateNoteFolderMayBelongToDatePattern, + dateNoteDirectoryDisplayLabel, + favoriteFolderKey, + folderForVaultRelativePath, + isFavoriteFolderKey, + noteFolderSubpath, + normalizeVaultSettings, + parseFavoriteFolderKey, + removeFavoritesForFolder, + rewriteFavoriteNotePath, + rewriteFavoritesForFolderRename, + toggleFavorite, + weeklyNoteLocationForDate +} from './vault-layout' + +function note(path: string, title: string): NoteMeta { + return { + path, + title, + folder: 'inbox', + siblingOrder: 0, + createdAt: 0, + updatedAt: 0, + size: 0, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '' + } +} + +function settings(dailyDirectory: string, weeklyDirectory: string): VaultSettings { + return { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: true, directory: dailyDirectory }, + weeklyNotes: { enabled: true, directory: weeklyDirectory }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } +} + +describe('classifyDateNote', () => { + it('recognizes daily notes when the configured directory includes the primary inbox prefix', () => { + const info = classifyDateNote( + note('inbox/Journal/2026-06-12.md', '2026-06-12'), + settings('inbox/Journal', 'Weekly Notes') + ) + + expect(info).toMatchObject({ kind: 'daily' }) + expect(info?.date).toEqual(new Date(2026, 5, 12)) + }) + + it('recognizes weekly notes when the configured directory includes the primary inbox prefix', () => { + const info = classifyDateNote( + note('inbox/Weeks/2026-W24.md', '2026-W24'), + settings('Daily Notes', 'inbox/Weeks') + ) + + expect(info).toMatchObject({ kind: 'weekly' }) + expect(info?.date).toEqual(new Date(2026, 5, 8)) + }) + + it('recognizes weekly notes created from date-based directory and title patterns', () => { + const info = classifyDateNote( + note('inbox/2026/06-Jun/2026-W24-Mon.md', '2026-W24-Mon'), + { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: "yyyy-'W'ww-EEE", + locale: 'en-US' + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + ) + + expect(info).toMatchObject({ kind: 'weekly' }) + expect(info?.date).toEqual(new Date(2026, 5, 8)) + }) + + it('recognizes daily notes created from date-based directory and title patterns', () => { + const info = classifyDateNote( + note('inbox/2026/06-Jun/2026-06-09-Tue.md', '2026-06-09-Tue'), + { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: 'yyyy-MM-dd-EEE', + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + ) + + expect(info).toMatchObject({ kind: 'daily' }) + expect(info?.date).toEqual(new Date(2026, 5, 9)) + }) + + it('keeps existing lowercase daily directories literal', () => { + const date = new Date(2026, 5, 9) + const vaultSettings = settings('daily', 'Weekly Notes') + + expect(dailyNoteLocationForDate(date, vaultSettings)).toEqual({ + subpath: 'daily', + title: '2026-06-09' + }) + + const info = classifyDateNote(note('inbox/daily/2026-06-09.md', '2026-06-09'), vaultSettings) + + expect(info).toMatchObject({ kind: 'daily' }) + expect(info?.date).toEqual(date) + }) + + it('renders daily note locations from date-based directory and title patterns', () => { + const location = dailyNoteLocationForDate(new Date(2026, 5, 9), { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: 'yyyy-MM-dd-EEE', + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings) + + expect(location).toEqual({ + subpath: '2026/06-Jun', + title: '2026-06-09-Tue' + }) + }) + + it('supports month-only daily directory patterns when the title supplies the date', () => { + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'MM-MMM', + titlePattern: 'yyyy-MM-dd', + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + expect(dailyNoteLocationForDate(new Date(2026, 5, 9), vaultSettings)).toEqual({ + subpath: '06-Jun', + title: '2026-06-09' + }) + + const info = classifyDateNote( + note('inbox/06-Jun/2026-06-09.md', '2026-06-09'), + vaultSettings + ) + + expect(info).toMatchObject({ kind: 'daily' }) + expect(info?.date).toEqual(new Date(2026, 5, 9)) + }) + + it('renders quoted literals inside daily directory patterns', () => { + const location = dailyNoteLocationForDate(new Date(2026, 5, 9), { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: "'Daily Notes'/yyyy/MM-MMM", + titlePattern: 'yyyy-MM-dd-EEE', + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings) + + expect(location).toEqual({ + subpath: 'Daily Notes/2026/06-Jun', + title: '2026-06-09-Tue' + }) + }) + + it('renders weekly note locations from date-based directory and title patterns', () => { + const location = weeklyNoteLocationForDate(new Date(2026, 5, 9), { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: "'Weekly Notes'/yyyy/MM-MMM", + titlePattern: "yyyy-'W'ww-EEE", + locale: 'en-US' + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings) + + expect(location).toEqual({ + subpath: 'Weekly Notes/2026/06-Jun', + title: '2026-W24-Mon' + }) + }) + + it('uses the ISO week-year for weekly pattern years', () => { + const location = weeklyNoteLocationForDate(new Date(2021, 0, 1), { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: 'yyyy', + titlePattern: "yyyy-'W'ww", + locale: 'en-US' + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings) + + expect(location).toEqual({ + subpath: '2020', + title: '2020-W53' + }) + }) + + it('supports ISO week-only weekly directory patterns', () => { + const location = weeklyNoteLocationForDate(new Date(2026, 5, 9), { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: 'ww', + titlePattern: "yyyy-'W'ww", + locale: 'en-US' + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings) + + expect(location).toEqual({ + subpath: '24', + title: '2026-W24' + }) + }) + + it('keeps existing lowercase weekly directories literal', () => { + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: 'week', + titlePattern: "yyyy-'W'ww", + locale: 'en-US' + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + expect(weeklyNoteLocationForDate(new Date(2026, 5, 9), vaultSettings)).toEqual({ + subpath: 'week', + title: '2026-W24' + }) + + const info = classifyDateNote(note('inbox/week/2026-W24.md', '2026-W24'), vaultSettings) + + expect(info).toMatchObject({ kind: 'weekly' }) + expect(info?.date).toEqual(new Date(2026, 5, 8)) + }) + + it('recognizes a daily note whose title encodes the day via ISO week and weekday', () => { + // The title carries no day-of-month token; the day is implied by the ISO + // week (`ww`) plus the weekday name (`EEE`). 2026-W24-Fri is Fri Jun 12. + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: "yyyy-'W'ww-EEE", + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + const info = classifyDateNote( + note('inbox/2026/06-Jun/2026-W24-Fri.md', '2026-W24-Fri'), + vaultSettings + ) + + expect(info).toMatchObject({ kind: 'daily' }) + expect(info?.date).toEqual(new Date(2026, 5, 12)) + }) + + it('recognizes a daily note when the year comes only from the directory', () => { + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy', + titlePattern: 'MM-dd', + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + const info = classifyDateNote(note('inbox/2026/06-09.md', '06-09'), vaultSettings) + + expect(info).toMatchObject({ kind: 'daily' }) + expect(info?.date).toEqual(new Date(2026, 5, 9)) + }) + + it('does not classify a note whose directory and title disagree on the date', () => { + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: "yyyy-'W'ww-EEE", + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + // ISO week 24 falls entirely in June, so a July folder cannot round-trip. + const info = classifyDateNote( + note('inbox/2026/07-Jul/2026-W24-Fri.md', '2026-W24-Fri'), + vaultSettings + ) + + expect(info).toBeNull() + }) + + it('recognizes daily notes from legacy patterns after the active pattern changes', () => { + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: 'yyyy-MM-dd-EEE', + locale: 'en-US', + legacyPatterns: [ + { directory: 'Daily Notes', titlePattern: 'yyyy-MM-dd', locale: 'en-US' } + ] + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + const info = classifyDateNote( + note('inbox/Daily Notes/2026-06-12.md', '2026-06-12'), + vaultSettings + ) + + expect(info).toMatchObject({ kind: 'daily' }) + expect(info?.date).toEqual(new Date(2026, 5, 12)) + }) + + it('recognizes weekly notes from legacy patterns after the active pattern changes', () => { + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: "yyyy-'W'ww-EEE", + locale: 'en-US', + legacyPatterns: [ + { directory: 'Weekly Notes', titlePattern: "yyyy-'W'ww", locale: 'en-US' } + ] + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + const info = classifyDateNote( + note('inbox/Weekly Notes/2026-W24.md', '2026-W24'), + vaultSettings + ) + + expect(info).toMatchObject({ kind: 'weekly' }) + expect(info?.date).toEqual(new Date(2026, 5, 8)) + }) +}) + +describe('dateNoteDirectoryDisplayLabel', () => { + it('uses the fallback label for fully date-based directory patterns', () => { + expect(dateNoteDirectoryDisplayLabel('yyyy/MM-MMM', 'Daily Notes')).toBe('Daily Notes') + }) + + it('uses quoted literal directory segments as the label', () => { + expect(dateNoteDirectoryDisplayLabel("'Journal'/yyyy/MM-MMM", 'Daily Notes')).toBe('Journal') + }) + + it('keeps literal legacy directories unchanged', () => { + expect(dateNoteDirectoryDisplayLabel('week', 'Weekly Notes')).toBe('week') + }) +}) + +describe('dateNoteFolderMayBelongToDatePattern', () => { + it('matches active and legacy date-pattern folders for sidebar pruning', () => { + const vaultSettings = { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy/MMM-MMM', + titlePattern: 'yyyy-MM-dd-EEE', + locale: 'en-US', + legacyPatterns: [ + { directory: 'Daily Notes', titlePattern: 'yyyy-MM-dd', locale: 'en-US' } + ] + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } as VaultSettings + + expect(dateNoteFolderMayBelongToDatePattern('2026/Jun-Jun', vaultSettings)).toBe(true) + expect(dateNoteFolderMayBelongToDatePattern('Daily Notes', vaultSettings)).toBe(true) + expect(dateNoteFolderMayBelongToDatePattern('Projects', vaultSettings)).toBe(false) + }) +}) + +describe('folderForVaultRelativePath — case-insensitive system folders (#186)', () => { + // On case-insensitive filesystems, `listAssets` preserves the on-disk casing + // (`Inbox/photo.png`) while notes always arrive canonical-lowercased + // (`inbox/note.md`). Both must classify to the same folder, or the browser + // shows the markdown but hides the images/PDFs. + const inboxMode = { primaryNotesLocation: 'inbox' } as VaultSettings + + it('classifies a capitalized inbox folder the same as lowercase', () => { + expect(folderForVaultRelativePath('inbox/photo.png', inboxMode)).toBe('inbox') + expect(folderForVaultRelativePath('Inbox/photo.png', inboxMode)).toBe('inbox') + expect(folderForVaultRelativePath('INBOX/photo.png', inboxMode)).toBe('inbox') + }) + + it('matches the other system folders case-insensitively too', () => { + expect(folderForVaultRelativePath('Archive/scan.pdf', inboxMode)).toBe('archive') + expect(folderForVaultRelativePath('Quick/clip.png', inboxMode)).toBe('quick') + expect(folderForVaultRelativePath('Trash/old.png', inboxMode)).toBe('trash') + }) + + it('still returns null for a non-system root folder in inbox mode', () => { + expect(folderForVaultRelativePath('Random/photo.png', inboxMode)).toBeNull() + }) + + it('keeps a capitalized asset in the same subpath tree as its notes', () => { + // A note under a capital `Inbox/Projects/` resolves to subpath `Projects` + // (folder is assigned authoritatively by the main-process walk); the asset + // under the same folder must resolve to the identical `Projects`. + expect(noteFolderSubpath({ folder: 'inbox', path: 'Inbox/Projects/n.md' }, inboxMode)).toBe( + 'Projects' + ) + expect(assetFolderSubpath({ path: 'Inbox/Projects/photo.png' }, inboxMode)).toBe('Projects') + expect( + assetBelongsToFolderView({ path: 'Inbox/Projects/photo.png' }, 'inbox', 'Projects', inboxMode) + ).toBe(true) + expect(assetBelongsToFolderView({ path: 'Inbox/photo.png' }, 'inbox', '', inboxMode)).toBe(true) + // A top-level note in a capital `Inbox/` is at the root of the view, not + // nested under a phantom `Inbox` subfolder. + expect(noteFolderSubpath({ folder: 'inbox', path: 'Inbox/n.md' }, inboxMode)).toBe('') + }) +}) + +describe('favorites', () => { + it('discriminates folder keys from note paths by the colon', () => { + expect(isFavoriteFolderKey('inbox:Projects')).toBe(true) + expect(isFavoriteFolderKey('inbox:')).toBe(true) // top-level folder key + expect(isFavoriteFolderKey('inbox/Idea.md')).toBe(false) + expect(favoriteFolderKey('inbox', 'Projects')).toBe('inbox:Projects') + expect(parseFavoriteFolderKey('inbox:Projects/Sub')).toEqual({ + folder: 'inbox', + subpath: 'Projects/Sub' + }) + expect(parseFavoriteFolderKey('inbox/Idea.md')).toBeNull() + }) + + it('toggles a key on and off', () => { + expect(toggleFavorite([], 'inbox/A.md')).toEqual(['inbox/A.md']) + expect(toggleFavorite(['inbox/A.md', 'x:y'], 'inbox/A.md')).toEqual(['x:y']) + // Added at the end, preserving order. + expect(toggleFavorite(['x:y'], 'inbox/A.md')).toEqual(['x:y', 'inbox/A.md']) + }) + + it('rewrites a note favorite when the note is renamed/moved', () => { + const favs = ['inbox/A.md', 'inbox:Projects'] + expect(rewriteFavoriteNotePath(favs, 'inbox/A.md', 'inbox/B.md')).toEqual([ + 'inbox/B.md', + 'inbox:Projects' + ]) + // Unaffected favorites return the same reference (no needless persist). + expect(rewriteFavoriteNotePath(favs, 'inbox/Z.md', 'inbox/Q.md')).toBe(favs) + }) + + it('repoints favorites when a folder is renamed', () => { + const favs = ['inbox:Projects', 'inbox:Projects/Sub', 'inbox/Projects/note.md', 'quick:Keep'] + const next = rewriteFavoritesForFolderRename( + favs, + 'inbox', + 'Projects', + 'Work', + 'inbox/Projects/', + 'inbox/Work/' + ) + expect(next).toEqual(['inbox:Work', 'inbox:Work/Sub', 'inbox/Work/note.md', 'quick:Keep']) + }) + + it('removes favorites for a deleted folder and its notes', () => { + const favs = ['inbox:Projects', 'inbox:Projects/Sub', 'inbox/Projects/note.md', 'inbox/Other.md'] + expect( + removeFavoritesForFolder(favs, 'inbox', 'Projects', 'inbox/Projects/') + ).toEqual(['inbox/Other.md']) + }) + + it('normalizes favorites: drops empties/dupes, keeps order', () => { + const settings = normalizeVaultSettings({ + favorites: ['inbox/A.md', '', 'inbox/A.md', 'inbox:Projects'] + } as unknown as VaultSettings) + expect(settings.favorites).toEqual(['inbox/A.md', 'inbox:Projects']) + }) + + it('defaults favorites to an empty array', () => { + const settings = normalizeVaultSettings({} as unknown as VaultSettings) + expect(settings.favorites).toEqual([]) + }) +}) diff --git a/packages/app-core/src/lib/vault-layout.ts b/packages/app-core/src/lib/vault-layout.ts index 94499c31..f70dec81 100644 --- a/packages/app-core/src/lib/vault-layout.ts +++ b/packages/app-core/src/lib/vault-layout.ts @@ -1,9 +1,15 @@ import { + DEFAULT_DAILY_NOTE_LOCALE, + DEFAULT_DAILY_NOTE_TITLE_PATTERN, DEFAULT_DAILY_NOTES_DIRECTORY, + DEFAULT_WEEKLY_NOTE_LOCALE, + DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, DEFAULT_WEEKLY_NOTES_DIRECTORY, DEFAULT_VAULT_SETTINGS, type AssetMeta, + type DateNotePatternSettings, type FolderIconId, + type FolderColorId, type NoteFolder, type NoteMeta, type VaultSettings @@ -16,6 +22,7 @@ const RESERVED_ROOT_NAMES = new Set<string>([ 'quick', 'archive', 'trash', + 'assets', 'attachements', '_assets', '.zennotes' @@ -56,6 +63,23 @@ function isFolderIconId(value: unknown): value is FolderIconId { return typeof value === 'string' && VALID_FOLDER_ICON_IDS.has(value as FolderIconId) } +const VALID_FOLDER_COLOR_IDS = new Set<FolderColorId>([ + 'red', + 'orange', + 'amber', + 'green', + 'teal', + 'sky', + 'blue', + 'indigo', + 'violet', + 'pink' +]) + +export function isFolderColorId(value: unknown): value is FolderColorId { + return typeof value === 'string' && VALID_FOLDER_COLOR_IDS.has(value as FolderColorId) +} + function pad(n: number): string { return n.toString().padStart(2, '0') } @@ -70,11 +94,370 @@ export function normalizeWeeklyNotesDirectory(directory: string | null | undefin return trimmed || DEFAULT_WEEKLY_NOTES_DIRECTORY } +export function normalizeDailyNoteTitlePattern(value: string | null | undefined): string { + const trimmed = (value ?? '').trim().replace(/[\\/]+/g, '-') + return trimmed || DEFAULT_DAILY_NOTE_TITLE_PATTERN +} + +export function normalizeDailyNoteLocale(value: string | null | undefined): string { + const trimmed = (value ?? '').trim() + return trimmed || DEFAULT_DAILY_NOTE_LOCALE +} + +export function normalizeWeeklyNoteTitlePattern(value: string | null | undefined): string { + const trimmed = (value ?? '').trim().replace(/[\\/]+/g, '-') + return trimmed || DEFAULT_WEEKLY_NOTE_TITLE_PATTERN +} + +export function normalizeWeeklyNoteLocale(value: string | null | undefined): string { + const trimmed = (value ?? '').trim() + return trimmed || DEFAULT_WEEKLY_NOTE_LOCALE +} + +function dateNotePatternKey(pattern: DateNotePatternSettings): string { + return `${pattern.directory}\0${pattern.titlePattern ?? ''}\0${pattern.locale ?? ''}` +} + +function normalizeDailyNotePatternHistory( + value: readonly DateNotePatternSettings[] | null | undefined, + primaryNotesLocation: VaultSettings['primaryNotesLocation'] +): DateNotePatternSettings[] { + if (!Array.isArray(value)) return [] + const out: DateNotePatternSettings[] = [] + const seen = new Set<string>() + for (const pattern of value) { + const next = { + directory: normalizePrimaryRelativeSubpath( + normalizeDailyNotesDirectory(pattern?.directory), + { primaryNotesLocation } + ), + titlePattern: normalizeDailyNoteTitlePattern(pattern?.titlePattern), + locale: normalizeDailyNoteLocale(pattern?.locale) + } + const key = dateNotePatternKey(next) + if (seen.has(key)) continue + seen.add(key) + out.push(next) + } + return out +} + +function normalizeWeeklyNotePatternHistory( + value: readonly DateNotePatternSettings[] | null | undefined, + primaryNotesLocation: VaultSettings['primaryNotesLocation'] +): DateNotePatternSettings[] { + if (!Array.isArray(value)) return [] + const out: DateNotePatternSettings[] = [] + const seen = new Set<string>() + for (const pattern of value) { + const next = { + directory: normalizePrimaryRelativeSubpath( + normalizeWeeklyNotesDirectory(pattern?.directory), + { primaryNotesLocation } + ), + titlePattern: normalizeWeeklyNoteTitlePattern(pattern?.titlePattern), + locale: normalizeWeeklyNoteLocale(pattern?.locale) + } + const key = dateNotePatternKey(next) + if (seen.has(key)) continue + seen.add(key) + out.push(next) + } + return out +} + +function normalizePrimaryRelativeSubpath( + subpath: string, + settings: Pick<VaultSettings, 'primaryNotesLocation'> +): string { + if (settings.primaryNotesLocation !== 'inbox') return subpath + if (subpath === 'inbox') return '' + return subpath.startsWith('inbox/') ? subpath.slice('inbox/'.length) : subpath +} + function normalizeTemplateId(value: string | null | undefined): string | undefined { const trimmed = (value ?? '').trim() return trimmed || undefined } +function localeArg(locale: string): string | undefined { + return locale === DEFAULT_DAILY_NOTE_LOCALE ? undefined : locale +} + +function localeDatePart( + date: Date, + locale: string, + options: Intl.DateTimeFormatOptions +): string { + try { + return date.toLocaleDateString(localeArg(locale), options) + } catch { + return date.toLocaleDateString(undefined, options) + } +} + +function escapeRegex(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') +} + +const DATE_NOTE_PATTERN_TOKENS = [ + 'yyyy', + 'yy', + 'MMMM', + 'MMM', + 'MM', + 'M', + 'dd', + 'd', + 'EEEE', + 'EEE', + 'ww', + 'w' +] as const + +type DateNotePatternToken = (typeof DATE_NOTE_PATTERN_TOKENS)[number] + +type DateNotePatternPart = + | { kind: 'literal'; value: string } + | { kind: 'token'; value: DateNotePatternToken } + +function parseDateNotePattern(pattern: string): DateNotePatternPart[] { + const parts: DateNotePatternPart[] = [] + let literal = '' + let quoted = false + + const flushLiteral = (): void => { + if (!literal) return + parts.push({ kind: 'literal', value: literal }) + literal = '' + } + + for (let i = 0; i < pattern.length; ) { + const ch = pattern[i] + if (ch === "'") { + if (pattern[i + 1] === "'") { + literal += "'" + i += 2 + continue + } + quoted = !quoted + i += 1 + continue + } + + if (!quoted) { + const token = DATE_NOTE_PATTERN_TOKENS.find((candidate) => + pattern.startsWith(candidate, i) + ) + if (token) { + flushLiteral() + parts.push({ kind: 'token', value: token }) + i += token.length + continue + } + } + + literal += ch + i += 1 + } + flushLiteral() + return parts +} + +function pad2(value: number): string { + return String(value).padStart(2, '0') +} + +function formatDateNotePattern( + date: Date, + pattern: string, + locale: string, + parts: { year?: number; week?: number } = {} +): string { + const year = parts.year ?? date.getFullYear() + const week = parts.week ?? getISOWeek(date) + const month = date.getMonth() + 1 + const day = date.getDate() + return parseDateNotePattern(pattern) + .map((part) => { + if (part.kind === 'literal') return part.value + switch (part.value) { + case 'yyyy': + return String(year) + case 'yy': + return pad2(year % 100) + case 'MMMM': + return localeDatePart(date, locale, { month: 'long' }) + case 'MMM': + return localeDatePart(date, locale, { month: 'short' }) + case 'MM': + return pad2(month) + case 'M': + return String(month) + case 'dd': + return pad2(day) + case 'd': + return String(day) + case 'EEEE': + return localeDatePart(date, locale, { weekday: 'long' }) + case 'EEE': + return localeDatePart(date, locale, { weekday: 'short' }) + case 'ww': + return pad2(week) + case 'w': + return String(week) + } + }) + .join('') +} + +interface DateNotePatternMatch { + year?: number + month?: number + day?: number + week?: number +} + +function mergeCapture( + match: DateNotePatternMatch, + key: keyof DateNotePatternMatch, + value: number +): boolean { + if (match[key] !== undefined && match[key] !== value) return false + match[key] = value + return true +} + +function readTwoDigitYear(value: string): number { + const n = Number(value) + return n >= 70 ? 1900 + n : 2000 + n +} + +function matchDateNotePattern(pattern: string, text: string): DateNotePatternMatch | null { + const captures: Array<{ token: DateNotePatternToken; index: number }> = [] + let captureIndex = 1 + const source = parseDateNotePattern(pattern) + .map((part) => { + if (part.kind === 'literal') return escapeRegex(part.value) + switch (part.value) { + case 'yyyy': + captures.push({ token: part.value, index: captureIndex++ }) + return '(\\d{4})' + case 'yy': + captures.push({ token: part.value, index: captureIndex++ }) + return '(\\d{2})' + case 'MM': + case 'M': + case 'dd': + case 'd': + case 'ww': + case 'w': + captures.push({ token: part.value, index: captureIndex++ }) + return '(\\d{1,2})' + case 'MMMM': + case 'MMM': + case 'EEEE': + case 'EEE': + return '[^/]+' + } + }) + .join('') + const re = new RegExp(`^${source}$`) + const m = re.exec(text) + if (!m) return null + + const out: DateNotePatternMatch = {} + for (const capture of captures) { + const raw = m[capture.index] + if (!raw) continue + const value = Number(raw) + switch (capture.token) { + case 'yyyy': + if (!mergeCapture(out, 'year', value)) return null + break + case 'yy': + if (!mergeCapture(out, 'year', readTwoDigitYear(raw))) return null + break + case 'MM': + case 'M': + if (!mergeCapture(out, 'month', value)) return null + break + case 'dd': + case 'd': + if (!mergeCapture(out, 'day', value)) return null + break + case 'ww': + case 'w': + if (!mergeCapture(out, 'week', value)) return null + break + } + } + return out +} + +function shouldFormatDirectoryPattern(pattern: string): boolean { + const tokens = parseDateNotePattern(pattern).filter((part) => part.kind === 'token') + return ( + pattern.includes("'") || + tokens.length >= 2 || + tokens.some((part) => part.value === 'yyyy' || part.value === 'yy' || part.value === 'ww') + ) +} + +function formatDirectoryPattern( + date: Date, + pattern: string, + locale: string, + parts: { year?: number; week?: number } = {} +): string { + if (!shouldFormatDirectoryPattern(pattern)) return pattern + return formatDateNotePattern(date, pattern, locale, parts) +} + +function matchDirectoryPattern(pattern: string, text: string): DateNotePatternMatch | null { + if (!shouldFormatDirectoryPattern(pattern)) return pattern === text ? {} : null + return matchDateNotePattern(pattern, text) +} + +export function dateNoteDirectoryDisplayLabel( + pattern: string, + fallbackLabel: string +): string { + if (!shouldFormatDirectoryPattern(pattern)) { + return pattern.split('/').filter(Boolean).pop() || fallbackLabel + } + + for (const segment of pattern.split('/')) { + const parts = parseDateNotePattern(segment) + if (parts.some((part) => part.kind === 'token')) continue + const literal = parts + .map((part) => (part.kind === 'literal' ? part.value : '')) + .join('') + .trim() + if (literal) return literal + } + + return fallbackLabel +} + +const DATE_PATTERN_MATCH_KEYS: Array<keyof DateNotePatternMatch> = [ + 'year', + 'month', + 'day', + 'week' +] + +function mergePatternMatch( + target: DateNotePatternMatch, + source: DateNotePatternMatch +): boolean { + for (const key of DATE_PATTERN_MATCH_KEYS) { + const value = source[key] + if (value !== undefined && !mergeCapture(target, key, value)) return false + } + return true +} + export function normalizeVaultSettings( settings: VaultSettings | null | undefined ): VaultSettings { @@ -86,22 +469,67 @@ export function normalizeVaultSettings( normalizedFolderIcons[key] = value } } + const folderColors = settings?.folderColors + const normalizedFolderColors: Record<string, FolderColorId> = {} + if (folderColors && typeof folderColors === 'object') { + for (const [key, value] of Object.entries(folderColors)) { + if (!key || !isFolderColorId(value)) continue + normalizedFolderColors[key] = value + } + } + const normalizedFavorites: string[] = [] + if (Array.isArray(settings?.favorites)) { + const seen = new Set<string>() + for (const entry of settings.favorites) { + if (typeof entry !== 'string' || !entry || seen.has(entry)) continue + seen.add(entry) + normalizedFavorites.push(entry) + } + } + const primaryNotesLocation = + settings?.primaryNotesLocation === 'root' + ? 'root' + : DEFAULT_VAULT_SETTINGS.primaryNotesLocation + const dailyDirectory = normalizePrimaryRelativeSubpath( + normalizeDailyNotesDirectory(settings?.dailyNotes?.directory), + { primaryNotesLocation } + ) + const weeklyDirectory = normalizePrimaryRelativeSubpath( + normalizeWeeklyNotesDirectory(settings?.weeklyNotes?.directory), + { primaryNotesLocation } + ) + return { - primaryNotesLocation: - settings?.primaryNotesLocation === 'root' - ? 'root' - : DEFAULT_VAULT_SETTINGS.primaryNotesLocation, + primaryNotesLocation, dailyNotes: { enabled: !!settings?.dailyNotes?.enabled, - directory: normalizeDailyNotesDirectory(settings?.dailyNotes?.directory), - templateId: normalizeTemplateId(settings?.dailyNotes?.templateId) + directory: dailyDirectory, + titlePattern: normalizeDailyNoteTitlePattern(settings?.dailyNotes?.titlePattern), + locale: normalizeDailyNoteLocale(settings?.dailyNotes?.locale), + legacyPatterns: normalizeDailyNotePatternHistory( + settings?.dailyNotes?.legacyPatterns, + primaryNotesLocation + ), + templateId: normalizeTemplateId(settings?.dailyNotes?.templateId), + // Defaults: derive due dates ON, roll tasks over OFF. Absent (undefined) + // means "use the default", so only an explicit `false`/`true` overrides. + tasksDueOnNoteDate: settings?.dailyNotes?.tasksDueOnNoteDate !== false, + rolloverUnfinishedTasks: settings?.dailyNotes?.rolloverUnfinishedTasks === true }, weeklyNotes: { enabled: !!settings?.weeklyNotes?.enabled, - directory: normalizeWeeklyNotesDirectory(settings?.weeklyNotes?.directory), + directory: weeklyDirectory, + titlePattern: normalizeWeeklyNoteTitlePattern(settings?.weeklyNotes?.titlePattern), + locale: normalizeWeeklyNoteLocale(settings?.weeklyNotes?.locale), + legacyPatterns: normalizeWeeklyNotePatternHistory( + settings?.weeklyNotes?.legacyPatterns, + primaryNotesLocation + ), templateId: normalizeTemplateId(settings?.weeklyNotes?.templateId) }, - folderIcons: normalizedFolderIcons + folderIcons: normalizedFolderIcons, + folderColors: normalizedFolderColors, + favorites: normalizedFavorites } } @@ -109,6 +537,101 @@ export function folderIconKey(folder: NoteFolder, subpath: string): string { return `${folder}:${subpath}` } +/** + * Favorites store either a note's vault-relative path (e.g. `inbox/Idea.md`) or a + * folder key `folder:subpath`. Folder keys always contain a `:`; note paths never + * do, so the colon discriminates the two. + */ +export function favoriteFolderKey(folder: NoteFolder, subpath: string): string { + return folderIconKey(folder, subpath) +} + +export function isFavoriteFolderKey(key: string): boolean { + return key.includes(':') +} + +/** Parse a folder favorite key back into its `{ folder, subpath }` parts. */ +export function parseFavoriteFolderKey( + key: string +): { folder: NoteFolder; subpath: string } | null { + const idx = key.indexOf(':') + if (idx === -1) return null + const folder = key.slice(0, idx) as NoteFolder + return { folder, subpath: key.slice(idx + 1) } +} + +/** Toggle a favorite key, returning the next list (added at the end, or removed). */ +export function toggleFavorite(favorites: string[], key: string): string[] { + return favorites.includes(key) + ? favorites.filter((f) => f !== key) + : [...favorites, key] +} + +/** Rewrite a note favorite when its path changes (rename or move). */ +export function rewriteFavoriteNotePath( + favorites: string[], + oldPath: string, + newPath: string +): string[] { + if (oldPath === newPath || !favorites.includes(oldPath)) return favorites + return favorites.map((f) => (f === oldPath ? newPath : f)) +} + +/** + * Rewrite favorites after a folder rename/move: the folder's own key plus every + * note favorite whose path lived under that folder are repointed at the new + * location. `oldRelPrefix`/`newRelPrefix` are the vault-relative folder paths + * (with trailing slash) used to rewrite note paths. + */ +export function rewriteFavoritesForFolderRename( + favorites: string[], + folder: NoteFolder, + oldSubpath: string, + newSubpath: string, + oldRelPrefix: string, + newRelPrefix: string +): string[] { + const exactKey = favoriteFolderKey(folder, oldSubpath) + const keyPrefix = `${exactKey}/` + const newExactKey = favoriteFolderKey(folder, newSubpath) + return favorites.map((f) => { + if (f === exactKey) return newExactKey + if (f.startsWith(keyPrefix)) { + return favoriteFolderKey(folder, newSubpath + f.slice(exactKey.length)) + } + if (oldRelPrefix && f.startsWith(oldRelPrefix)) { + return newRelPrefix + f.slice(oldRelPrefix.length) + } + return f + }) +} + +/** Remove a single favorite key (note path or folder key) if present. */ +export function removeFavorite(favorites: string[], key: string): string[] { + return favorites.includes(key) ? favorites.filter((f) => f !== key) : favorites +} + +/** + * Remove favorites for a deleted folder: its own key, any descendant folder + * keys, and any note favorites whose path lived under it (`relPrefix` is the + * vault-relative folder path with trailing slash). + */ +export function removeFavoritesForFolder( + favorites: string[], + folder: NoteFolder, + subpath: string, + relPrefix: string +): string[] { + const exactKey = favoriteFolderKey(folder, subpath) + const keyPrefix = `${exactKey}/` + return favorites.filter( + (f) => + f !== exactKey && + !f.startsWith(keyPrefix) && + !(relPrefix && f.startsWith(relPrefix)) + ) +} + export function rewriteFolderIconsForRename( folderIcons: Record<string, FolderIconId>, folder: NoteFolder, @@ -168,6 +691,94 @@ export function duplicateFolderIcons( return next } +// Folder color maps share the exact key scheme as folder icons (`folder:subpath`), +// so renames/deletes/duplicates rewrite them identically. Generic helpers keep +// the two in lock-step without duplicating the traversal logic. +function rewriteFolderKeyMap<T>( + map: Record<string, T>, + folder: NoteFolder, + oldSubpath: string, + newSubpath: string +): Record<string, T> { + const next: Record<string, T> = {} + const exactKey = folderIconKey(folder, oldSubpath) + const prefix = `${exactKey}/` + for (const [key, value] of Object.entries(map)) { + if (key === exactKey) { + next[folderIconKey(folder, newSubpath)] = value + continue + } + if (key.startsWith(prefix)) { + next[folderIconKey(folder, newSubpath) + key.slice(exactKey.length)] = value + continue + } + next[key] = value + } + return next +} + +function removeFolderKeyMap<T>( + map: Record<string, T>, + folder: NoteFolder, + subpath: string +): Record<string, T> { + const next: Record<string, T> = {} + const exactKey = folderIconKey(folder, subpath) + const prefix = `${exactKey}/` + for (const [key, value] of Object.entries(map)) { + if (key === exactKey || key.startsWith(prefix)) continue + next[key] = value + } + return next +} + +function duplicateFolderKeyMap<T>( + map: Record<string, T>, + folder: NoteFolder, + sourceSubpath: string, + targetSubpath: string +): Record<string, T> { + const next: Record<string, T> = { ...map } + const exactKey = folderIconKey(folder, sourceSubpath) + const prefix = `${exactKey}/` + for (const [key, value] of Object.entries(map)) { + if (key === exactKey) { + next[folderIconKey(folder, targetSubpath)] = value + continue + } + if (key.startsWith(prefix)) { + next[folderIconKey(folder, targetSubpath) + key.slice(exactKey.length)] = value + } + } + return next +} + +export function rewriteFolderColorsForRename( + folderColors: Record<string, FolderColorId>, + folder: NoteFolder, + oldSubpath: string, + newSubpath: string +): Record<string, FolderColorId> { + return rewriteFolderKeyMap(folderColors, folder, oldSubpath, newSubpath) +} + +export function removeFolderColors( + folderColors: Record<string, FolderColorId>, + folder: NoteFolder, + subpath: string +): Record<string, FolderColorId> { + return removeFolderKeyMap(folderColors, folder, subpath) +} + +export function duplicateFolderColors( + folderColors: Record<string, FolderColorId>, + folder: NoteFolder, + sourceSubpath: string, + targetSubpath: string +): Record<string, FolderColorId> { + return duplicateFolderKeyMap(folderColors, folder, sourceSubpath, targetSubpath) +} + export function isPrimaryNotesAtRoot( settings: VaultSettings | null | undefined ): boolean { @@ -181,7 +792,10 @@ export function notePathWithinFolder( ): string { if (folder === 'inbox' && isPrimaryNotesAtRoot(settings)) return path const prefix = `${folder}/` - return path.startsWith(prefix) ? path.slice(prefix.length) : path + // Case-insensitive so a note under a capitalized on-disk system folder + // (e.g. `Inbox/`) lands in the same subpath as its sibling assets, which use + // the same lenient stripping. Mirrors `assetPathWithinFolder`. (#186) + return path.toLowerCase().startsWith(prefix) ? path.slice(prefix.length) : path } export function noteFolderSubpath( @@ -209,12 +823,35 @@ export function noteTitleForDate(date = new Date()): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } +export interface DailyNoteLocation { + title: string + subpath: string +} + +export function dailyNoteLocationForDate( + date = new Date(), + settings: VaultSettings | null | undefined +): DailyNoteLocation { + const normalized = normalizeVaultSettings(settings) + return dailyNoteLocationForPattern(date, normalized, currentDailyNotePattern(normalized)) +} + export function weeklyNoteTitle(date = new Date()): string { return `${getISOWeekYear(date)}-W${pad(getISOWeek(date))}` } -const DAILY_TITLE_RE = /^(\d{4})-(\d{2})-(\d{2})$/ -const WEEKLY_TITLE_RE = /^(\d{4})-W(\d{2})$/ +export interface WeeklyNoteLocation { + title: string + subpath: string +} + +export function weeklyNoteLocationForDate( + date = new Date(), + settings: VaultSettings | null | undefined +): WeeklyNoteLocation { + const normalized = normalizeVaultSettings(settings) + return weeklyNoteLocationForPattern(date, normalized, currentWeeklyNotePattern(normalized)) +} export interface DateNoteInfo { kind: 'daily' | 'weekly' @@ -222,38 +859,363 @@ export interface DateNoteInfo { date: Date } +function currentDailyNotePattern(settings: VaultSettings): DateNotePatternSettings { + return { + directory: settings.dailyNotes.directory, + titlePattern: settings.dailyNotes.titlePattern ?? DEFAULT_DAILY_NOTE_TITLE_PATTERN, + locale: settings.dailyNotes.locale ?? DEFAULT_DAILY_NOTE_LOCALE + } +} + +function currentWeeklyNotePattern(settings: VaultSettings): DateNotePatternSettings { + return { + directory: settings.weeklyNotes.directory, + titlePattern: settings.weeklyNotes.titlePattern ?? DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, + locale: settings.weeklyNotes.locale ?? DEFAULT_WEEKLY_NOTE_LOCALE + } +} + +function dailyNotePatterns(settings: VaultSettings): DateNotePatternSettings[] { + const current = currentDailyNotePattern(settings) + const seen = new Set([dateNotePatternKey(current)]) + const out = [current] + for (const pattern of settings.dailyNotes.legacyPatterns ?? []) { + const key = dateNotePatternKey(pattern) + if (seen.has(key)) continue + seen.add(key) + out.push(pattern) + } + return out +} + +function weeklyNotePatterns(settings: VaultSettings): DateNotePatternSettings[] { + const current = currentWeeklyNotePattern(settings) + const seen = new Set([dateNotePatternKey(current)]) + const out = [current] + for (const pattern of settings.weeklyNotes.legacyPatterns ?? []) { + const key = dateNotePatternKey(pattern) + if (seen.has(key)) continue + seen.add(key) + out.push(pattern) + } + return out +} + +function dailyNoteLocationForPattern( + date: Date, + settings: VaultSettings, + pattern: DateNotePatternSettings +): DailyNoteLocation { + const locale = pattern.locale ?? DEFAULT_DAILY_NOTE_LOCALE + const subpath = normalizePrimaryRelativeSubpath( + normalizeDailyNotesDirectory(formatDirectoryPattern(date, pattern.directory, locale)), + settings + ) + const title = normalizeDailyNoteTitlePattern( + formatDateNotePattern( + date, + pattern.titlePattern ?? DEFAULT_DAILY_NOTE_TITLE_PATTERN, + locale + ) + ) + return { title, subpath } +} + +function weeklyNoteLocationForPattern( + date: Date, + settings: VaultSettings, + pattern: DateNotePatternSettings +): WeeklyNoteLocation { + const weekYear = getISOWeekYear(date) + const week = getISOWeek(date) + const monday = mondayOfISOWeek(weekYear, week) + const locale = pattern.locale ?? DEFAULT_WEEKLY_NOTE_LOCALE + const patternParts = { year: weekYear, week } + const subpath = normalizePrimaryRelativeSubpath( + normalizeWeeklyNotesDirectory( + formatDirectoryPattern(monday, pattern.directory, locale, patternParts) + ), + settings + ) + const title = normalizeWeeklyNoteTitlePattern( + formatDateNotePattern( + monday, + pattern.titlePattern ?? DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, + locale, + patternParts + ) + ) + return { title, subpath } +} + +/** + * Numerics a note's directory + title expose, used only to bound the set of + * candidate dates we round-trip. Tokens we cannot reverse cheaply (localized + * month/weekday names) contribute nothing here — the round-trip below verifies + * them instead, so the locale never has to be parsed backwards. + */ +function patternNumerics( + pattern: DateNotePatternSettings, + subpath: string, + title: string, + defaultTitlePattern: string +): DateNotePatternMatch | null { + const directoryMatch = matchDirectoryPattern(pattern.directory, subpath) + const titleMatch = matchDateNotePattern(pattern.titlePattern ?? defaultTitlePattern, title) + if (!directoryMatch || !titleMatch) return null + const merged: DateNotePatternMatch = {} + if (!mergePatternMatch(merged, directoryMatch) || !mergePatternMatch(merged, titleMatch)) { + return null + } + return merged +} + +/** + * Every calendar day consistent with the extracted numerics. A day-of-month + * pins it to one date; otherwise we enumerate the relevant month(s)/week and + * let the round-trip pick the match — so a title built from ISO week + weekday + * (no day-of-month) still resolves to its day. + */ +function dailyCandidateDates(n: DateNotePatternMatch): Date[] { + if (n.year === undefined) return [] + if (n.month !== undefined && n.day !== undefined) { + const date = new Date(n.year, n.month - 1, n.day) + const valid = + date.getFullYear() === n.year && + date.getMonth() === n.month - 1 && + date.getDate() === n.day + return valid ? [date] : [] + } + const firstMonth = n.month !== undefined ? n.month - 1 : 0 + const lastMonth = n.month !== undefined ? n.month - 1 : 11 + const out: Date[] = [] + for (let month = firstMonth; month <= lastMonth; month++) { + const daysInMonth = new Date(n.year, month + 1, 0).getDate() + for (let day = 1; day <= daysInMonth; day++) { + if (n.day !== undefined && day !== n.day) continue + const date = new Date(n.year, month, day) + if (n.week !== undefined && getISOWeek(date) !== n.week) continue + out.push(date) + } + } + return out +} + +/** Mondays of every ISO week consistent with the extracted numerics. */ +function weeklyCandidateMondays(n: DateNotePatternMatch): Date[] { + if (n.year === undefined) return [] + if (n.week !== undefined) { + const monday = mondayOfISOWeek(n.year, n.week) + return getISOWeekYear(monday) === n.year ? [monday] : [] + } + const out: Date[] = [] + for (let week = 1; week <= 53; week++) { + const monday = mondayOfISOWeek(n.year, week) + if (getISOWeekYear(monday) !== n.year) continue + out.push(monday) + } + return out +} + +function matchDailyPattern( + subpath: string, + title: string, + settings: VaultSettings, + pattern: DateNotePatternSettings +): Date | null { + const numerics = patternNumerics(pattern, subpath, title, DEFAULT_DAILY_NOTE_TITLE_PATTERN) + if (!numerics) return null + for (const candidate of dailyCandidateDates(numerics)) { + const expected = dailyNoteLocationForPattern(candidate, settings, pattern) + if (expected.subpath === subpath && expected.title === title) return candidate + } + return null +} + +function matchWeeklyPattern( + subpath: string, + title: string, + settings: VaultSettings, + pattern: DateNotePatternSettings +): Date | null { + const numerics = patternNumerics(pattern, subpath, title, DEFAULT_WEEKLY_NOTE_TITLE_PATTERN) + if (!numerics) return null + for (const candidate of weeklyCandidateMondays(numerics)) { + const expected = weeklyNoteLocationForPattern(candidate, settings, pattern) + if (expected.subpath === subpath && expected.title === title) return candidate + } + return null +} + /** * Classify a note as a daily or weekly note, or `null` if it is neither. - * A note qualifies only when its title matches the date/week format, it lives - * in the configured daily/weekly directory, and that feature is enabled — so a - * stray note titled `2026-06-08` outside the daily folder is not treated as one. + * A note qualifies only when re-formatting its recovered date with the + * configured directory/title pattern reproduces this exact path — so a stray + * note titled `2026-06-08` outside the daily folder is not treated as one, and + * a note is recognized regardless of which tokens its title uses to encode the + * day (day-of-month, or ISO week + weekday). */ export function classifyDateNote( note: Pick<NoteMeta, 'folder' | 'path' | 'title'>, settings: VaultSettings | null | undefined ): DateNoteInfo | null { const normalized = normalizeVaultSettings(settings) - const subpath = noteFolderSubpath(note, settings) + if (note.folder !== 'inbox') return null + + const subpath = noteFolderSubpath(note, normalized) + + if (normalized.dailyNotes.enabled) { + for (const pattern of dailyNotePatterns(normalized)) { + const date = matchDailyPattern(subpath, note.title, normalized, pattern) + if (date) return { kind: 'daily', date } + } + } - if (normalized.dailyNotes.enabled && subpath === normalized.dailyNotes.directory) { - const m = DAILY_TITLE_RE.exec(note.title) - if (m) { - const [, y, mo, d] = m - return { kind: 'daily', date: new Date(Number(y), Number(mo) - 1, Number(d)) } + if (normalized.weeklyNotes.enabled) { + for (const pattern of weeklyNotePatterns(normalized)) { + const date = matchWeeklyPattern(subpath, note.title, normalized, pattern) + if (date) return { kind: 'weekly', date } } } - if (normalized.weeklyNotes.enabled && subpath === normalized.weeklyNotes.directory) { - const m = WEEKLY_TITLE_RE.exec(note.title) - if (m) { - const [, y, w] = m - return { kind: 'weekly', date: mondayOfISOWeek(Number(y), Number(w)) } + return null +} + +export interface DateNoteIndexes { + dailyByDate: Map<string, NoteMeta> + weeklyByWeek: Map<string, NoteMeta> +} + +export function buildDateNoteIndexes( + notes: readonly NoteMeta[], + settings: VaultSettings | null | undefined +): DateNoteIndexes { + const dailyByDate = new Map<string, NoteMeta>() + const weeklyByWeek = new Map<string, NoteMeta>() + + for (const note of notes) { + const info = classifyDateNote(note, settings) + if (!info) continue + if (info.kind === 'daily') dailyByDate.set(noteTitleForDate(info.date), note) + else weeklyByWeek.set(weeklyNoteTitle(info.date), note) + } + + return { dailyByDate, weeklyByWeek } +} + +/** + * Map every daily note to its own date as `vault-relative path -> YYYY-MM-DD` + * (the same encoding a `due:` token uses). Returns an empty map when daily + * notes are disabled or `dailyNotes.tasksDueOnNoteDate` is off, so callers can + * feed the result straight to `inferDailyTaskDueDates` without re-checking the + * setting. Weekly notes are intentionally excluded — a week has no single day. + */ +export function buildDailyNoteDateByPath( + notes: readonly NoteMeta[], + settings: VaultSettings | null | undefined +): Map<string, string> { + const out = new Map<string, string>() + const normalized = normalizeVaultSettings(settings) + if (!normalized.dailyNotes.enabled || !normalized.dailyNotes.tasksDueOnNoteDate) return out + for (const note of notes) { + const info = classifyDateNote(note, normalized) + if (info?.kind === 'daily') out.set(note.path, noteTitleForDate(info.date)) + } + return out +} + +export function dateNoteFolderMayBelongToDatePattern( + subpath: string, + settings: VaultSettings | null | undefined +): boolean { + const normalized = normalizeVaultSettings(settings) + if (normalized.dailyNotes.enabled) { + for (const pattern of dailyNotePatterns(normalized)) { + if (matchDirectoryPattern(pattern.directory, subpath)) return true + } + } + if (normalized.weeklyNotes.enabled) { + for (const pattern of weeklyNotePatterns(normalized)) { + if (matchDirectoryPattern(pattern.directory, subpath)) return true } } + return false +} +export function findDailyNoteForDate( + notes: readonly NoteMeta[], + settings: VaultSettings | null | undefined, + date: Date +): NoteMeta | null { + const expected = dailyNoteLocationForDate(date, settings) + const normalized = normalizeVaultSettings(settings) + const dateKey = noteTitleForDate(date) + return ( + notes.find( + (note) => + note.folder === 'inbox' && + note.title === expected.title && + noteFolderSubpath(note, normalized) === expected.subpath && + classifyDateNote(note, normalized)?.kind === 'daily' + ) ?? + notes.find((note) => { + const info = classifyDateNote(note, normalized) + return info?.kind === 'daily' && noteTitleForDate(info.date) === dateKey + }) ?? + null + ) +} + +export function findWeeklyNoteForDate( + notes: readonly NoteMeta[], + settings: VaultSettings | null | undefined, + date: Date +): NoteMeta | null { + const expected = weeklyNoteLocationForDate(date, settings) + const normalized = normalizeVaultSettings(settings) + const weekKey = weeklyNoteTitle(date) + return ( + notes.find( + (note) => + note.folder === 'inbox' && + note.title === expected.title && + noteFolderSubpath(note, normalized) === expected.subpath && + classifyDateNote(note, normalized)?.kind === 'weekly' + ) ?? + notes.find((note) => { + const info = classifyDateNote(note, normalized) + return info?.kind === 'weekly' && weeklyNoteTitle(info.date) === weekKey + }) ?? + null + ) +} + +export function findDateNoteByTitle( + notes: readonly NoteMeta[], + settings: VaultSettings | null | undefined, + kind: DateNoteInfo['kind'], + title: string +): NoteMeta | null { + for (const note of notes) { + if (note.title !== title) continue + const info = classifyDateNote(note, settings) + if (info?.kind === kind) return note + } return null } +// Match a path's top segment to a system folder case-insensitively. On +// case-insensitive filesystems (macOS/Windows) the inbox folder can be stored +// with different casing than the canonical lowercase the rest of the app emits: +// `listNotes` builds note paths from `folderRoot()` (always `inbox/…`), but +// `listAssets`/the watcher walk real directory entries and preserve the on-disk +// case (e.g. `Inbox/…`). Comparing case-sensitively dropped those assets to +// `null`, so a capitalized `Inbox/` showed its notes but hid its images/PDFs. (#186) +function systemFolderForTopSegment(top: string): NoteFolder | null { + const lower = top.toLowerCase() + return SYSTEM_FOLDERS.has(lower as NoteFolder) ? (lower as NoteFolder) : null +} + export function folderForVaultRelativePath( relPath: string, settings: VaultSettings | null | undefined @@ -261,8 +1223,9 @@ export function folderForVaultRelativePath( const normalized = relPath.replace(/\\/g, '/').replace(/^\/+/, '') const top = normalized.split('/')[0] ?? '' if (!top || top.startsWith('.')) return null - if (SYSTEM_FOLDERS.has(top as NoteFolder)) return top as NoteFolder - if (isPrimaryNotesAtRoot(settings) && !RESERVED_ROOT_NAMES.has(top)) return 'inbox' + const system = systemFolderForTopSegment(top) + if (system) return system + if (isPrimaryNotesAtRoot(settings) && !RESERVED_ROOT_NAMES.has(top.toLowerCase())) return 'inbox' return null } @@ -274,7 +1237,11 @@ export function assetPathWithinFolder( const normalized = assetPath.replace(/\\/g, '/').replace(/^\/+/, '') if (folder === 'inbox' && isPrimaryNotesAtRoot(settings)) return normalized const prefix = `${folder}/` - return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized + // Strip the system-folder prefix case-insensitively so a capitalized on-disk + // folder (e.g. `Inbox/`) lands in the same subpath tree as its notes. (#186) + return normalized.toLowerCase().startsWith(prefix) + ? normalized.slice(prefix.length) + : normalized } export function assetFolderSubpath( diff --git a/packages/app-core/src/lib/vault-switcher.test.ts b/packages/app-core/src/lib/vault-switcher.test.ts index 45e90d07..d0c0b37b 100644 --- a/packages/app-core/src/lib/vault-switcher.test.ts +++ b/packages/app-core/src/lib/vault-switcher.test.ts @@ -111,4 +111,35 @@ describe('buildVaultSwitcherEntries', () => { ['local', 'Work', false] ]) }) + + it('names the current remote vault after the profile, not the server vault folder (#153)', () => { + // The sidebar header derives its label and badge from the current entry's + // name. In remote mode the server reports a vault folder name ("workspace"), + // but the header should show the connection the user named ("Home"). + const entries = buildVaultSwitcherEntries({ + localVaults: [], + remoteProfiles: [ + { + id: 'home', + name: 'Home', + baseUrl: 'https://home.example.com', + hasCredential: true, + vaultPath: null, + lastConnectedAt: 1 + } + ], + currentVault: { root: '/srv/workspace', name: 'workspace' }, + workspaceMode: 'remote', + remoteWorkspaceInfo: { + mode: 'remote', + baseUrl: 'https://home.example.com', + authConfigured: true, + capabilities: null, + profileId: 'home' + } + }) + + const current = entries.find((entry) => entry.current) + expect(current?.name).toBe('Home') + }) }) diff --git a/packages/app-core/src/lib/vim-nav.test.ts b/packages/app-core/src/lib/vim-nav.test.ts index 0b6a3d88..e2eed61c 100644 --- a/packages/app-core/src/lib/vim-nav.test.ts +++ b/packages/app-core/src/lib/vim-nav.test.ts @@ -1,9 +1,16 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import type { EditorView } from '@codemirror/view' import { TASKS_TAB_PATH } from '@shared/tasks' import { databaseTabPath } from '@shared/databases' -import { hintTargetOpensNote } from './vim-nav' + +const cmMock = vi.hoisted(() => ({ vim: undefined as unknown })) +vi.mock('@replit/codemirror-vim', () => ({ + getCM: () => ({ state: { vim: cmMock.vim } }) +})) + +import { hintTargetOpensNote, isVimAwaitingArgument } from './vim-nav' function el(html: string): HTMLElement { const container = document.createElement('div') @@ -46,3 +53,28 @@ describe('hintTargetOpensNote (#100 — hint into a note lands in the editor)', expect(hintTargetOpensNote(null)).toBe(false) }) }) + +describe('isVimAwaitingArgument (#147 — Space is the Vim arg, not the leader)', () => { + const view = {} as unknown as EditorView // getCM is mocked, so the view is unused + + it('is true while a partial command is buffered (f/t/r, operators, counts)', () => { + cmMock.vim = { inputState: { keyBuffer: ['f'] } } + expect(isVimAwaitingArgument(view)).toBe(true) + }) + + it('is true when a literal next character is expected (e.g. r)', () => { + cmMock.vim = { expectLiteralNext: true, inputState: { keyBuffer: [] } } + expect(isVimAwaitingArgument(view)).toBe(true) + }) + + it('is false when Vim is at rest', () => { + cmMock.vim = { expectLiteralNext: false, inputState: { keyBuffer: [] } } + expect(isVimAwaitingArgument(view)).toBe(false) + }) + + it('is false with no vim state, and for a null view', () => { + cmMock.vim = null + expect(isVimAwaitingArgument(view)).toBe(false) + expect(isVimAwaitingArgument(null)).toBe(false) + }) +}) diff --git a/packages/app-core/src/lib/vim-nav.ts b/packages/app-core/src/lib/vim-nav.ts index 85c1dc4d..3d3f90af 100644 --- a/packages/app-core/src/lib/vim-nav.ts +++ b/packages/app-core/src/lib/vim-nav.ts @@ -74,6 +74,22 @@ export function isEditorInsertMode(view: EditorView | null, vimMode: boolean): b return cm?.state.vim?.insertMode === true } +/** + * True when codemirror-vim is mid-command, waiting for a character or motion + * argument — e.g. after `f`/`t`/`F`/`T`/`r`, an operator like `d`/`c`, or a + * pending count. In that state the next key (including Space) belongs to the Vim + * sequence, not the global leader, so the leader must stand down. (#147) + */ +export function isVimAwaitingArgument(view: EditorView | null): boolean { + if (!view) return false + const vim = getCM(view)?.state?.vim as + | { expectLiteralNext?: boolean; inputState?: { keyBuffer?: unknown[] } } + | undefined + if (!vim) return false + if (vim.expectLiteralNext) return true + return (vim.inputState?.keyBuffer?.length ?? 0) > 0 +} + export function clearEditorPendingVimStatus(view: EditorView | null): void { if (!view) return const cm = getCM(view) diff --git a/packages/app-core/src/lib/wikilink-navigation.ts b/packages/app-core/src/lib/wikilink-navigation.ts new file mode 100644 index 00000000..19527799 --- /dev/null +++ b/packages/app-core/src/lib/wikilink-navigation.ts @@ -0,0 +1,27 @@ +import { useStore } from '../store' +import { parseOutline } from './outline' + +/** + * Open `path` and scroll to the heading matching `headingAnchor` + * (case-insensitive, like Obsidian). Falls back to opening the note at the top + * when the heading isn't found. Shared by the editor's wikilink click and the + * preview pane so `[[Doc#Heading]]` lands on the heading. (#196) + */ +export async function openWikilinkHeading(path: string, headingAnchor: string): Promise<void> { + const s = useStore.getState() + let body = s.noteContents[path]?.body + if (body == null) { + try { + body = (await window.zen.readNote(path)).body + } catch { + body = '' + } + } + const needle = headingAnchor.trim().toLowerCase() + const heading = parseOutline(body).find((h) => h.text.trim().toLowerCase() === needle) + if (heading) { + await s.openNoteAtOffset(path, heading.from, { scrollMode: 'start' }) + } else { + await s.selectNote(path) + } +} diff --git a/packages/app-core/src/lib/wikilinks.test.ts b/packages/app-core/src/lib/wikilinks.test.ts new file mode 100644 index 00000000..37e45c8b --- /dev/null +++ b/packages/app-core/src/lib/wikilinks.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + parseCreateNotePath, + resolveWikilinkTarget, + stripWikilinkAnchor, + suggestCreateNotePath, + wikilinkHeadingAnchor +} from './wikilinks' + +const notes = [ + { path: 'inbox/My Document.md', title: 'My Document', folder: 'inbox' as const }, + { path: 'inbox/projects/Spec.md', title: 'Spec', folder: 'inbox' as const } +] + +describe('stripWikilinkAnchor (#196)', () => { + it('drops a #heading anchor', () => { + expect(stripWikilinkAnchor('My Document#My Heading')).toBe('My Document') + }) + + it('drops a ^block anchor', () => { + expect(stripWikilinkAnchor('My Document^abc123')).toBe('My Document') + }) + + it('keeps a plain target untouched', () => { + expect(stripWikilinkAnchor('My Document')).toBe('My Document') + }) + + it('keeps the path up to the first anchor', () => { + expect(stripWikilinkAnchor('projects/Spec#design')).toBe('projects/Spec') + }) +}) + +describe('wikilinkHeadingAnchor (#196)', () => { + it('returns the heading text after #', () => { + expect(wikilinkHeadingAnchor('My Document#My Heading')).toBe('My Heading') + }) + + it('is null without a heading anchor', () => { + expect(wikilinkHeadingAnchor('My Document')).toBeNull() + expect(wikilinkHeadingAnchor('My Document^block')).toBeNull() + }) +}) + +describe('resolveWikilinkTarget — heading/block anchors (#196)', () => { + it('resolves [[Doc#heading]] to the document', () => { + expect(resolveWikilinkTarget(notes, 'My Document#My Heading')?.path).toBe('inbox/My Document.md') + }) + + it('resolves [[Doc^block]] to the document', () => { + expect(resolveWikilinkTarget(notes, 'My Document^abc')?.path).toBe('inbox/My Document.md') + }) + + it('still resolves a plain [[Doc]]', () => { + expect(resolveWikilinkTarget(notes, 'My Document')?.path).toBe('inbox/My Document.md') + }) + + it('resolves a path-like [[folder/Doc#heading]]', () => { + expect(resolveWikilinkTarget(notes, 'projects/Spec#design')?.path).toBe('inbox/projects/Spec.md') + }) + + it('returns null for a bare [[#heading]] with no document', () => { + expect(resolveWikilinkTarget(notes, '#My Heading')).toBeNull() + }) +}) + +describe('suggestCreateNotePath — anchored targets (#196)', () => { + it('suggests the document, not the invalid anchored name', () => { + expect(suggestCreateNotePath('New Doc#Heading')).toBe('/New Doc.md') + }) +}) + +describe('leading-slash + anchor (#196 — [[/Untitled#test]])', () => { + const untitled = [{ path: 'inbox/Untitled.md', title: 'Untitled', folder: 'inbox' as const }] + + it('strips the anchor off an absolute-path target', () => { + expect(stripWikilinkAnchor('/Untitled#test')).toBe('/Untitled') + }) + + it('resolves [[/Untitled#test]] to inbox/Untitled.md', () => { + expect(resolveWikilinkTarget(untitled, '/Untitled#test')?.path).toBe('inbox/Untitled.md') + }) + + it('suggests a valid create path (no #, no extra slash)', () => { + expect(suggestCreateNotePath('/Untitled#test')).toBe('/Untitled.md') + }) + + it('parseCreateNotePath accepts the suggested path', () => { + expect(parseCreateNotePath(suggestCreateNotePath('/Untitled#test')).relPath).toBe( + 'inbox/Untitled.md' + ) + }) +}) diff --git a/packages/app-core/src/lib/wikilinks.ts b/packages/app-core/src/lib/wikilinks.ts index 79b77249..c2c6d23c 100644 --- a/packages/app-core/src/lib/wikilinks.ts +++ b/packages/app-core/src/lib/wikilinks.ts @@ -30,6 +30,30 @@ export function isPathLikeWikilinkTarget(target: string): boolean { return trimmed.startsWith('/') || trimmed.includes('/') || /\.md$/i.test(trimmed) } +/** + * Strip a `#heading` / `^block` anchor off a wikilink target — it points within + * the note, not at the note name. `[[Doc#Heading]]` resolves to `Doc`. Note + * names can't contain `#` or `^` (see INVALID_NOTE_PATH_CHARS), so the first one + * always starts the anchor. (#196) + */ +export function stripWikilinkAnchor(target: string): string { + const anchor = target.search(/[#^]/) + return anchor === -1 ? target : target.slice(0, anchor) +} + +/** + * The `#heading` text from a wikilink target, or null when there's no heading + * anchor. `[[Doc#My Heading]]` → `My Heading`; `[[Doc^block]]` / `[[Doc]]` → + * null (block refs aren't headings). Used to scroll to the heading on click. (#196) + */ +export function wikilinkHeadingAnchor(target: string): string | null { + const hash = target.indexOf('#') + if (hash < 0) return null + const caret = target.indexOf('^') + if (caret >= 0 && caret < hash) return null // a ^block anchor comes first + return target.slice(hash + 1).trim() || null +} + function resolveExplicitPath(notes: NoteRef[], target: string): NoteRef | null { const normalized = normalizeSlashes(target.trim()) if (!normalized) return null @@ -65,13 +89,17 @@ function resolvePathSuffix(notes: NoteRef[], target: string): NoteRef | null { } export function resolveWikilinkTarget<T extends NoteRef>(notes: T[], target: string): T | null { + // `[[Doc#Heading]]` / `[[Doc^block]]` point at a spot inside Doc — resolve the + // document, ignoring the anchor. (#196) + const doc = stripWikilinkAnchor(target) const visible = notes.filter((note) => note.folder !== 'trash') - if (isPathLikeWikilinkTarget(target)) { - return (resolveExplicitPath(visible, target) ?? - resolvePathSuffix(visible, target)) as T | null + if (isPathLikeWikilinkTarget(doc)) { + return (resolveExplicitPath(visible, doc) ?? + resolvePathSuffix(visible, doc)) as T | null } - const needle = normalizeForCompare(stripMdExtension(target)) + const needle = normalizeForCompare(stripMdExtension(doc)) + if (!needle) return null return visible.find((note) => normalizeForCompare(note.title) === needle) ?? null } @@ -102,7 +130,8 @@ export function extractWikilinkTargets(body: string): string[] { } export function suggestCreateNotePath(target: string): string { - const trimmed = normalizeSlashes(target.trim()).replace(/\/+$/, '') + // Creating from `[[Doc#Heading]]` makes `Doc`, not the invalid `Doc#Heading`. (#196) + const trimmed = normalizeSlashes(stripWikilinkAnchor(target).trim()).replace(/\/+$/, '') if (!trimmed) return '/Untitled.md' if (trimmed.startsWith('/')) { diff --git a/packages/app-core/src/lib/workspace-tabs.ts b/packages/app-core/src/lib/workspace-tabs.ts index 5f673f8b..246c5739 100644 --- a/packages/app-core/src/lib/workspace-tabs.ts +++ b/packages/app-core/src/lib/workspace-tabs.ts @@ -5,6 +5,7 @@ import { isTagsTabPath } from '@shared/tags' import { isTasksTabPath } from '@shared/tasks' import { isTrashTabPath } from '@shared/trash' import { isDatabaseTabPath } from '@shared/databases' +import { isAssetsViewTabPath } from '@shared/assets-view' import { isAssetTabPath } from './asset-tabs' import { isDiagramTabPath } from './diagram-tabs' import { allLeaves, type PaneLayout } from './pane-layout' @@ -17,6 +18,7 @@ export function isWorkspaceVirtualTabPath(path: string): boolean { isHelpTabPath(path) || isArchiveTabPath(path) || isTrashTabPath(path) || + isAssetsViewTabPath(path) || isAssetTabPath(path) || isDiagramTabPath(path) || isDatabaseTabPath(path) diff --git a/packages/app-core/src/store-database-sync.test.ts b/packages/app-core/src/store-database-sync.test.ts new file mode 100644 index 00000000..5604b8bd --- /dev/null +++ b/packages/app-core/src/store-database-sync.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Record pages mirror their database row's fields as frontmatter (the table is +// the source of truth). Editing the table — a cell value or an added field — +// must update an OPEN record page's frontmatter live, preserving its body. + +const CSV = 'Untitled Database.base/data.csv' +const PAGE = 'Untitled Database.base/pages/test.md' + +function baseDoc() { + return { + version: 1, + idFieldId: 'f_id', + activeViewId: 'v1', + fields: [ + { id: 'f_id', name: 'id', type: 'text' }, + { id: 'f_name', name: 'Name', type: 'text' }, + { id: 'f_a', name: 'New field', type: 'text' } + ], + views: [{ id: 'v1', type: 'table', name: 'Table', columnOrder: ['f_name', 'f_a'] }], + rows: [{ id: 'row1', cells: { f_id: 'row1', f_name: 'test', f_a: 'old' } }], + pages: { row1: PAGE } + } +} + +function pageNote(body: string) { + return { + path: PAGE, + title: 'test', + folder: 'inbox' as const, + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: body.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: '', + body + } +} + +function installZen(): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getCapabilities: vi.fn().mockReturnValue({ + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false + }), + scanTasks: vi.fn().mockResolvedValue([]), + listNotes: vi.fn().mockResolvedValue([]), + listFolders: vi.fn().mockResolvedValue([]), + listLocalVaults: vi.fn().mockResolvedValue([]), + listAssets: vi.fn().mockResolvedValue([]), + hasAssetsDir: vi.fn().mockResolvedValue(false), + getRemoteWorkspaceInfo: vi.fn().mockResolvedValue(null), + getVaultSettings: vi.fn().mockResolvedValue({}), + writeNote: vi.fn().mockResolvedValue(pageNote('')), + writeDatabaseRows: vi.fn().mockResolvedValue(undefined), + writeDatabaseSchema: vi.fn().mockResolvedValue(undefined) + } + }) +} + +async function loadStore() { + vi.resetModules() + localStorage.clear() + installZen() + return import('./store') +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('record page ↔ table sync (table is the source)', () => { + it('updates an open record page frontmatter when a table cell changes', async () => { + const { useStore } = await loadStore() + const doc = baseDoc() + useStore.setState({ + databases: { [CSV]: doc as never }, + noteContents: { [PAGE]: pageNote('---\nNew field: old\n---\n# test\n\nbody text\n') as never } + }) + + // Edit the "New field" cell from "old" → "new" in the table. + const next = { + ...doc, + rows: [{ id: 'row1', cells: { f_id: 'row1', f_name: 'test', f_a: 'new' } }] + } + useStore.getState().updateDatabaseRows(CSV, next as never) + + const body = useStore.getState().noteContents[PAGE]!.body + expect(body).toContain('New field: new') + expect(body).not.toContain('New field: old') + expect(body).toContain('# test') // body preserved + expect(body).toContain('body text') + }) + + it('adds a newly created field to an open record page frontmatter', async () => { + const { useStore } = await loadStore() + const doc = baseDoc() + useStore.setState({ + databases: { [CSV]: doc as never }, + noteContents: { [PAGE]: pageNote('---\nNew field: old\n---\n# test\n') as never } + }) + + // Add "New field 2" via a schema change. + const next = { + ...doc, + fields: [...doc.fields, { id: 'f_b', name: 'New field 2', type: 'text' }], + rows: [{ id: 'row1', cells: { f_id: 'row1', f_name: 'test', f_a: 'old', f_b: 'testing' } }] + } + useStore.getState().updateDatabaseSchema(CSV, next as never) + + const body = useStore.getState().noteContents[PAGE]!.body + expect(body).toContain('New field: old') + expect(body).toContain('New field 2: testing') + }) + + it('leaves a record page that is not open untouched (lazily synced later)', async () => { + const { useStore } = await loadStore() + const doc = baseDoc() + // No noteContents entry for PAGE → it isn't open. + useStore.setState({ databases: { [CSV]: doc as never }, noteContents: {} }) + + const next = { ...doc, rows: [{ id: 'row1', cells: { f_id: 'row1', f_name: 'test', f_a: 'new' } }] } + useStore.getState().updateDatabaseRows(CSV, next as never) + + expect(useStore.getState().noteContents[PAGE]).toBeUndefined() + }) +}) diff --git a/packages/app-core/src/store-note-integrity.test.ts b/packages/app-core/src/store-note-integrity.test.ts new file mode 100644 index 00000000..a50c149b --- /dev/null +++ b/packages/app-core/src/store-note-integrity.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Reproduction harness for #202 ("Notes show the wrong content" → files +// overwritten with another note's body). Drives the REAL store over an +// in-memory vault, simulating an Obsidian vault opened in ROOT mode and the +// user NAVIGATING between notes (the reporter made "no edits"), plus the +// external file-watcher firing (their vault lived under ~/sync). Asserts that a +// note's content never lands under another note's path, and that pure +// navigation never writes to disk. + +interface MemNote { + path: string + body: string +} + +function meta(path: string, body: string) { + const title = path.split('/').pop()!.replace(/\.md$/, '') + return { + path, + title, + folder: 'inbox' as const, + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: body.length, + tags: [], + wikilinks: [], + assetEmbeds: [], + hasAttachments: false, + excerpt: body.slice(0, 40) + } +} + +// The reporter's vault: nested folders, spaces in names, distinct bodies. +const INITIAL: MemNote[] = [ + { path: 'index.md', body: 'INDEX_BODY' }, + { path: 'Work/Documentation/Vault CLI Cheatsheet.md', body: 'CHEATSHEET_BODY' }, + { path: 'Work/Documentation/Another Note.md', body: 'ANOTHER_BODY' }, + { path: 'Work/Projects/plan.md', body: 'PLAN_BODY' } +] + +let vault: Map<string, string> +const writeCalls: Array<{ path: string; body: string }> = [] + +function installZen(): void { + vault = new Map(INITIAL.map((n) => [n.path, n.body])) + writeCalls.length = 0 + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + getCapabilities: vi.fn().mockReturnValue({ + supportsUpdater: false, + supportsNativeMenus: false, + supportsFloatingWindows: false, + supportsLocalFilesystemPickers: true, + supportsRemoteWorkspace: false, + supportsCliInstall: false, + supportsCustomTemplates: false + }), + scanTasks: vi.fn().mockResolvedValue([]), + scanTasksForPath: vi.fn().mockResolvedValue([]), + listNotes: vi.fn(async () => [...vault.entries()].map(([p, b]) => meta(p, b))), + listFolders: vi.fn().mockResolvedValue([]), + listLocalVaults: vi.fn().mockResolvedValue([]), + listAssets: vi.fn().mockResolvedValue([]), + hasAssetsDir: vi.fn().mockResolvedValue(false), + getRemoteWorkspaceInfo: vi.fn().mockResolvedValue(null), + getVaultSettings: vi.fn().mockResolvedValue({}), + closeVault: vi.fn().mockResolvedValue(null), + readNote: vi.fn(async (path: string) => { + if (!vault.has(path)) throw new Error(`ENOENT ${path}`) + const body = vault.get(path)! + return { ...meta(path, body), body } + }), + writeNote: vi.fn(async (path: string, body: string) => { + writeCalls.push({ path, body }) + vault.set(path, body) + return meta(path, body) + }) + } + }) +} + +async function loadStore() { + vi.resetModules() + localStorage.clear() + return import('./store') +} + +async function flush(): Promise<void> { + await new Promise((r) => window.setTimeout(r, 0)) +} + +beforeEach(() => { + vi.restoreAllMocks() + installZen() +}) + +function seedRootVault(useStore: { setState: (s: Record<string, unknown>) => void }): void { + useStore.setState({ + notes: INITIAL.map((n) => meta(n.path, n.body)), + vaultSettings: { + primaryNotesLocation: 'root', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } + }) +} + +describe('#202 — store keeps each note its own content during navigation', () => { + it('opening note after note never cross-wires content', async () => { + const { useStore } = await loadStore() + seedRootVault(useStore) + const paneId = useStore.getState().activePaneId + + for (const n of INITIAL) { + await useStore.getState().openNoteInPane(paneId, n.path) + await flush() + } + // Revisit in a different order (tab switching). + for (const n of [...INITIAL].reverse()) { + await useStore.getState().focusTabInPane(paneId, n.path) + await flush() + } + + const contents = useStore.getState().noteContents + for (const n of INITIAL) { + expect(contents[n.path]?.body, `${n.path} holds the wrong body`).toBe(n.body) + } + }) + + it('pure navigation (no edits) writes NOTHING to disk', async () => { + const { useStore } = await loadStore() + seedRootVault(useStore) + const paneId = useStore.getState().activePaneId + + for (const n of INITIAL) { + await useStore.getState().openNoteInPane(paneId, n.path) + await flush() + } + expect(writeCalls, `navigation triggered a write: ${JSON.stringify(writeCalls)}`).toEqual([]) + // And disk is byte-identical to the originals. + for (const n of INITIAL) expect(vault.get(n.path)).toBe(n.body) + }) + + it('editing one note autosaves ONLY that note, never a neighbour', async () => { + const { useStore } = await loadStore() + seedRootVault(useStore) + const paneId = useStore.getState().activePaneId + + const target = 'Work/Documentation/Vault CLI Cheatsheet.md' + await useStore.getState().openNoteInPane(paneId, target) + await flush() + useStore.getState().updateNoteBody(target, 'EDITED_CHEATSHEET') + await useStore.getState().persistNote(target) + await flush() + + expect(vault.get(target)).toBe('EDITED_CHEATSHEET') + for (const n of INITIAL) { + if (n.path === target) continue + expect(vault.get(n.path), `${n.path} was clobbered by an unrelated edit`).toBe(n.body) + } + expect(writeCalls.every((c) => c.path === target)).toBe(true) + }) + + it('an external watcher change (the ~/sync daemon) lands under the right path', async () => { + const { useStore } = await loadStore() + seedRootVault(useStore) + const paneId = useStore.getState().activePaneId + + const a = 'Work/Documentation/Vault CLI Cheatsheet.md' + const b = 'Work/Documentation/Another Note.md' + await useStore.getState().openNoteInPane(paneId, a) + await useStore.getState().openNoteInPane(paneId, b) + await flush() + + // Sync daemon rewrites A on disk while B is the active tab. + vault.set(a, 'SYNC_REWROTE_CHEATSHEET') + await useStore.getState().applyChange({ kind: 'change', path: a, folder: 'inbox', scope: 'content' }) + await flush() + + const contents = useStore.getState().noteContents + expect(contents[a]?.body).toBe('SYNC_REWROTE_CHEATSHEET') + expect(contents[b]?.body).toBe('ANOTHER_BODY') // untouched + // The external change must not have provoked a write-back. + expect(writeCalls).toEqual([]) + }) +}) diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts index 3605a1ac..05e24ff7 100644 --- a/packages/app-core/src/store.test.ts +++ b/packages/app-core/src/store.test.ts @@ -33,6 +33,7 @@ function makeNote(body: string) { size: body.length, tags: [], wikilinks: [], + assetEmbeds: [], hasAttachments: false, excerpt: body, body @@ -138,6 +139,220 @@ describe('tasks cache freshness', () => { }) }) +describe('daily note patterns', () => { + it('creates daily notes using the configured directory and title patterns', async () => { + const created = { + ...makeNote(''), + path: 'inbox/2026/06-Jun/2026-06-09-Tue.md', + title: '2026-06-09-Tue' + } + const createNote = vi.fn().mockResolvedValue(created) + installZen({ + createNote, + listNotes: vi.fn().mockResolvedValue([created]), + readNote: vi.fn().mockResolvedValue({ ...created, body: '' }) + }) + + const { useStore } = await loadStore() + useStore.setState({ + notes: [], + customTemplates: [], + vaultSettings: { + primaryNotesLocation: 'inbox', + dailyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: 'yyyy-MM-dd-EEE', + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } + }) + + await useStore.getState().openDailyNoteForDate(new Date(2026, 5, 9)) + + expect(createNote).toHaveBeenCalledWith('inbox', '2026-06-09-Tue', '2026/06-Jun') + }) +}) + +describe('weekly note patterns', () => { + it('creates weekly notes using the configured directory and title patterns', async () => { + const created = { + ...makeNote(''), + path: 'inbox/2026/06-Jun/2026-W24-Mon.md', + title: '2026-W24-Mon' + } + const createNote = vi.fn().mockResolvedValue(created) + installZen({ + createNote, + listNotes: vi.fn().mockResolvedValue([created]), + readNote: vi.fn().mockResolvedValue({ ...created, body: '' }) + }) + + const { useStore } = await loadStore() + useStore.setState({ + notes: [], + customTemplates: [], + vaultSettings: { + primaryNotesLocation: 'inbox', + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: 'yyyy/MM-MMM', + titlePattern: "yyyy-'W'ww-EEE", + locale: 'en-US' + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } + }) + + await useStore.getState().openWeeklyNoteForDate(new Date(2026, 5, 9)) + + expect(createNote).toHaveBeenCalledWith('inbox', '2026-W24-Mon', '2026/06-Jun') + }) +}) + +describe('date note pattern history', () => { + it('keeps existing daily notes dynamic when the daily pattern changes', async () => { + const oldSettings = { + primaryNotesLocation: 'inbox' as const, + dailyNotes: { + enabled: true, + directory: 'Daily Notes', + titlePattern: 'yyyy-MM-dd', + locale: 'en-US' + }, + weeklyNotes: { enabled: false, directory: 'Weekly Notes' }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } + const nextSettings = { + ...oldSettings, + dailyNotes: { + ...oldSettings.dailyNotes, + directory: 'yyyy/MM-MMM', + titlePattern: 'yyyy-MM-dd-EEE' + } + } + const existing = { + ...makeNote('daily'), + path: 'inbox/Daily Notes/2026-06-12.md', + title: '2026-06-12', + body: '# 2026-06-12\n' + } + const setVaultSettings = vi.fn().mockImplementation(async (settings) => settings) + const moveNote = vi.fn() + const renameNote = vi.fn() + const createNote = vi.fn() + installZen({ + setVaultSettings, + moveNote, + renameNote, + createNote, + listNotes: vi.fn().mockResolvedValue([existing]), + readNote: vi.fn().mockResolvedValue(existing) + }) + + const { useStore } = await loadStore() + useStore.setState({ + notes: [existing], + vaultSettings: oldSettings + }) + + await useStore.getState().setVaultSettings(nextSettings) + await useStore.getState().openDailyNoteForDate(new Date(2026, 5, 12)) + + expect(setVaultSettings).toHaveBeenCalledWith( + expect.objectContaining({ + dailyNotes: expect.objectContaining({ + directory: 'yyyy/MM-MMM', + titlePattern: 'yyyy-MM-dd-EEE', + legacyPatterns: [ + { directory: 'Daily Notes', titlePattern: 'yyyy-MM-dd', locale: 'en-US' } + ] + }) + }) + ) + expect(moveNote).not.toHaveBeenCalled() + expect(renameNote).not.toHaveBeenCalled() + expect(createNote).not.toHaveBeenCalled() + expect(useStore.getState().selectedPath).toBe(existing.path) + }) + + it('keeps existing weekly notes dynamic when the weekly pattern changes', async () => { + const oldSettings = { + primaryNotesLocation: 'inbox' as const, + dailyNotes: { enabled: false, directory: 'Daily Notes' }, + weeklyNotes: { + enabled: true, + directory: 'Weekly Notes', + titlePattern: "yyyy-'W'ww", + locale: 'en-US' + }, + folderIcons: {}, + folderColors: {}, + favorites: [] + } + const nextSettings = { + ...oldSettings, + weeklyNotes: { + ...oldSettings.weeklyNotes, + directory: 'yyyy/MM-MMM', + titlePattern: "yyyy-'W'ww-EEE" + } + } + const existing = { + ...makeNote('weekly'), + path: 'inbox/Weekly Notes/2026-W24.md', + title: '2026-W24', + body: '# 2026-W24\n' + } + const setVaultSettings = vi.fn().mockImplementation(async (settings) => settings) + const moveNote = vi.fn() + const renameNote = vi.fn() + const createNote = vi.fn() + installZen({ + setVaultSettings, + moveNote, + renameNote, + createNote, + listNotes: vi.fn().mockResolvedValue([existing]), + readNote: vi.fn().mockResolvedValue(existing) + }) + + const { useStore } = await loadStore() + useStore.setState({ + notes: [existing], + vaultSettings: oldSettings + }) + + await useStore.getState().setVaultSettings(nextSettings) + await useStore.getState().openWeeklyNoteForDate(new Date(2026, 5, 12)) + + expect(setVaultSettings).toHaveBeenCalledWith( + expect.objectContaining({ + weeklyNotes: expect.objectContaining({ + directory: 'yyyy/MM-MMM', + titlePattern: "yyyy-'W'ww-EEE", + legacyPatterns: [ + { directory: 'Weekly Notes', titlePattern: "yyyy-'W'ww", locale: 'en-US' } + ] + }) + }) + ) + expect(moveNote).not.toHaveBeenCalled() + expect(renameNote).not.toHaveBeenCalled() + expect(createNote).not.toHaveBeenCalled() + expect(useStore.getState().selectedPath).toBe(existing.path) + }) +}) + describe('local vault shortcuts', () => { it('stores known local vaults for the sidebar switcher', async () => { const localVaults = [ @@ -185,6 +400,53 @@ describe('local vault shortcuts', () => { expect(useStore.getState().hasAssetsDir).toBe(true) }) + it('switches from a remote workspace to a local vault that shares the server path', async () => { + // A localhost server reports the served folder's real on-disk path as the + // vault root, so the current remote vault.root can equal a local vault's + // path. Switching back to local must not be blocked by that coincidence. + const sharedRoot = '/Users/test/Shared' + const openLocalVault = vi + .fn() + .mockResolvedValue({ root: sharedRoot, name: 'Shared' }) + installZen({ + openLocalVault, + getRemoteWorkspaceInfo: vi.fn().mockResolvedValue(null), + getVaultSettings: vi.fn().mockResolvedValue({}), + listLocalVaults: vi.fn().mockResolvedValue([]), + listAssets: vi.fn().mockResolvedValue([]), + hasAssetsDir: vi.fn().mockResolvedValue(false) + }) + + const { useStore } = await loadStore() + useStore.setState({ + vault: { root: sharedRoot, name: 'Shared' }, + workspaceMode: 'remote' + }) + + await useStore.getState().openLocalVault(sharedRoot) + + expect(openLocalVault).toHaveBeenCalledWith(sharedRoot) + expect(useStore.getState().workspaceMode).toBe('local') + expect(useStore.getState().vault).toEqual({ root: sharedRoot, name: 'Shared' }) + }) + + it('ignores reopening the current local vault', async () => { + const openLocalVault = vi + .fn() + .mockResolvedValue({ root: '/Users/test/Notes', name: 'Notes' }) + installZen({ openLocalVault }) + + const { useStore } = await loadStore() + useStore.setState({ + vault: { root: '/Users/test/Notes', name: 'Notes' }, + workspaceMode: 'local' + }) + + await useStore.getState().openLocalVault('/Users/test/Notes') + + expect(openLocalVault).not.toHaveBeenCalled() + }) + it('closes the current local vault and clears workspace state', async () => { const closeVault = vi.fn().mockResolvedValue(null) const listLocalVaults = vi.fn().mockResolvedValue([]) @@ -486,3 +748,84 @@ describe('note jump history with database tabs', () => { expect(useStore.getState().selectedPath).toBe(dbTab) }) }) + +describe('database deletion', () => { + it('forgets a deleted database instead of re-reading the gone file', async () => { + const csvPath = 'quick/Untitled Database.csv' + const doc = { path: csvPath, title: 'Untitled Database', rows: [], columns: [], views: [] } + const openDatabase = vi.fn().mockResolvedValue(doc) + installZen({ openDatabase }) + + const { useStore } = await loadStore() + const paneId = useStore.getState().activePaneId + const tabPath = databaseTabPath(csvPath) + + // Open the database: caches the doc and opens its tab. + await useStore.getState().openDatabase(csvPath) + expect(useStore.getState().databases[csvPath]).toBeTruthy() + expect(findLeaf(useStore.getState().paneLayout, paneId)?.tabs).toContain(tabPath) + + openDatabase.mockClear() + + // The watcher reports the .csv was deleted (the user removed the database). + await useStore.getState().applyChange({ + kind: 'unlink', + path: csvPath, + folder: 'quick', + scope: 'database' + }) + + // It must NOT re-read the gone file (that throws "Database not found" and + // logs an Electron handler error in the terminal). + expect(openDatabase).not.toHaveBeenCalled() + // The cached doc is dropped and its tab is closed. + expect(useStore.getState().databases[csvPath]).toBeUndefined() + expect(findLeaf(useStore.getState().paneLayout, paneId)?.tabs ?? []).not.toContain(tabPath) + }) + + it('still re-reads on a non-delete database change', async () => { + const csvPath = 'quick/Live.csv' + const v1 = { path: csvPath, title: 'Live', rows: [], columns: [], views: [] } + const v2 = { ...v1, rows: [{ id: 'r1' }] } + const openDatabase = vi.fn().mockResolvedValueOnce(v1).mockResolvedValue(v2) + installZen({ openDatabase }) + + const { useStore } = await loadStore() + await useStore.getState().openDatabase(csvPath) + openDatabase.mockClear() + + // A change (not a delete) more than the write-echo window ago re-syncs. + await useStore.getState().applyChange({ + kind: 'change', + path: csvPath, + folder: 'quick', + scope: 'database' + }) + + expect(openDatabase).toHaveBeenCalledWith(csvPath) + expect(useStore.getState().databases[csvPath]).toBeTruthy() + }) + + it('closes a stale tab when the database no longer exists (open returns null)', async () => { + // Simulates a database tab restored on startup after its .csv was deleted: + // DatabaseView calls loadDatabase, the main process returns null (no throw), + // and the renderer forgets the tab instead of looping on a missing file. + const csvPath = 'inbox/Gone.csv' + const openDatabase = vi.fn().mockResolvedValue(null) + installZen({ openDatabase }) + + const { useStore } = await loadStore() + const paneId = useStore.getState().activePaneId + const tabPath = databaseTabPath(csvPath) + + // Restore the tab directly (as the persisted layout would on launch). + await useStore.getState().openNoteInPane(paneId, tabPath) + expect(findLeaf(useStore.getState().paneLayout, paneId)?.tabs).toContain(tabPath) + + // DatabaseView's effect fires this when it has no cached doc. + await useStore.getState().loadDatabase(csvPath) + + expect(useStore.getState().databases[csvPath]).toBeUndefined() + expect(findLeaf(useStore.getState().paneLayout, paneId)?.tabs ?? []).not.toContain(tabPath) + }) +}) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 052fb93e..63f70296 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3,6 +3,7 @@ import type { EditorView } from '@codemirror/view' import { DEFAULT_VAULT_SETTINGS } from '@shared/ipc' import type { AssetMeta, + DateNotePatternSettings, DeletedAsset, FolderEntry, LocalVaultEntry, @@ -22,10 +23,12 @@ import type { WorkspaceMode } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' -import { TASKS_TAB_PATH, isTasksTabPath } from '@shared/tasks' +import { isExcalidrawPath } from '@shared/excalidraw' +import { TASKS_TAB_PATH, isTasksTabPath, parseTasksFromBody } from '@shared/tasks' import type { DatabaseDoc, DatabaseSidecar } from '@shared/databases' import { databaseTabPath, + formTitleFromCsvPath, isDatabaseInternalPath, isDatabaseTabPath, isDatabaseCsvPath @@ -36,14 +39,19 @@ import { TAGS_TAB_PATH, isTagsTabPath } from '@shared/tags' import { HELP_TAB_PATH, isHelpTabPath } from '@shared/help' import { ARCHIVE_TAB_PATH, isArchiveTabPath } from '@shared/archive' import { TRASH_TAB_PATH, isTrashTabPath } from '@shared/trash' +import { ASSETS_VIEW_TAB_PATH, isAssetsViewTabPath } from '@shared/assets-view' import { QUICK_NOTES_TAB_PATH, isQuickNotesTabPath } from '@shared/quick-notes' -import { isAssetTabPath, assetPathFromTab } from './lib/asset-tabs' +import { isAssetTabPath, assetPathFromTab, assetTabPath } from './lib/asset-tabs' import { FENCE_RE, TASK_LINE_RE, + extractUncheckedTaskBlocks, + removeTaskAtIndex, + takeTaskLineAtIndex, setTaskCheckedAtIndex, setTaskDueAtIndex, setTaskPriorityAtIndex, + setTaskTextAtIndex, setTaskWaitingAtIndex, toggleTaskAtIndex, type TaskPriority as TaskLinePriority @@ -51,6 +59,7 @@ import { import { DEFAULT_THEME_ID, THEMES, type ThemeFamily, type ThemeMode } from './lib/themes' import { formatMarkdown } from './lib/format-markdown' import { confirmMoveToTrash } from './lib/confirm-trash' +import { confirmApp } from './lib/confirm-requests' import { pickServerDirectoryApp } from './lib/server-directory-picker-requests' import { promptApp } from './lib/prompt-requests' import { @@ -71,16 +80,25 @@ import { workspaceRestorePrefetchContentPaths } from './lib/workspace-tabs' import { + classifyDateNote, + duplicateFolderColors, duplicateFolderIcons, + dailyNoteLocationForDate, folderForVaultRelativePath, + findDailyNoteForDate, + findWeeklyNoteForDate, + noteTitleForDate, isPrimaryNotesAtRoot, - normalizeDailyNotesDirectory, - normalizeWeeklyNotesDirectory, + removeFavoritesForFolder, + removeFolderColors, removeFolderIcons, normalizeVaultSettings, noteFolderSubpath, - noteTitleForDate, - weeklyNoteTitle, + rewriteFavoriteNotePath, + rewriteFavoritesForFolderRename, + toggleFavorite as toggleFavoriteKey, + weeklyNoteLocationForDate, + rewriteFolderColorsForRename, rewriteFolderIconsForRename } from './lib/vault-layout' import { renderTemplate, renderTitle } from './lib/template-render' @@ -198,7 +216,12 @@ async function listNotesFromBridge(): Promise<NoteMeta[]> { async function refreshVaultIndexes(): Promise<void> { const state = useStore.getState() - await Promise.all([state.refreshNotes(), state.refreshAssets(), state.loadCustomTemplates()]) + await Promise.all([ + state.refreshNotes(), + state.refreshAssets(), + state.loadCustomTemplates(), + state.refreshRootContentHidden() + ]) } /** Find a template (built-in or custom) by id, or undefined if it's gone. */ @@ -263,6 +286,9 @@ interface Prefs { /** Key sequence that exits insert mode (maps to <Esc>), e.g. "jk". * Empty disables it. */ vimInsertEscape: string + /** When true, Vim yank/delete/change also copy to the system clipboard + * (like `set clipboard=unnamed`). */ + vimYankToClipboard: boolean keymapOverrides: KeymapOverrides /** When true, pressing the leader key shows the next available Vim-style actions. */ whichKeyHints: boolean @@ -277,6 +303,9 @@ interface Prefs { /** Optional explicit binary path for fzf. Blank uses PATH lookup. */ fzfBinaryPath: string | null livePreview: boolean // hide markdown syntax on inactive lines + /** Auto-close markdown delimiters while typing: `**`+Space → `**|**`, + * ```` ``` ````+Enter expands a fenced block. Off restores plain typing. */ + markdownSnippets: boolean hideBuiltinTemplates: boolean // hide shipped built-in templates from the pickers tabsEnabled: boolean wrapTabs: boolean @@ -379,6 +408,7 @@ export type TaskMutation = | { kind: 'set-waiting'; waiting: boolean } | { kind: 'set-priority'; priority: TaskLinePriority | null } | { kind: 'set-due'; due: string | null } + | { kind: 'set-text'; text: string } type AssetUndoEntry = { kind: 'delete-asset'; deleted: DeletedAsset; createdAt: number } @@ -409,6 +439,7 @@ function normalizeKanbanColumnTitles(raw: unknown): Record<string, string> { const DEFAULT_PREFS: Prefs = { vimMode: true, vimInsertEscape: '', + vimYankToClipboard: false, keymapOverrides: {}, whichKeyHints: true, whichKeyHintMode: 'timed', @@ -417,6 +448,7 @@ const DEFAULT_PREFS: Prefs = { ripgrepBinaryPath: null, fzfBinaryPath: null, livePreview: true, + markdownSnippets: true, hideBuiltinTemplates: false, tabsEnabled: true, wrapTabs: false, @@ -487,6 +519,10 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { typeof p.vimInsertEscape === 'string' ? p.vimInsertEscape.trim().slice(0, 5) : DEFAULT_PREFS.vimInsertEscape, + vimYankToClipboard: + typeof p.vimYankToClipboard === 'boolean' + ? p.vimYankToClipboard + : DEFAULT_PREFS.vimYankToClipboard, keymapOverrides: normalizeKeymapOverrides(p.keymapOverrides), whichKeyHints: typeof p.whichKeyHints === 'boolean' @@ -515,6 +551,10 @@ function normalizePrefs(p: Partial<Prefs>): Prefs { : DEFAULT_PREFS.fzfBinaryPath, livePreview: typeof p.livePreview === 'boolean' ? p.livePreview : DEFAULT_PREFS.livePreview, + markdownSnippets: + typeof p.markdownSnippets === 'boolean' + ? p.markdownSnippets + : DEFAULT_PREFS.markdownSnippets, hideBuiltinTemplates: typeof p.hideBuiltinTemplates === 'boolean' ? p.hideBuiltinTemplates @@ -900,6 +940,39 @@ function resolveTaskLineNumber(body: string, task: VaultTask): number { return task.lineNumber } +/** Parse a `YYYY-MM-DD` string to a local-midnight Date, or null if malformed. */ +function parseIsoDateLocal(iso: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) + if (!m) return null + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + return Number.isNaN(d.getTime()) ? null : d +} + +// Per-vault "we already rolled over today" marker, persisted in localStorage so +// opening today's daily note across sessions doesn't re-scan past notes once +// it's done for the day. Keyed by vault root so multiple vaults don't collide. +function rolloverMarkerKey(root: string): string { + return `zen.tasks.rollover.${root || 'default'}` +} +function readRolloverMarker(root: string): string | null { + try { + return typeof localStorage !== 'undefined' + ? localStorage.getItem(rolloverMarkerKey(root)) + : null + } catch { + return null + } +} +function writeRolloverMarker(root: string, iso: string): void { + try { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(rolloverMarkerKey(root), iso) + } + } catch { + // localStorage may be unavailable (private mode); the in-session flow still works. + } +} + function applyTaskMutationsToTask(task: VaultTask, mutations: TaskMutation[]): VaultTask { let next = task for (const m of mutations) { @@ -920,6 +993,11 @@ function applyTaskMutationsToTask(task: VaultTask, mutations: TaskMutation[]): V if (next.due !== due) next = { ...next, due } break } + case 'set-text': { + const content = m.text.trim() + if (next.content !== content) next = { ...next, content } + break + } } } return next @@ -998,8 +1076,9 @@ async function rewriteTagAcrossVault( const { notes, activeNote } = get() const escaped = oldTag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Match `#tag` preceded by start/whitespace and followed by a non - // tag-character or end-of-string, keeping the leading separator. - const pattern = new RegExp(`(^|\\s)#${escaped}(?=[^\\w\\-/]|$)`, 'gm') + // tag-character or end-of-string, keeping the leading separator. The + // boundary excludes any Unicode letter so Cyrillic/CJK tags rename too (#205). + const pattern = new RegExp(`(^|\\s)#${escaped}(?=[^\\p{L}\\d_/-]|$)`, 'gmu') const rewriteBody = (src: string): string => { // Preserve code fences and inline code exactly. Split the body @@ -1057,6 +1136,7 @@ async function rewriteTagAcrossVault( function collectPrefs(s: { vimMode: boolean vimInsertEscape: string + vimYankToClipboard: boolean keymapOverrides: KeymapOverrides whichKeyHints: boolean whichKeyHintMode: WhichKeyHintMode @@ -1065,6 +1145,7 @@ function collectPrefs(s: { ripgrepBinaryPath: string | null fzfBinaryPath: string | null livePreview: boolean + markdownSnippets: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -1114,6 +1195,7 @@ function collectPrefs(s: { return { vimMode: s.vimMode, vimInsertEscape: s.vimInsertEscape, + vimYankToClipboard: s.vimYankToClipboard, keymapOverrides: s.keymapOverrides, whichKeyHints: s.whichKeyHints, whichKeyHintMode: s.whichKeyHintMode, @@ -1122,6 +1204,7 @@ function collectPrefs(s: { ripgrepBinaryPath: s.ripgrepBinaryPath, fzfBinaryPath: s.fzfBinaryPath, livePreview: s.livePreview, + markdownSnippets: s.markdownSnippets, hideBuiltinTemplates: s.hideBuiltinTemplates, tabsEnabled: s.tabsEnabled, wrapTabs: s.wrapTabs, @@ -1408,6 +1491,15 @@ export function isArchiveViewActive(state: { return leaf?.activeTab === ARCHIVE_TAB_PATH } +/** True when the active pane's active tab is the built-in Assets view. */ +export function isAssetsViewActive(state: { + paneLayout: PaneLayout + activePaneId: string +}): boolean { + const leaf = findLeaf(state.paneLayout, state.activePaneId) + return leaf?.activeTab === ASSETS_VIEW_TAB_PATH +} + /** True when the active pane's active tab is the built-in Quick Notes view. */ export function isQuickNotesViewActive(state: { paneLayout: PaneLayout @@ -1425,6 +1517,8 @@ interface Store { localVaults: LocalVaultEntry[] workspaceSetupError: string | null vaultSettings: VaultSettings + /** Vault is in `inbox` mode but its root holds notes only `root` mode shows. */ + rootContentHiddenByInboxMode: boolean notes: NoteMeta[] folders: FolderEntry[] assetFiles: AssetMeta[] @@ -1464,6 +1558,8 @@ interface Store { vimMode: boolean /** Key sequence that exits insert mode (maps to <Esc>), e.g. "jk". Persisted. */ vimInsertEscape: string + /** When true, Vim yank/delete/change also copy to the system clipboard. Persisted. */ + vimYankToClipboard: boolean keymapOverrides: KeymapOverrides whichKeyHints: boolean whichKeyHintMode: WhichKeyHintMode @@ -1472,6 +1568,8 @@ interface Store { ripgrepBinaryPath: string | null fzfBinaryPath: string | null livePreview: boolean + /** Auto-close markdown delimiters while typing. Persisted. */ + markdownSnippets: boolean hideBuiltinTemplates: boolean tabsEnabled: boolean wrapTabs: boolean @@ -1611,6 +1709,15 @@ interface Store { setVault: (v: VaultInfo | null) => void setVaultSettings: (next: VaultSettings) => Promise<void> + /** + * Toggle a favorite (a note path or a `folder:subpath` key) and persist it. + * Favorites pin to the top of the sidebar. + */ + toggleFavorite: (key: string) => Promise<void> + /** Toggle favorite for the active editor note (Vim leader command). */ + toggleFavoriteActiveNote: () => Promise<void> + /** @internal Replace the favorites list and persist (no note refresh). */ + applyFavorites: (nextFavorites: string[]) => Promise<void> setNotes: (notes: NoteMeta[]) => void setView: (view: View) => void /** Open the Tasks panel as a tab in the active pane. If the tab is @@ -1630,6 +1737,7 @@ interface Store { openQuickNotesView: () => Promise<void> /** Open the built-in Archive tab in the active pane. */ openArchiveView: () => Promise<void> + openAssetsView: () => Promise<void> /** Open the built-in Trash tab in the active pane. */ openTrashView: () => Promise<void> /** Read a CSV database (CSV + sidecar) into `databases` if not already loaded. */ @@ -1638,12 +1746,16 @@ interface Store { openDatabase: (csvPath: string) => Promise<void> /** Create a new empty database under `folder`/`subpath` and open it. */ createDatabase: (folder: NoteFolder, subpath?: string, title?: string) => Promise<void> + /** Rename a database (its `.base` folder); rehomes the open grid tab. */ + renameDatabase: (csvPath: string, newTitle: string) => Promise<void> /** Optimistically replace a database's rows and debounce-persist the CSV. */ updateDatabaseRows: (csvPath: string, next: DatabaseDoc) => void /** Optimistically replace a database's schema/views and debounce-persist sidecar + CSV. */ updateDatabaseSchema: (csvPath: string, next: DatabaseDoc) => void /** Re-read a database from disk after an external change (skips our own write echoes). */ syncDatabaseFromDisk: (csvPath: string) => Promise<void> + /** Drop a deleted database's cached doc and close its tab (no disk read). */ + forgetDatabase: (csvPath: string) => Promise<void> /** Open a record as a markdown "page" note (creating + linking it on first open). */ openRecordPage: (csvPath: string, rowId: string) => Promise<void> /** Rename a record's linked page note to match its title (no-op if unlinked). */ @@ -1672,6 +1784,12 @@ interface Store { task: VaultTask, mutation: TaskMutation | TaskMutation[] ) => Promise<void> + /** Delete a task's line from its note (the right-click "Delete" action). */ + deleteTaskFromList: (task: VaultTask) => Promise<void> + /** Physically move a task's line into the daily note for `dateIso`, + * removing it from its current note. Falls back to setting the due date + * when daily notes are disabled or it already lives in that day's note. */ + moveTaskToDate: (task: VaultTask, dateIso: string) => Promise<void> setTasksFilter: (q: string) => void setTasksViewMode: (mode: TasksViewMode) => void setKanbanGroupBy: (group: KanbanGroupBy) => void @@ -1697,6 +1815,7 @@ interface Store { jumpToNextNote: () => Promise<void> applyChange: (ev: VaultChangeEvent) => Promise<void> refreshNotes: () => Promise<void> + refreshRootContentHidden: () => Promise<void> refreshAssets: () => Promise<void> deleteAsset: (relPath: string) => Promise<void> undoLastAssetAction: () => Promise<boolean> @@ -1710,6 +1829,7 @@ interface Store { subpath?: string, options?: { focusTitle?: boolean; title?: string } ) => Promise<void> + createDrawingAndOpen: (folder: NoteFolder, subpath?: string) => Promise<void> /** * Create a note after asking where to put it: a destination prompt that * defaults to `initialPath` (empty = vault root), so the user can press Enter @@ -1731,6 +1851,7 @@ interface Store { archiveActive: () => Promise<void> unarchiveActive: () => Promise<void> exportActiveNotePdf: () => Promise<void> + copyActiveNoteAsMarkdown: () => Promise<void> setSearchOpen: (open: boolean) => void setVaultTextSearchOpen: (open: boolean) => void setCommandPaletteOpen: (open: boolean, mode?: CommandPaletteInitialMode) => void @@ -1742,6 +1863,7 @@ interface Store { setFocusMode: (focus: boolean) => void setVimMode: (on: boolean) => void setVimInsertEscape: (sequence: string) => void + setVimYankToClipboard: (on: boolean) => void setKeymapBinding: (id: KeymapId, binding: string | null) => void resetAllKeymaps: () => void setWhichKeyHints: (on: boolean) => void @@ -1751,6 +1873,7 @@ interface Store { setRipgrepBinaryPath: (path: string | null) => void setFzfBinaryPath: (path: string | null) => void setLivePreview: (on: boolean) => void + setMarkdownSnippets: (on: boolean) => void setHideBuiltinTemplates: (hidden: boolean) => void setTabsEnabled: (on: boolean) => void setWrapTabs: (on: boolean) => void @@ -1831,6 +1954,20 @@ interface Store { setCalendarShowWeekNumbers: (show: boolean) => void openDailyNoteForDate: (date: Date) => Promise<void> openWeeklyNoteForDate: (date: Date) => Promise<void> + /** Find the daily note for `date`, creating it on disk (template-aware) + * WITHOUT navigating to it. Returns its meta, or null if daily notes are + * disabled or creation failed. */ + ensureDailyNoteForDate: (date: Date) => Promise<NoteMeta | null> + /** Append a `- [ ] …` task to the daily note for `dateIso` (YYYY-MM-DD), + * prompting to create that daily note first if it doesn't exist. */ + addTaskForDate: (dateIso: string, text: string) => Promise<void> + /** Move unfinished tasks from past daily notes into today's note. Returns the + * number of task lines moved. Without `force`, it is gated by the + * `rolloverUnfinishedTasks` setting and a once-per-day marker. */ + rolloverUnfinishedTasksIntoToday: (opts?: { + force?: boolean + open?: boolean + }) => Promise<number> /** Mark the first-run onboarding as complete (or skipped). Persists. */ completeOnboarding: () => void /** Re-open the first-run onboarding wizard. Persists. */ @@ -2001,6 +2138,36 @@ function scheduleDatabaseWrite( ) } +/** + * The database table is the source of truth for a record's properties; the + * record-page note's frontmatter is a derived "metadata" mirror. Whenever the + * table changes (a cell value, an added/renamed/removed field), re-mirror the + * frontmatter of any record page that's currently open so it updates live — + * preserving the page's body. Pages that aren't open are re-mirrored lazily the + * next time they're opened (see `openRecordPage`). + */ +function remirrorOpenRecordPages( + csvPath: string, + get: () => { + databases: Record<string, DatabaseDoc> + noteContents: Record<string, NoteContent> + updateNoteBody: (path: string, body: string) => void + } +): void { + const doc = get().databases[csvPath] + if (!doc?.pages) return + const { noteContents } = get() + for (const [rowId, pagePath] of Object.entries(doc.pages)) { + const current = noteContents[pagePath] + if (!current) continue // not open — re-mirrored on next open + const row = doc.rows.find((r) => r.id === rowId) + if (!row) continue + const { body } = parseFrontmatter(current.body) + const next = composePageBody(doc, row, body) + if (next !== current.body) get().updateNoteBody(pagePath, next) + } +} + function normalizeServerBaseUrl(value: string): string { const trimmed = value.trim() const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}` @@ -2146,6 +2313,87 @@ function renameNoteState( } } +const MAX_DATE_NOTE_PATTERN_HISTORY = 20 + +function dateNotePatternKey(pattern: DateNotePatternSettings): string { + return `${pattern.directory}\0${pattern.titlePattern ?? ''}\0${pattern.locale ?? ''}` +} + +function currentDailyPatternFromSettings(settings: VaultSettings): DateNotePatternSettings { + return { + directory: settings.dailyNotes.directory, + titlePattern: settings.dailyNotes.titlePattern, + locale: settings.dailyNotes.locale + } +} + +function currentWeeklyPatternFromSettings(settings: VaultSettings): DateNotePatternSettings { + return { + directory: settings.weeklyNotes.directory, + titlePattern: settings.weeklyNotes.titlePattern, + locale: settings.weeklyNotes.locale + } +} + +function appendDateNotePatternHistory( + history: readonly DateNotePatternSettings[] | undefined, + previous: DateNotePatternSettings, + current: DateNotePatternSettings +): DateNotePatternSettings[] { + const out: DateNotePatternSettings[] = [] + const seen = new Set([dateNotePatternKey(current)]) + for (const pattern of [previous, ...(history ?? [])]) { + const key = dateNotePatternKey(pattern) + if (seen.has(key)) continue + seen.add(key) + out.push(pattern) + if (out.length >= MAX_DATE_NOTE_PATTERN_HISTORY) break + } + return out +} + +function withDateNotePatternHistory( + previousSettings: VaultSettings, + requestedSettings: VaultSettings +): VaultSettings { + const previous = normalizeVaultSettings(previousSettings) + const next = normalizeVaultSettings(requestedSettings) + const previousDaily = currentDailyPatternFromSettings(previous) + const nextDaily = currentDailyPatternFromSettings(next) + const previousWeekly = currentWeeklyPatternFromSettings(previous) + const nextWeekly = currentWeeklyPatternFromSettings(next) + + return { + ...next, + dailyNotes: { + ...next.dailyNotes, + legacyPatterns: + previous.dailyNotes.enabled && + next.dailyNotes.enabled && + dateNotePatternKey(previousDaily) !== dateNotePatternKey(nextDaily) + ? appendDateNotePatternHistory( + next.dailyNotes.legacyPatterns, + previousDaily, + nextDaily + ) + : next.dailyNotes.legacyPatterns + }, + weeklyNotes: { + ...next.weeklyNotes, + legacyPatterns: + previous.weeklyNotes.enabled && + next.weeklyNotes.enabled && + dateNotePatternKey(previousWeekly) !== dateNotePatternKey(nextWeekly) + ? appendDateNotePatternHistory( + next.weeklyNotes.legacyPatterns, + previousWeekly, + nextWeekly + ) + : next.weeklyNotes.legacyPatterns + } + } +} + function rewriteNoteCommentsPath( comments: Record<string, NoteComment[]>, oldPath: string, @@ -2654,6 +2902,7 @@ export const useStore = create<Store>((set, get) => { localVaults: [], workspaceSetupError: null, vaultSettings: DEFAULT_VAULT_SETTINGS, + rootContentHiddenByInboxMode: false, notes: [], folders: [], assetFiles: [], @@ -2686,6 +2935,7 @@ export const useStore = create<Store>((set, get) => { zenRestoreState: null, vimMode: loadPrefs().vimMode, vimInsertEscape: loadPrefs().vimInsertEscape, + vimYankToClipboard: loadPrefs().vimYankToClipboard, keymapOverrides: loadPrefs().keymapOverrides, whichKeyHints: loadPrefs().whichKeyHints, whichKeyHintMode: loadPrefs().whichKeyHintMode, @@ -2694,6 +2944,7 @@ export const useStore = create<Store>((set, get) => { ripgrepBinaryPath: loadPrefs().ripgrepBinaryPath, fzfBinaryPath: loadPrefs().fzfBinaryPath, livePreview: loadPrefs().livePreview, + markdownSnippets: loadPrefs().markdownSnippets, hideBuiltinTemplates: loadPrefs().hideBuiltinTemplates, tabsEnabled: loadPrefs().tabsEnabled, wrapTabs: loadPrefs().wrapTabs, @@ -2773,15 +3024,57 @@ export const useStore = create<Store>((set, get) => { }), setVaultSettings: async (next) => { try { - const settings = normalizeVaultSettings(await window.zen.setVaultSettings(next)) + const settingsToSave = withDateNotePatternHistory(get().vaultSettings, next) + const settings = normalizeVaultSettings(await window.zen.setVaultSettings(settingsToSave)) set({ vaultSettings: settings }) await get().refreshNotes() + await get().refreshRootContentHidden() } catch (err) { console.error('setVaultSettings failed', err) } }, + applyFavorites: async (nextFavorites) => { + const current = get().vaultSettings + if ( + current.favorites.length === nextFavorites.length && + current.favorites.every((f, i) => f === nextFavorites[i]) + ) { + return // unchanged — skip the disk write + } + // Favorites don't affect note listing, so update optimistically and persist + // without a full refreshNotes. + set({ vaultSettings: { ...current, favorites: nextFavorites } }) + try { + const saved = normalizeVaultSettings( + await window.zen.setVaultSettings({ ...get().vaultSettings, favorites: nextFavorites }) + ) + set({ vaultSettings: saved }) + } catch (err) { + console.error('applyFavorites failed', err) + set({ vaultSettings: current }) // revert on failure + } + }, + toggleFavorite: async (key) => { + if (!key) return + await get().applyFavorites(toggleFavoriteKey(get().vaultSettings.favorites, key)) + }, + toggleFavoriteActiveNote: async () => { + const path = get().activeNote?.path ?? get().selectedPath + if (!path) return + await get().toggleFavorite(path) + }, + refreshRootContentHidden: async () => { + try { + const hidden = await window.zen.rootContentHiddenByInboxMode() + if (get().rootContentHiddenByInboxMode !== hidden) { + set({ rootContentHiddenByInboxMode: hidden }) + } + } catch { + // Non-fatal: the banner is advisory; keep the previous value on error. + } + }, setNotes: (notes) => set({ notes }), setView: (view) => { set({ @@ -2885,20 +3178,42 @@ export const useStore = create<Store>((set, get) => { ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) }, + + openAssetsView: async () => { + const state = get() + // Refresh both: assets for the list, notes for fresh assetEmbeds (usage). + await Promise.all([get().refreshAssets(), get().refreshNotes()]) + await get().openNoteInPane(state.activePaneId, ASSETS_VIEW_TAB_PATH) + ;(document.activeElement as HTMLElement | null)?.blur?.() + set({ focusedPanel: 'editor' }) + }, loadDatabase: async (csvPath) => { if (get().databasesLoading[csvPath]) return set((s) => ({ databasesLoading: { ...s.databasesLoading, [csvPath]: true } })) try { const doc = await window.zen.openDatabase(csvPath) + if (!doc) { + // The .csv is gone — drop it and close any stale tab rather than leave + // a grid pointed at a deleted file (and re-requesting it on every render). + await get().forgetDatabase(csvPath) + return + } set((s) => ({ databases: { ...s.databases, [csvPath]: doc } })) } catch (err) { console.error('loadDatabase failed', err) } finally { - set((s) => ({ databasesLoading: { ...s.databasesLoading, [csvPath]: false } })) + set((s) => + csvPath in s.databasesLoading + ? { databasesLoading: { ...s.databasesLoading, [csvPath]: false } } + : {} + ) } }, openDatabase: async (csvPath) => { await get().loadDatabase(csvPath) + // The load may have failed/forgotten a now-missing database — don't open an + // empty tab for it. + if (!get().databases[csvPath]) return await get().openNoteInPane(get().activePaneId, databaseTabPath(csvPath)) ;(document.activeElement as HTMLElement | null)?.blur?.() set({ focusedPanel: 'editor' }) @@ -2914,13 +3229,56 @@ export const useStore = create<Store>((set, get) => { console.error('createDatabase failed', err) } }, + renameDatabase: async (csvPath, newTitle) => { + if (typeof window.zen.renameDatabase !== 'function') return + try { + const newCsvPath = await window.zen.renameDatabase(csvPath, newTitle) + if (!newCsvPath || newCsvPath === csvPath) { + await get().refreshNotes() + return + } + // The `.base` folder moved, so the open grid tab's path changed. Rehome it + // in place (and the cached doc) instead of leaving a stale tab. + const oldTab = databaseTabPath(csvPath) + const newTab = databaseTabPath(newCsvPath) + set((s) => { + const rewrite = (p: string): string => (p === oldTab ? newTab : p) + const ensured = ensureActivePane(rewritePathsInTree(s.paneLayout, rewrite), s.activePaneId) + const databases = { ...s.databases } + const loading = { ...s.databasesLoading } + const prev = databases[csvPath] + if (prev) { + databases[newCsvPath] = { + ...prev, + path: newCsvPath, + title: formTitleFromCsvPath(newCsvPath) + } + delete databases[csvPath] + } + delete loading[csvPath] + return { + paneLayout: ensured.layout, + activePaneId: ensured.activePaneId, + databases, + databasesLoading: loading, + ...activeFieldsFrom(ensured.layout, ensured.activePaneId, s.noteContents, s.noteDirty) + } + }) + await get().refreshNotes() + } catch (err) { + console.error('renameDatabase failed', err) + window.alert(err instanceof Error ? err.message : String(err)) + } + }, updateDatabaseRows: (csvPath, next) => { set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) scheduleDatabaseWrite(csvPath, 'rows', () => get().databases[csvPath]) + remirrorOpenRecordPages(csvPath, get) }, updateDatabaseSchema: (csvPath, next) => { set((s) => ({ databases: { ...s.databases, [csvPath]: next } })) scheduleDatabaseWrite(csvPath, 'schema', () => get().databases[csvPath]) + remirrorOpenRecordPages(csvPath, get) }, syncDatabaseFromDisk: async (csvPath) => { if (!get().databases[csvPath]) return @@ -2930,11 +3288,37 @@ export const useStore = create<Store>((set, get) => { if (databaseSaveTimers.has(csvPath)) return try { const doc = await window.zen.openDatabase(csvPath) + if (!doc) { + await get().forgetDatabase(csvPath) + return + } set((s) => (s.databases[csvPath] ? { databases: { ...s.databases, [csvPath]: doc } } : {})) } catch (err) { console.error('syncDatabaseFromDisk failed', err) } }, + forgetDatabase: async (csvPath) => { + // Close the database's tab in every pane that holds it. A .csv can be open + // either as a `zen://database/…` tab or as a `.csv` asset tab that renders + // the same grid, so close both forms. + const tabPaths = [databaseTabPath(csvPath), assetTabPath(csvPath)] + for (const leaf of allLeaves(get().paneLayout)) { + for (const tabPath of tabPaths) { + if (leaf.tabs.includes(tabPath)) { + await get().closeTabInPane(leaf.id, tabPath) + } + } + } + // Drop the cached doc + loading flag so nothing re-reads a file that's gone. + set((s) => { + if (!(csvPath in s.databases) && !(csvPath in s.databasesLoading)) return {} + const databases = { ...s.databases } + const databasesLoading = { ...s.databasesLoading } + delete databases[csvPath] + delete databasesLoading[csvPath] + return { databases, databasesLoading } + }) + }, openRecordPage: async (csvPath, rowId) => { const doc = get().databases[csvPath] if (!doc) return @@ -3165,6 +3549,9 @@ export const useStore = create<Store>((set, get) => { case 'set-due': nextBody = setTaskDueAtIndex(nextBody, task.taskIndex, m.due) break + case 'set-text': + nextBody = setTaskTextAtIndex(nextBody, task.taskIndex, m.text) + break } } if (nextBody === body) { @@ -3185,6 +3572,119 @@ export const useStore = create<Store>((set, get) => { } }, + deleteTaskFromList: async (task) => { + const path = task.sourcePath + const openBuffer = get().noteContents[path] + let body: string + try { + body = openBuffer?.body ?? (await window.zen.readNote(path)).body + } catch (err) { + console.error('deleteTaskFromList readNote failed', err) + return + } + const nextBody = removeTaskAtIndex(body, task.taskIndex) + if (nextBody === body) return + // Optimistically drop it from the index so the row vanishes immediately. + set((s) => ({ + vaultTasks: s.vaultTasks.filter( + (t) => !(t.sourcePath === path && t.taskIndex === task.taskIndex) + ) + })) + if (openBuffer) { + get().updateNoteBody(path, nextBody) + } else { + try { + await window.zen.writeNote(path, nextBody) + await get().rescanTasksForPath(path) + } catch (err) { + console.error('deleteTaskFromList writeNote failed', err) + void get().rescanTasksForPath(path) + } + } + }, + + moveTaskToDate: async (task, dateIso) => { + const parsed = parseIsoDateLocal(dateIso) + if (!parsed) return + const settings = normalizeVaultSettings(get().vaultSettings) + // No daily notes to move into — just set the due date instead. + if (!settings.dailyNotes.enabled) { + await get().applyTaskMutation(task, { kind: 'set-due', due: dateIso }) + return + } + const target = await get().ensureDailyNoteForDate(parsed) + if (!target) return + const inferDue = settings.dailyNotes.tasksDueOnNoteDate + // Already in that day's note — nothing to relocate; just align its due. + if (target.path === task.sourcePath) { + await get().applyTaskMutation(task, { kind: 'set-due', due: inferDue ? null : dateIso }) + return + } + + const srcBuffer = get().noteContents[task.sourcePath] + const tgtBuffer = get().noteContents[target.path] + let srcBody: string + let tgtBody: string + try { + srcBody = srcBuffer?.body ?? (await window.zen.readNote(task.sourcePath)).body + tgtBody = tgtBuffer?.body ?? (await window.zen.readNote(target.path)).body + } catch (err) { + console.error('moveTaskToDate read failed', err) + return + } + const { line, body: strippedSrc } = takeTaskLineAtIndex(srcBody, task.taskIndex) + if (!line) return + // Moving INTO the target day's note: with implicit due on, a bare line + // already reads as that day, so strip any `due:` token; otherwise write the + // explicit date. + const movedLine = setTaskDueAtIndex(line, 0, inferDue ? null : dateIso) + const trimmed = tgtBody.replace(/\s+$/u, '') + const nextTgt = trimmed.length ? `${trimmed}\n${movedLine}\n` : `${movedLine}\n` + + // Persist both notes (open buffers go through the edit pipeline). + if (srcBuffer) get().updateNoteBody(task.sourcePath, strippedSrc) + else { + try { + await window.zen.writeNote(task.sourcePath, strippedSrc) + } catch (err) { + console.error('moveTaskToDate write source failed', err) + return + } + } + if (tgtBuffer) get().updateNoteBody(target.path, nextTgt) + else { + try { + await window.zen.writeNote(target.path, nextTgt) + } catch (err) { + console.error('moveTaskToDate write target failed', err) + return + } + } + + // Rebuild the index for the two affected notes with a client-side parse — + // authoritative (same parser the scanner uses) and independent of the + // single-file IPC rescanner, so the move shows immediately. + const srcTasks = parseTasksFromBody(strippedSrc, { + path: task.sourcePath, + title: task.noteTitle, + folder: task.noteFolder + }) + const tgtTasks = parseTasksFromBody(nextTgt, { + path: target.path, + title: target.title, + folder: target.folder + }) + set((s) => ({ + vaultTasks: [ + ...s.vaultTasks.filter( + (t) => t.sourcePath !== task.sourcePath && t.sourcePath !== target.path + ), + ...srcTasks, + ...tgtTasks + ] + })) + }, + setTasksFilter: (q) => set({ tasksFilter: q, taskCursorIndex: 0 }), setTasksViewMode: (mode) => { set({ tasksViewMode: mode, taskCursorIndex: 0 }) @@ -3457,7 +3957,13 @@ export const useStore = create<Store>((set, get) => { return } if (ev.scope === 'database') { - await get().syncDatabaseFromDisk(ev.path) + // On delete, forget the database instead of re-reading a file that's gone + // (which throws "Database not found"); otherwise sync from disk. + if (ev.kind === 'unlink') { + await get().forgetDatabase(ev.path) + } else { + await get().syncDatabaseFromDisk(ev.path) + } // Surface a newly-created (or removed) .csv in the note list. if (ev.kind !== 'change') await get().refreshAssets() return @@ -3469,8 +3975,11 @@ export const useStore = create<Store>((set, get) => { await get().refreshNotes() return } - const pathIsMarkdown = ev.path.toLowerCase().endsWith('.md') - if (ev.scope !== 'vault-settings' && !pathIsMarkdown) { + // Excalidraw drawings are notes (they live in the notes tree), so treat + // their change events as note events, not asset events. + const pathIsNote = + ev.path.toLowerCase().endsWith('.md') || isExcalidrawPath(ev.path) + if (ev.scope !== 'vault-settings' && !pathIsNote) { await get().refreshAssets() return } @@ -3747,6 +4256,9 @@ export const useStore = create<Store>((set, get) => { try { const meta = await window.zen.renameNote(oldPath, nextTitle) set((s) => renameNoteState(s, oldPath, meta)) + await get().applyFavorites( + rewriteFavoriteNotePath(get().vaultSettings.favorites, oldPath, meta.path) + ) await get().refreshNotes() } catch (err) { console.error('renameNote failed', err) @@ -3773,6 +4285,17 @@ export const useStore = create<Store>((set, get) => { } }, + createDrawingAndOpen: async (folder, subpath = '') => { + try { + const meta = await window.zen.createExcalidraw(folder, subpath) + await get().refreshNotes() + set({ view: { kind: 'folder', folder, subpath } }) + await get().selectNote(meta.path) + } catch (err) { + console.error('createExcalidraw failed', err) + } + }, + createNoteInChosenFolder: async (opts) => { const state = get() const entered = await promptApp( @@ -3965,6 +4488,21 @@ export const useStore = create<Store>((set, get) => { } }, + copyActiveNoteAsMarkdown: async () => { + const s = get() + const active = s.activeNote + if (!active) return + let body = s.noteContents[active.path]?.body + if (body == null) { + try { + body = (await window.zen.readNote(active.path)).body + } catch { + return + } + } + window.zen.clipboardWriteText(body) + }, + setSearchOpen: (open) => set({ searchOpen: open, @@ -4022,6 +4560,10 @@ export const useStore = create<Store>((set, get) => { set({ vimInsertEscape: sequence.trim().slice(0, 5) }) savePrefs(collectPrefs(get())) }, + setVimYankToClipboard: (on) => { + set({ vimYankToClipboard: on }) + savePrefs(collectPrefs(get())) + }, setKeymapBinding: (id, binding) => { set((s) => { const nextOverrides = { ...s.keymapOverrides } @@ -4063,6 +4605,10 @@ export const useStore = create<Store>((set, get) => { set({ livePreview: on }) savePrefs(collectPrefs(get())) }, + setMarkdownSnippets: (on) => { + set({ markdownSnippets: on }) + savePrefs(collectPrefs(get())) + }, setHideBuiltinTemplates: (hidden) => { set({ hideBuiltinTemplates: hidden }) savePrefs(collectPrefs(get())) @@ -4347,43 +4893,190 @@ export const useStore = create<Store>((set, get) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.dailyNotes.enabled) return - const title = noteTitleForDate(date) - const subpath = normalizeDailyNotesDirectory(settings.dailyNotes.directory) - const existing = state.notes.find( - (note) => - note.folder === 'inbox' && - note.title === title && - noteFolderSubpath(note, settings) === subpath - ) + const { title, subpath } = dailyNoteLocationForDate(date, settings) + const existing = findDailyNoteForDate(state.notes, settings, date) if (existing) { set({ view: { kind: 'folder', folder: 'inbox', subpath } }) await get().selectNote(existing.path) - return + } else { + const template = resolveTemplate(state.customTemplates, settings.dailyNotes.templateId) + if (template) { + await get().createFromTemplate(template, { folder: 'inbox', subpath, title, date }) + } else { + await get().createAndOpen('inbox', subpath, { title }) + } } - const template = resolveTemplate(state.customTemplates, settings.dailyNotes.templateId) - if (template) { - await get().createFromTemplate(template, { folder: 'inbox', subpath, title, date }) - return + // Opening *today's* note rolls unfinished tasks forward from past daily + // notes (Obsidian-style) when enabled. Fire-and-forget so the note shows + // right away; the rollover appends into the now-open buffer. + if (noteTitleForDate(date) === noteTitleForDate(new Date())) { + void get().rolloverUnfinishedTasksIntoToday() } - await get().createAndOpen('inbox', subpath, { title }) }, openTodayDailyNote: async () => { await get().openDailyNoteForDate(new Date()) }, + ensureDailyNoteForDate: async (date) => { + const state = get() + const settings = normalizeVaultSettings(state.vaultSettings) + if (!settings.dailyNotes.enabled) return null + const existing = findDailyNoteForDate(state.notes, settings, date) + if (existing) return existing + const { title, subpath } = dailyNoteLocationForDate(date, settings) + const template = resolveTemplate(state.customTemplates, settings.dailyNotes.templateId) + const body = template ? renderTemplate(template.body, { title, now: date }).body : '' + try { + const meta = await window.zen.createNote('inbox', title, subpath) + if (body) await window.zen.writeNote(meta.path, body) + await get().refreshNotes() + return get().notes.find((n) => n.path === meta.path) ?? meta + } catch (err) { + console.error('ensureDailyNoteForDate failed', err) + return null + } + }, + + addTaskForDate: async (dateIso, text) => { + const content = text.trim() + if (!content) return + const parsed = parseIsoDateLocal(dateIso) + if (!parsed) return + const settings = normalizeVaultSettings(get().vaultSettings) + if (!settings.dailyNotes.enabled) return + let note = findDailyNoteForDate(get().notes, settings, parsed) + if (!note) { + const ok = await confirmApp({ + title: 'Create daily note?', + description: `No daily note exists for ${dateIso} yet. Create it and add this task?`, + confirmLabel: 'Create & add' + }) + if (!ok) return + note = await get().ensureDailyNoteForDate(parsed) + if (!note) return + } + const path = note.path + // Implicit due already covers daily-note tasks; only write an explicit + // `due:` token when inference is off, so the task still lands on this day. + const line = settings.dailyNotes.tasksDueOnNoteDate + ? `- [ ] ${content}` + : `- [ ] ${content} due:${dateIso}` + const openBuffer = get().noteContents[path] + const body = openBuffer?.body ?? (await window.zen.readNote(path)).body + const trimmed = body.replace(/\s+$/u, '') + const nextBody = trimmed.length ? `${trimmed}\n${line}\n` : `${line}\n` + if (openBuffer) { + // Open note: edit through the buffer so unsaved changes aren't stomped; + // its autosave + the watcher rescan the tasks (a disk rescan now would be + // stale). The common add-from-calendar case hits the writeNote branch. + get().updateNoteBody(path, nextBody) + } else { + try { + await window.zen.writeNote(path, nextBody) + await get().rescanTasksForPath(path) + } catch (err) { + console.error('addTaskForDate writeNote failed', err) + } + } + }, + + rolloverUnfinishedTasksIntoToday: async (opts) => { + const force = opts?.force === true + const settings = normalizeVaultSettings(get().vaultSettings) + if (!settings.dailyNotes.enabled) return 0 + const today = new Date() + const todayIso = noteTitleForDate(today) + const vaultRoot = get().vault?.root ?? '' + if (!force) { + if (!settings.dailyNotes.rolloverUnfinishedTasks) return 0 + if (readRolloverMarker(vaultRoot) === todayIso) return 0 + } + const todayNote = await get().ensureDailyNoteForDate(today) + if (!todayNote) return 0 + if (opts?.open) { + const { subpath } = dailyNoteLocationForDate(today, settings) + set({ view: { kind: 'folder', folder: 'inbox', subpath } }) + await get().selectNote(todayNote.path) + } + + // Gather unfinished task blocks from every *past* daily note, oldest first. + const pastNotes: Array<{ note: NoteMeta; iso: string }> = [] + for (const note of get().notes) { + if (note.path === todayNote.path) continue + const info = classifyDateNote(note, settings) + if (info?.kind !== 'daily') continue + const iso = noteTitleForDate(info.date) + if (iso < todayIso) pastNotes.push({ note, iso }) + } + pastNotes.sort((a, b) => (a.iso < b.iso ? -1 : a.iso > b.iso ? 1 : 0)) + + const movedLines: string[] = [] + for (const { note } of pastNotes) { + const buffer = get().noteContents[note.path] + let body: string + try { + body = buffer?.body ?? (await window.zen.readNote(note.path)).body + } catch (err) { + console.error('rollover readNote failed', note.path, err) + continue + } + const { moved, rest } = extractUncheckedTaskBlocks(body) + if (moved.length === 0) continue + movedLines.push(...moved) + if (buffer) { + // Open buffer: route through the normal edit pipeline (marks dirty, + // autosaves, watcher rescans tasks) — same as toggleTaskFromList. A disk + // rescan here would read the not-yet-flushed file and go stale. + get().updateNoteBody(note.path, rest) + } else { + try { + await window.zen.writeNote(note.path, rest) + await get().rescanTasksForPath(note.path) + } catch (err) { + console.error('rollover writeNote (source) failed', note.path, err) + // Don't drop the lines we already pulled — they'll still land in today. + } + } + } + + if (movedLines.length === 0) { + writeRolloverMarker(vaultRoot, todayIso) + return 0 + } + + const todayBuffer = get().noteContents[todayNote.path] + let todayBody: string + try { + todayBody = todayBuffer?.body ?? (await window.zen.readNote(todayNote.path)).body + } catch (err) { + console.error('rollover readNote (today) failed', err) + return 0 + } + const trimmed = todayBody.replace(/\s+$/u, '') + const block = movedLines.join('\n') + const nextBody = trimmed.length ? `${trimmed}\n${block}\n` : `${block}\n` + if (todayBuffer) { + get().updateNoteBody(todayNote.path, nextBody) + } else { + try { + await window.zen.writeNote(todayNote.path, nextBody) + await get().rescanTasksForPath(todayNote.path) + } catch (err) { + console.error('rollover writeNote (today) failed', err) + return 0 + } + } + writeRolloverMarker(vaultRoot, todayIso) + return movedLines.length + }, + openWeeklyNoteForDate: async (date) => { const state = get() const settings = normalizeVaultSettings(state.vaultSettings) if (!settings.weeklyNotes.enabled) return - const title = weeklyNoteTitle(date) - const subpath = normalizeWeeklyNotesDirectory(settings.weeklyNotes.directory) - const existing = state.notes.find( - (note) => - note.folder === 'inbox' && - note.title === title && - noteFolderSubpath(note, settings) === subpath - ) + const { title, subpath } = weeklyNoteLocationForDate(date, settings) + const existing = findWeeklyNoteForDate(state.notes, settings, date) if (existing) { set({ view: { kind: 'folder', folder: 'inbox', subpath } }) await get().selectNote(existing.path) @@ -5073,6 +5766,12 @@ export const useStore = create<Store>((set, get) => { oldSubpath, newSubpath ) + const nextFolderColors = rewriteFolderColorsForRename( + get().vaultSettings.folderColors, + folder, + oldSubpath, + newSubpath + ) set((s) => { const nextLayout = rewritePathsInTree(s.paneLayout, rewritePath) const ensured = ensureActivePane(nextLayout, s.activePaneId) @@ -5098,12 +5797,26 @@ export const useStore = create<Store>((set, get) => { pinnedRefPath: s.pinnedRefPath ? rewritePath(s.pinnedRefPath) : null, vaultSettings: { ...s.vaultSettings, - folderIcons: nextFolderIcons + folderIcons: nextFolderIcons, + folderColors: nextFolderColors }, ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } }) + // Repoint favorites at the renamed folder (its own key, descendant folder + // keys, and note favorites that lived under it) and persist. + await get().applyFavorites( + rewriteFavoritesForFolderRename( + get().vaultSettings.favorites, + folder, + oldSubpath, + newSubpath, + oldPrefix, + newPrefix + ) + ) + await get().refreshNotes() const v = get().view @@ -5130,6 +5843,7 @@ export const useStore = create<Store>((set, get) => { } const prefix = `${folder}/${subpath}/` const nextFolderIcons = removeFolderIcons(get().vaultSettings.folderIcons, folder, subpath) + const nextFolderColors = removeFolderColors(get().vaultSettings.folderColors, folder, subpath) set((s) => { const nextLayout = rewritePathsInTree(s.paneLayout, (p) => p.startsWith(prefix) ? null : p @@ -5153,11 +5867,16 @@ export const useStore = create<Store>((set, get) => { s.pinnedRefPath && s.pinnedRefPath.startsWith(prefix) ? null : s.pinnedRefPath, vaultSettings: { ...s.vaultSettings, - folderIcons: nextFolderIcons + folderIcons: nextFolderIcons, + folderColors: nextFolderColors }, ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } }) + // Drop favorites for the deleted folder and the notes that lived under it. + await get().applyFavorites( + removeFavoritesForFolder(get().vaultSettings.favorites, folder, subpath, prefix) + ) }, duplicateFolder: async (folder, subpath) => { @@ -5172,6 +5891,12 @@ export const useStore = create<Store>((set, get) => { folder, subpath, newSubpath + ), + folderColors: duplicateFolderColors( + s.vaultSettings.folderColors, + folder, + subpath, + newSubpath ) } })) @@ -5219,6 +5944,9 @@ export const useStore = create<Store>((set, get) => { ...activeFieldsFrom(ensured.layout, ensured.activePaneId, contents, dirty) } }) + await get().applyFavorites( + rewriteFavoriteNotePath(get().vaultSettings.favorites, relPath, meta.path) + ) } catch (err) { console.error('moveNote failed', err) } @@ -5446,7 +6174,12 @@ export const useStore = create<Store>((set, get) => { openLocalVault: async (root: string) => { const trimmed = root.trim() - if (!trimmed || trimmed === get().vault?.root) return + if (!trimmed) return + // Only a no-op when we are already in this exact local vault. In remote + // mode vault.root holds the server-reported path, which for a localhost + // server equals the local vault's own path -- comparing against it here + // would wrongly block switching back from remote to local. + if (get().workspaceMode === 'local' && trimmed === get().vault?.root) return try { await get().flushDirtyNotes() set({ workspaceSetupError: null }) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index d466bddc..177860c3 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -1741,19 +1741,44 @@ textarea, color: rgb(var(--z-fg)); } -/* Leading YAML frontmatter — render as compact, muted "properties" (see - * cm-frontmatter.ts) instead of full-size body text. */ +/* Leading YAML frontmatter — render as a compact "properties" metadata card + * (see cm-frontmatter.ts). For database record pages these values mirror the + * database fields, so it should read like a tidy property list, not body text. */ .cm-editor .cm-frontmatter-line { - font-size: 0.8em; - line-height: 1.55; + font-size: 0.84em; + line-height: 1.85; color: rgb(var(--z-grey-1)); - background: rgb(var(--z-bg-1) / 0.35); + background: rgb(var(--z-bg-1) / 0.5); + padding-left: 0.85em; + padding-right: 0.85em; + border-left: 1px solid rgb(var(--z-grey-0) / 0.13); + border-right: 1px solid rgb(var(--z-grey-0) / 0.13); } .cm-editor .cm-frontmatter-line :is(.tok-meta, .tok-string, .tok-keyword, .tok-atom) { color: inherit; } -.cm-editor .cm-frontmatter-fence { - color: rgb(var(--z-grey-0) / 0.5); +/* The key (before the `:`) is a muted label; the value keeps the normal color. */ +.cm-editor .cm-frontmatter-key, +.cm-editor .cm-frontmatter-key :is(.tok-meta, .tok-string, .tok-keyword, .tok-atom) { + color: rgb(var(--z-grey-0) / 0.62); + font-weight: 500; +} +/* The `---` fences become the card's quiet, rounded top/bottom edges: hide the + * dashes (transparent) but keep a thin capped row. */ +.cm-editor .cm-frontmatter-top, +.cm-editor .cm-frontmatter-bottom { + color: transparent; + font-size: 0.5em; +} +.cm-editor .cm-frontmatter-top { + border-top: 1px solid rgb(var(--z-grey-0) / 0.13); + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +.cm-editor .cm-frontmatter-bottom { + border-bottom: 1px solid rgb(var(--z-grey-0) / 0.13); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; } .cm-editor .tok-heading, .cm-editor .tok-heading1, @@ -1797,6 +1822,9 @@ textarea, .cm-editor .tok-strong { font-weight: 700; } +.cm-editor .tok-strikethrough { + text-decoration: line-through; +} .cm-editor .tok-quote { font-style: italic; } @@ -2094,6 +2122,41 @@ textarea, line-height: var(--z-editor-line-height, 1.7); color: theme("colors.ink.900"); } +/* Compact Markdown inside comment cards (CommentsPanel): reuse .prose-zen + element styling but drop the full-note container padding/width and scale + headings + block spacing down to fit a narrow, small-text comment. */ +.prose-zen.comment-prose { + width: 100%; + max-width: none; + margin: 0; + padding: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} +.prose-zen.comment-prose > :first-child { + margin-top: 0; +} +.prose-zen.comment-prose > :last-child { + margin-bottom: 0; +} +.prose-zen.comment-prose h1, +.prose-zen.comment-prose h2, +.prose-zen.comment-prose h3, +.prose-zen.comment-prose h4, +.prose-zen.comment-prose h5, +.prose-zen.comment-prose h6 { + font-size: 1.05em; + margin-top: 0.6em; + margin-bottom: 0.2em; + line-height: 1.3; +} +.prose-zen.comment-prose h1 { + font-size: 1.2em; +} +.prose-zen.comment-prose h2 { + font-size: 1.12em; +} .prose-zen h1, .prose-zen h2, .prose-zen h3, @@ -3039,7 +3102,7 @@ textarea, color: rgb(var(--z-ink-900)); } .vim-cursor-on-active { - box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.5); + box-shadow: inset 0 0 0 2px rgb(var(--z-accent) / 0.65); } .vim-cursor-on-selected { box-shadow: inset 0 0 0 2px rgb(var(--z-accent) / 0.65); @@ -3383,3 +3446,509 @@ textarea, .prose-zen .prose-heading-fold-arrow.is-folded { color: rgb(var(--z-accent)); } + +/* ===================================================================== + WYSIWYG live-preview rendering — ported from PR #185 (author: songgnqing). + Rendered tables, blockquote bars, list bullets, horizontal rules, + fenced-code cards + language flair, hashtag chips, and wikilinks. + Block-card styling is gated by the .cm-wysiwyg wrapper, added to the + editor container when livePreview is on (see EditorPane). + ===================================================================== */ +/* The inline-code chip (background/padding/radius from `.tok-monospace`) is for + true inline code only. Inside fenced/indented blocks the Lezer markdown + grammar tags every token with the same `monospace` highlight tag, so each + token would otherwise get its own chip — peppering the block with ragged, + per-line gray boxes. Neutralize it here; the block card (WYSIWYG) or raw view + provides the background. Mirrors the preview's `.prose-zen pre code`. */ +.cm-editor .cm-code-block-line .tok-monospace { + background: transparent; + padding: 0; + border-radius: 0; +} + +/* --- WYSIWYG code-block card + language flair (Edit mode only) -------- */ +/* Give fenced code blocks a continuous "card": a shared background with side + borders, the first/last line rounding the top/bottom. Mirrors Obsidian's + HyperMD-codeblock-bg, scoped to Edit (WYSIWYG) — Split stays raw. */ +.cm-wysiwyg .cm-code-block-line { + position: relative; + background: rgb(var(--z-bg-2) / 0.55); + border-left: 1px solid rgb(var(--z-bg-3) / 0.7); + border-right: 1px solid rgb(var(--z-bg-3) / 0.7); + padding-left: 16px; + padding-right: 16px; +} +.cm-wysiwyg .cm-code-block-begin { + border-top: 1px solid rgb(var(--z-bg-3) / 0.7); + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +.cm-wysiwyg .cm-code-block-end { + border-bottom: 1px solid rgb(var(--z-bg-3) / 0.7); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} +/* Language label pinned top-right of the block; clicking it copies the code, + so there is no separate copy or fold control. */ +.cm-wysiwyg .cm-code-flair { + position: absolute; + top: 3px; + right: 8px; + z-index: 2; + padding: 1px 7px; + border: none; + border-radius: 6px; + background: transparent; + font-family: var(--z-interface-font, system-ui, sans-serif); + font-size: 11px; + line-height: 1.5; + color: rgb(var(--z-grey-1)); + cursor: pointer; + user-select: none; + transition: background 0.12s ease, color 0.12s ease; +} +.cm-wysiwyg .cm-code-flair:hover { + background: rgb(var(--z-bg-3) / 0.85); + color: rgb(var(--z-fg)); +} +.cm-wysiwyg .cm-code-flair.is-copied { + color: rgb(var(--z-accent)); +} + +/* --- WYSIWYG live table widget (Edit mode) --------------------------- */ +/* A GFM pipe table rendered as a real, editable <table>. Mirrors Obsidian's + cm-table-widget: rounded bordered grid, header tint, hover add-buttons, and + in-cell editing. Source stays authoritative; see cm-table.ts. */ +.cm-table-widget { + /* Use PADDING, not vertical margin, for the block gap: CodeMirror measures a + block widget's height via offsetHeight, which excludes margins — a vertical + margin here desyncs the height map and makes clicks below the table land a + line high. The padding also leaves room for the hover grips (top/left) and + add-buttons (right/bottom). + The negative LEFT margin cancels the left padding so the table content + still lines up with the body text (and matches Preview) instead of sitting + ~2 chars indented. Horizontal margin is safe — only vertical margin breaks + the height measurement. */ + margin: 0 0 0 -16px; + padding: 16px; + overflow-x: auto; + white-space: normal; + font-family: var( + --z-text-font, + -apple-system, + BlinkMacSystemFont, + "SF Pro Text", + Inter, + "PingFang SC", + "Microsoft YaHei", + system-ui, + sans-serif + ); + font-size: 0.94em; +} +.cm-table-wrapper { + position: relative; + width: fit-content; + max-width: 100%; +} +.cm-table-widget table { + border-collapse: collapse; + border-spacing: 0; +} +.cm-table-widget th, +.cm-table-widget td { + border: 1px solid rgb(var(--z-bg-3) / 0.9); + padding: 0; + vertical-align: top; + min-width: 64px; + position: relative; +} +.cm-table-widget th { + background: rgb(var(--z-bg-2) / 0.7); + font-weight: 600; +} +.cm-table-widget .cm-table-cell { + padding: 5px 10px; + outline: none; + min-height: 1.5em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + cursor: text; + /* No native text selection in view/normal mode — it renders as the OS color + and CodeMirror mirrors it across cells. Vim visual mode draws its own + themed overlay instead. Editing (contenteditable) re-enables selection. */ + user-select: none; + -webkit-user-select: none; +} +.cm-table-widget .cm-table-cell[contenteditable='true'] { + user-select: text; + -webkit-user-select: text; +} +.cm-table-widget .cm-table-cell:focus { + background: rgb(var(--z-accent) / 0.1); + box-shadow: inset 0 0 0 2px rgb(var(--z-accent) / 0.45); +} +/* Vim NORMAL mode: the cell is selected for navigation, not being typed into. + `caret-shape: block` isn't supported in our Chromium yet, so hide the native + (insert-style) caret and draw a Vim-style block cursor ourselves via ::before. + Editing (i/a) removes this class, restoring the normal thin caret. */ +.cm-table-widget .cm-table-cell.is-vim-normal { + position: relative; + background: rgb(var(--z-accent) / 0.07); +} +/* Vim block cursor — a real cursor positioned over the current character by + cm-table.ts (renderCellCursor), so h/l move it glyph-by-glyph like Vim. */ +.cm-table-widget .cm-table-cell-cursor { + position: absolute; + background: rgb(var(--z-accent) / 0.5); + border-radius: 1px; + pointer-events: none; +} +/* Vim visual-mode selection inside a cell uses the native selection; match the + editor's own selection tint (theme accent at the same strength) so it reads + consistently instead of the washed-out OS default. */ +.cm-table-widget .cm-table-cell::selection, +.cm-table-widget .cm-table-cell ::selection { + background: rgb(var(--z-accent) / 0.22); +} +/* Vim visual-mode selection overlay (cm-table.ts renderCellSelection) — themed + so it matches the editor selection across every theme. */ +.cm-table-widget .cm-table-cell-sel { + position: absolute; + background: rgb(var(--z-accent) / 0.28); + border-radius: 2px; + pointer-events: none; +} +/* While a table cell is focused, the "real" cursor lives in the cell, so hide + the editor's own cursor layer — otherwise its Vim block cursor lingers at the + last document position (e.g. above the table) as a second block cursor. */ +.cm-editor:has(.cm-table-cell:focus) .cm-cursorLayer, +.cm-editor:has(.cm-table-cell:focus) .cm-selectionLayer { + display: none; +} +/* Rendered inline markdown inside idle cells (the source pipeline emits real + <code>/<strong>/<em>/<a> tags, so style them to match the editor chrome). */ +.cm-table-widget .cm-table-cell code { + font-family: var(--z-mono-font, "SF Mono", ui-monospace, monospace); + font-size: 0.92em; + background: rgb(var(--z-bg-2)); + padding: 1px 4px; + border-radius: 4px; +} +.cm-table-widget .cm-table-cell strong { + font-weight: 700; +} +.cm-table-widget .cm-table-cell em { + font-style: italic; +} +.cm-table-widget .cm-table-cell a { + color: rgb(var(--z-accent)); + text-decoration: underline; + text-decoration-color: rgb(var(--z-accent) / 0.4); +} +.cm-table-widget td[align="center"] .cm-table-cell, +.cm-table-widget th[align="center"] .cm-table-cell { + text-align: center; +} +.cm-table-widget td[align="right"] .cm-table-cell, +.cm-table-widget th[align="right"] .cm-table-cell { + text-align: right; +} +/* Hover add-buttons: a thin "+" rail along the right edge (column) and the + bottom edge (row) of the grid. */ +.cm-table-widget .cm-table-add { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgb(var(--z-bg-3) / 0.9); + background: rgb(var(--z-bg-1) / 0.9); + color: rgb(var(--z-grey-1)); + font-size: 13px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity 0.1s ease, background 0.1s ease, color 0.1s ease; + border-radius: 5px; +} +.cm-table-widget:hover .cm-table-add { + opacity: 1; +} +.cm-table-widget .cm-table-add:hover { + background: rgb(var(--z-accent) / 0.85); + color: #fff; + border-color: transparent; +} +.cm-table-widget .cm-table-add-col { + top: 0; + right: 0; + width: 13px; + height: 100%; +} +.cm-table-widget .cm-table-add-row { + left: 0; + bottom: 0; + height: 13px; + /* Span the grid, not the button rail on the right. */ + width: calc(100% - 14px); +} + +/* Drag-to-reorder grips: row grip left of each body row, column grip above + each header cell. Hidden until you hover the row/column. */ +.cm-table-widget .cm-table-row-handle, +.cm-table-widget .cm-table-col-handle { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + background: rgb(var(--z-bg-2)); + border: 1px solid rgb(var(--z-bg-3) / 0.9); + color: rgb(var(--z-grey-1)); + cursor: grab; + opacity: 0; + z-index: 3; + border-radius: 4px; + font-size: 9px; + line-height: 1; + user-select: none; +} +.cm-table-widget .cm-table-row-handle::before { + content: "⠿"; +} +.cm-table-widget .cm-table-col-handle::before { + content: "⠿"; + transform: rotate(90deg); +} +.cm-table-widget .cm-table-row-handle { + right: 100%; + top: 0; + height: 100%; + width: 12px; + border-start-start-radius: 4px; + border-end-start-radius: 4px; +} +.cm-table-widget .cm-table-col-handle { + bottom: 100%; + left: 0; + width: 100%; + height: 12px; +} +.cm-table-widget tbody tr:hover .cm-table-row-handle, +.cm-table-widget thead th:hover .cm-table-col-handle, +.cm-table-widget .cm-table-row-handle:hover, +.cm-table-widget .cm-table-col-handle:hover { + opacity: 1; +} +.cm-table-widget.is-dragging .cm-table-row-handle, +.cm-table-widget.is-dragging .cm-table-col-handle { + opacity: 0; +} +.cm-table-widget .cm-table-row-handle:active, +.cm-table-widget .cm-table-col-handle:active { + cursor: grabbing; + background: rgb(var(--z-accent) / 0.85); + color: #fff; + border-color: transparent; +} +/* Accent drop indicator shown while dragging. */ +.cm-table-widget .cm-table-drop-indicator { + position: absolute; + background: rgb(var(--z-accent)); + z-index: 4; + pointer-events: none; + border-radius: 2px; +} +.cm-table-widget .cm-table-drop-row { + left: 0; + right: 0; + height: 2px; + transform: translateY(-1px); +} +.cm-table-widget .cm-table-drop-col { + top: 0; + bottom: 0; + width: 2px; + transform: translateX(-1px); +} + +/* Table context menu (right-click) — self-contained, theme-aware. */ +.cm-table-menu { + position: fixed; + z-index: 80; + min-width: 190px; + padding: 4px; + border-radius: 10px; + background: rgb(var(--z-bg-softer)); + border: 1px solid rgb(var(--z-bg-3) / 0.7); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32); + font-family: var(--z-interface-font, system-ui, sans-serif); + font-size: 13px; + color: rgb(var(--z-fg)); +} +.cm-table-menu-item { + display: block; + width: 100%; + text-align: left; + padding: 5px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} +.cm-table-menu-item:hover:not(:disabled) { + background: rgb(var(--z-accent) / 0.85); + color: #fff; +} +.cm-table-menu-item:disabled { + opacity: 0.4; + cursor: default; +} +.cm-table-menu-sep { + height: 1px; + margin: 4px 6px; + background: rgb(var(--z-bg-3) / 0.7); +} + +.cm-wysiwyg .cm-editor .cm-wq-quote { + border-left: 3px solid rgb(var(--z-accent) / 0.45); + padding-left: 16px; + color: rgb(var(--z-fg-2)); +} +.cm-wysiwyg .cm-editor .cm-wq-quote .tok-quote { + font-style: normal; + color: inherit; +} + +/* Callout cards in Edit mode — mirrors the Preview `.callout` colors. Every + line of the callout carries `cm-callout` (+ `-head`/`-foot` to round the + ends); the per-group class sets `--callout-color`, used for the box border, + tint, and the bold typed title. */ +.cm-wysiwyg .cm-editor .cm-callout { + --callout-color: var(--z-grey-0); + background: rgb(var(--callout-color) / 0.08); + border-left: 1px solid rgb(var(--callout-color) / 0.35); + border-right: 1px solid rgb(var(--callout-color) / 0.35); + padding-left: 14px; + padding-right: 14px; +} +.cm-wysiwyg .cm-editor .cm-callout-note { + --callout-color: var(--z-blue); +} +.cm-wysiwyg .cm-editor .cm-callout-tip { + --callout-color: var(--z-green); +} +.cm-wysiwyg .cm-editor .cm-callout-warning { + --callout-color: var(--z-yellow); +} +.cm-wysiwyg .cm-editor .cm-callout-danger { + --callout-color: var(--z-red); +} +.cm-wysiwyg .cm-editor .cm-callout-quote { + --callout-color: var(--z-grey-0); +} +.cm-wysiwyg .cm-editor .cm-callout-head { + border-top: 1px solid rgb(var(--callout-color) / 0.35); + border-top-left-radius: 8px; + border-top-right-radius: 8px; + padding-top: 4px; + font-weight: 600; + color: rgb(var(--callout-color)); +} +.cm-wysiwyg .cm-editor .cm-callout-foot { + border-bottom: 1px solid rgb(var(--callout-color) / 0.35); + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + padding-bottom: 4px; +} +.cm-wysiwyg .cm-editor .cm-callout-title { + font-weight: 600; + color: rgb(var(--callout-color)); +} +/* A list line inside a callout/blockquote keeps its hanging indent, but it has + to sit on TOP of the box's own left padding. Otherwise the list's negative + text-indent (see .cm-markdown-list-line) drags the bullet out past the box's + left edge. Re-add the box padding to the hanging indent. */ +.cm-wysiwyg .cm-editor .cm-callout .tok-quote { + font-style: normal; + color: inherit; +} +.cm-wysiwyg .cm-editor .cm-callout.cm-markdown-list-line { + padding-left: calc(14px + var(--z-list-hanging-indent, 0)); +} +.cm-wysiwyg .cm-editor .cm-wq-quote.cm-markdown-list-line { + padding-left: calc(16px + var(--z-list-hanging-indent, 0)); +} + +/* Unordered-list bullet rendered in place of `-` / `*` / `+`. */ +.cm-wysiwyg .cm-editor .cm-wq-bullet { + color: rgb(var(--z-grey-1)); + font-weight: 700; +} + +/* Horizontal rule rendered in place of `---` / `***` / `___`. */ +.cm-wysiwyg .cm-editor .cm-wq-hr { + display: inline-block; + width: 100%; + height: 0; + vertical-align: middle; + border-top: 2px solid rgb(var(--z-bg-3)); +} + +/* Links — subtle underline like Obsidian (accent color is set above). */ +.cm-wysiwyg .cm-editor .tok-link, +.cm-wysiwyg .cm-editor .tok-url { + text-decoration: underline; + text-decoration-color: rgb(var(--z-accent) / 0.4); + text-underline-offset: 2px; + cursor: pointer; +} +.cm-wysiwyg .cm-editor .tok-link:hover, +.cm-wysiwyg .cm-editor .tok-url:hover { + text-decoration-color: rgb(var(--z-accent)); +} + +/* Wikilinks — Obsidian internal-link style: accent underlined text, clickable + (brackets hidden via decorations in cm-wikilink-render.ts). */ +.cm-wysiwyg .cm-editor .cm-wikilink { + color: rgb(var(--z-accent)); + text-decoration: underline; + text-decoration-color: rgb(var(--z-accent) / 0.4); + text-underline-offset: 2px; + cursor: pointer; +} +.cm-wysiwyg .cm-editor .cm-wikilink:hover { + text-decoration-color: rgb(var(--z-accent)); +} +/* Revealed `[[ ]]` / `|` markers when editing a wikilink — quiet grey, upright, + no underline (the inner brackets otherwise inherit the italic/underline link + highlight, which left one bracket slanted). */ +/* Target the bracket span AND any nested highlight span inside it — the inner + `[`/`]` is a LinkMark whose own highlight span sets italic + link color, and + an !important on the parent can't override a child's own declaration. The + `*` reaches that inner span so all four brackets render the same. */ +.cm-wysiwyg .cm-editor .cm-wikilink-bracket, +.cm-wysiwyg .cm-editor .cm-wikilink-bracket * { + color: rgb(var(--z-grey-1)) !important; + font-style: normal !important; + text-decoration: none !important; + opacity: 0.7; +} + +/* Inline hashtags rendered as clickable pills — Obsidian's `.tag` style: + accent text on a faint accent tint, pill radius. */ +.cm-wysiwyg .cm-editor .cm-hashtag { + color: rgb(var(--z-accent)); + background-color: rgb(var(--z-accent) / 0.1); + padding: 0.25em 0.65em; + border-radius: 2em; + font-size: 0.8em; + font-weight: inherit; + line-height: 1; + cursor: pointer; +} +.cm-wysiwyg .cm-editor .cm-hashtag:hover { + background-color: rgb(var(--z-accent) / 0.2); +} diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 711e97ec..6fad0e4e 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.3.0", + "version": "2.4.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index 169d7476..b881cdab 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -110,6 +110,9 @@ export interface ZenBridge { browseServerDirectories(path?: string): Promise<DirectoryBrowseResult> getVaultSettings(): Promise<VaultSettings> setVaultSettings(next: VaultSettings): Promise<VaultSettings> + /** True when the vault is in `inbox` mode but its root holds notes that only + * `root` mode would surface (drives the "Switch to Vault root" banner). */ + rootContentHiddenByInboxMode(): Promise<boolean> listNotes(): Promise<NoteMeta[]> listNotesPage?(request: ListNotesPageRequest): Promise<ListNotesPageResponse> @@ -135,16 +138,21 @@ export interface ZenBridge { writeNoteComments(relPath: string, comments: NoteCommentInput[]): Promise<NoteComment[]> scanTasks(): Promise<VaultTask[]> scanTasksForPath(relPath: string): Promise<VaultTask[]> - openDatabase(relPath: string): Promise<DatabaseDoc> + /** Resolves to null when the `.csv` no longer exists (e.g. a stale tab). */ + openDatabase(relPath: string): Promise<DatabaseDoc | null> writeDatabaseRows(relPath: string, rows: DbRow[]): Promise<DatabaseDoc> writeDatabaseSchema(relPath: string, sidecar: DatabaseSidecar, rows: DbRow[]): Promise<DatabaseDoc> createDatabase(folder: NoteFolder, subpath: string, title?: string): Promise<DatabaseDoc> + /** Rename a database's `.base` folder; resolves to the new `data.csv` path. */ + renameDatabase(csvPath: string, newTitle: string): Promise<string> /** Create a record's "page" note (returns its vault-relative path). */ createRecordPage(csvPath: string, title: string, body: string): Promise<string> listDatabases(): Promise<DatabaseSummary[]> writeNote(relPath: string, body: string): Promise<NoteMeta> appendToNote(relPath: string, body: string, position: 'start' | 'end'): Promise<NoteMeta> createNote(folder: NoteFolder, title?: string, subpath?: string): Promise<NoteMeta> + /** Create a new `.excalidraw` drawing seeded with an empty scene. */ + createExcalidraw(folder: NoteFolder, subpath?: string, title?: string): Promise<NoteMeta> renameNote(relPath: string, nextTitle: string): Promise<NoteMeta> deleteNote(relPath: string): Promise<void> moveToTrash(relPath: string): Promise<NoteMeta> diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 6899638f..f22f44ef 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -18,6 +18,7 @@ export const IPC = { VAULT_GET_CURRENT: 'vault:get-current', VAULT_GET_SETTINGS: 'vault:get-settings', VAULT_SET_SETTINGS: 'vault:set-settings', + VAULT_ROOT_CONTENT_HIDDEN: 'vault:root-content-hidden', VAULT_LIST_NOTES: 'vault:list-notes', VAULT_LIST_NOTES_STREAM: 'vault:list-notes-stream', VAULT_LIST_FOLDERS: 'vault:list-folders', @@ -37,6 +38,7 @@ export const IPC = { VAULT_WRITE_NOTE: 'vault:write-note', VAULT_APPEND_NOTE: 'vault:append-note', VAULT_CREATE_NOTE: 'vault:create-note', + VAULT_CREATE_EXCALIDRAW: 'vault:create-excalidraw', VAULT_RENAME_NOTE: 'vault:rename-note', VAULT_DELETE_NOTE: 'vault:delete-note', VAULT_MOVE_TO_TRASH: 'vault:move-to-trash', @@ -69,6 +71,7 @@ export const IPC = { VAULT_WRITE_DATABASE_ROWS: 'vault:write-database-rows', VAULT_WRITE_DATABASE_SCHEMA: 'vault:write-database-schema', VAULT_CREATE_DATABASE: 'vault:create-database', + VAULT_RENAME_DATABASE: 'vault:rename-database', VAULT_CREATE_RECORD_PAGE: 'vault:create-record-page', VAULT_LIST_DATABASES: 'vault:list-databases', APP_LIST_FONTS: 'app:list-fonts', @@ -245,16 +248,62 @@ export type FolderIconId = | 'chart' | 'home' +/** Preset folder accent colors (tints the folder's sidebar icon). */ +export type FolderColorId = + | 'red' + | 'orange' + | 'amber' + | 'green' + | 'teal' + | 'sky' + | 'blue' + | 'indigo' + | 'violet' + | 'pink' + +export interface DateNotePatternSettings { + /** Directory or date-based directory pattern inside the primary notes area. */ + directory: string + /** Date-based title/filename pattern. */ + titlePattern?: string + /** BCP 47 locale used for localized pattern tokens. `system` = OS/browser locale. */ + locale?: string +} + export interface DailyNotesSettings { enabled: boolean + /** Directory or date-based directory pattern inside the primary notes area. */ directory: string + /** Date-based title/filename pattern for new daily notes. */ + titlePattern?: string + /** BCP 47 locale used for localized pattern tokens. `system` = OS/browser locale. */ + locale?: string + /** Prior patterns used only to recognize existing daily notes after settings changes. */ + legacyPatterns?: DateNotePatternSettings[] /** Template applied to new daily notes. Empty/undefined = blank note. */ templateId?: string + /** + * Treat a task written inside a daily note as due on that note's date, so it + * shows up on the calendar without typing `due:`. The line is left untouched — + * the due date is derived. An explicit `due:` token still wins. Default `true`. + */ + tasksDueOnNoteDate?: boolean + /** + * When today's daily note opens, move every unfinished task from previous + * daily notes into it (Obsidian-style). Off by default. */ + rolloverUnfinishedTasks?: boolean } export interface WeeklyNotesSettings { enabled: boolean + /** Directory or date-based directory pattern inside the primary notes area. */ directory: string + /** Date-based title/filename pattern for new weekly notes. */ + titlePattern?: string + /** BCP 47 locale used for localized pattern tokens. `system` = OS/browser locale. */ + locale?: string + /** Prior patterns used only to recognize existing weekly notes after settings changes. */ + legacyPatterns?: DateNotePatternSettings[] /** Template applied to new weekly notes. Empty/undefined = blank note. */ templateId?: string } @@ -264,22 +313,44 @@ export interface VaultSettings { dailyNotes: DailyNotesSettings weeklyNotes: WeeklyNotesSettings folderIcons: Record<string, FolderIconId> + /** Per-folder accent color, keyed by `folder:subpath` (same key as folderIcons). */ + folderColors: Record<string, FolderColorId> + /** + * Favorited notes and folders, pinned to the top of the sidebar. Each entry is + * either a note's vault-relative path (e.g. `inbox/Idea.md`) or a folder key + * `folder:subpath` (e.g. `inbox:Projects`). Folder keys always contain a `:`; + * note paths never do (`:` is a forbidden filename char), so the two are + * distinguishable. Order is the display order in the Favorites section. + */ + favorites: string[] } export const DEFAULT_DAILY_NOTES_DIRECTORY = 'Daily Notes' +export const DEFAULT_DAILY_NOTE_TITLE_PATTERN = 'yyyy-MM-dd' +export const DEFAULT_DAILY_NOTE_LOCALE = 'system' export const DEFAULT_WEEKLY_NOTES_DIRECTORY = 'Weekly Notes' +export const DEFAULT_WEEKLY_NOTE_TITLE_PATTERN = "yyyy-'W'ww" +export const DEFAULT_WEEKLY_NOTE_LOCALE = 'system' export const DEFAULT_VAULT_SETTINGS: VaultSettings = { primaryNotesLocation: 'inbox', dailyNotes: { enabled: false, - directory: DEFAULT_DAILY_NOTES_DIRECTORY + directory: DEFAULT_DAILY_NOTES_DIRECTORY, + titlePattern: DEFAULT_DAILY_NOTE_TITLE_PATTERN, + locale: DEFAULT_DAILY_NOTE_LOCALE, + tasksDueOnNoteDate: true, + rolloverUnfinishedTasks: false }, weeklyNotes: { enabled: false, - directory: DEFAULT_WEEKLY_NOTES_DIRECTORY + directory: DEFAULT_WEEKLY_NOTES_DIRECTORY, + titlePattern: DEFAULT_WEEKLY_NOTE_TITLE_PATTERN, + locale: DEFAULT_WEEKLY_NOTE_LOCALE }, - folderIcons: {} + folderIcons: {}, + folderColors: {}, + favorites: [] } export interface NoteMeta { @@ -297,6 +368,9 @@ export interface NoteMeta { tags: string[] /** Outbound [[wikilink]] targets (note titles), unique. */ wikilinks: string[] + /** Outbound asset-embed targets (`![[asset]]` / `![](asset)`), unique. Used to + * show which notes use a given asset in the Assets view. */ + assetEmbeds: string[] /** True when the body references at least one local non-text asset * (PDF, image, audio, video, generic file). Surfaced in the sidebar * as a small paperclip hint so attachments are discoverable. */ diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 101b92b6..d6117047 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.3.0", + "version": "2.4.0", "type": "module", "exports": { "./*": "./src/*.ts" @@ -9,7 +9,7 @@ "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "build": "tsc --noEmit -p tsconfig.json", - "test": "echo 'No shared-domain tests yet'", - "test:run": "echo 'No shared-domain tests yet'" + "test": "vitest", + "test:run": "vitest run" } } diff --git a/packages/shared-domain/src/assets-view.ts b/packages/shared-domain/src/assets-view.ts new file mode 100644 index 00000000..f891cd73 --- /dev/null +++ b/packages/shared-domain/src/assets-view.ts @@ -0,0 +1,17 @@ +/** + * Virtual path used to identify the built-in Assets view as a tab in the pane + * layout. Uses the `zen://` scheme so it never collides with a real vault path. + * Distinct from `zen://asset/<path>` (a single asset opened as a tab). + */ +export const ASSETS_VIEW_TAB_PATH = 'zen://assets' + +/** True when `path` points at the built-in Assets view tab. */ +export function isAssetsViewTabPath(path: string | null | undefined): boolean { + return path === ASSETS_VIEW_TAB_PATH +} + +/** + * The canonical top-level folder where the vault keeps its assets (images, + * PDFs, and other attachments), unified so they no longer mix with notes. + */ +export const ASSETS_DIR = 'assets' diff --git a/packages/shared-domain/src/databases.test.ts b/packages/shared-domain/src/databases.test.ts new file mode 100644 index 00000000..e1ea513f --- /dev/null +++ b/packages/shared-domain/src/databases.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + csvPathForFormDir, + databaseCsvPathFor, + databaseSchemaPathFor, + databaseTitleFromTab, + databaseTabPath, + formDirContaining, + formDirFromCsvPath, + formTitleFromCsvPath, + isDatabaseCsvPath, + isDatabaseInternalPath, + isFormDirName, + pagesDirFromCsvPath +} from './databases' + +describe('.base database paths', () => { + it('recognizes a .base folder name', () => { + expect(isFormDirName('Books.base')).toBe(true) + expect(isFormDirName('a/b/Books.base')).toBe(true) + expect(isFormDirName('Books')).toBe(false) + }) + + it('round-trips folder ⇄ data.csv path', () => { + expect(csvPathForFormDir('a/Books.base')).toBe('a/Books.base/data.csv') + expect(formDirFromCsvPath('a/Books.base/data.csv')).toBe('a/Books.base') + expect(formDirFromCsvPath('a/Books.base/other.csv')).toBeNull() + expect(formDirFromCsvPath('a/loose.csv')).toBeNull() + }) + + it('derives schema + pages paths', () => { + expect(databaseSchemaPathFor('a/Books.base/data.csv')).toBe('a/Books.base/schema.json') + expect(pagesDirFromCsvPath('a/Books.base/data.csv')).toBe('a/Books.base/pages') + expect(databaseSchemaPathFor('a/loose.csv')).toBeNull() + }) + + it('derives the title from the folder name', () => { + expect(formTitleFromCsvPath('inbox/Reading List.base/data.csv')).toBe('Reading List') + expect(databaseTitleFromTab(databaseTabPath('inbox/Reading List.base/data.csv'))).toBe( + 'Reading List' + ) + // Legacy loose csv keeps its basename title. + expect(databaseTitleFromTab(databaseTabPath('inbox/Old.csv'))).toBe('Old') + }) + + it('finds the containing .base folder for nested files', () => { + expect(formDirContaining('a/Books.base/pages/r.md')).toBe('a/Books.base') + expect(formDirContaining('a/Books.base/schema.json')).toBe('a/Books.base') + expect(formDirContaining('a/notes/r.md')).toBeNull() + }) + + it('treats .base internals as internal but pages as user-facing', () => { + expect(isDatabaseInternalPath('a/Books.base/data.csv')).toBe(true) + expect(isDatabaseInternalPath('a/Books.base/schema.json')).toBe(true) + expect(isDatabaseInternalPath('a/Books.base/pages/record.md')).toBe(false) + // Legacy. + expect(isDatabaseInternalPath('a/Old.csv.base.json')).toBe(true) + }) + + it('classifies database data files (new + legacy), excluding loose CSV inside a .base', () => { + expect(isDatabaseCsvPath('a/Books.base/data.csv')).toBe(true) + expect(isDatabaseCsvPath('a/Old.csv')).toBe(true) + expect(isDatabaseCsvPath('a/Books.base/extra.csv')).toBe(false) + }) + + it('normalizes any database file back to its data.csv identity', () => { + expect(databaseCsvPathFor('a/Books.base/data.csv')).toBe('a/Books.base/data.csv') + expect(databaseCsvPathFor('a/Books.base/schema.json')).toBe('a/Books.base/data.csv') + expect(databaseCsvPathFor('a/Old.csv')).toBe('a/Old.csv') + expect(databaseCsvPathFor('a/Old.csv.base.json')).toBe('a/Old.csv') + expect(databaseCsvPathFor('a/notes/plain.md')).toBeNull() + }) +}) diff --git a/packages/shared-domain/src/databases.ts b/packages/shared-domain/src/databases.ts index c18ba284..4af30097 100644 --- a/packages/shared-domain/src/databases.ts +++ b/packages/shared-domain/src/databases.ts @@ -4,11 +4,20 @@ * are typed fields. The same data is shown through multiple VIEWS (an editable * Table and a Board grouped by a field). * - * On disk a database is a pair: - * <folder>/<Name>.csv — the data (an `id` UUID column gives rows a - * stable identity across external edits) - * <folder>/<Name>.csv.base.json — the sidecar: field types, select options, - * and view definitions (a CSV can't hold these) + * On disk a database is ONE self-contained folder whose name ends with the + * reserved `.base` suffix (so it never collides with an ordinary folder): + * <Name>.base/data.csv — the data (an `id` UUID column gives rows a + * stable identity across external edits) + * <Name>.base/schema.json — field types, select options, view definitions, + * and the row→page map (a CSV can't hold these) + * <Name>.base/pages/ — the record-page notes (one `.md` per record) + * The database's identity / cache key is its `data.csv` path; its title is the + * folder name minus `.base`. Because everything lives in one folder, rename = + * folder rename and trash/move = folder move (no path rewriting). Record-page + * paths in `schema.json` are stored RELATIVE to the folder for the same reason. + * + * Legacy layout (pre-2.4.0), still recognized so old vaults keep working until + * migrated: a loose `<Name>.csv` + co-located `<Name>.csv.base.json` sidecar. * * This module is PURE (no node/DOM imports) so it is shared by the main process * and the renderer. Cell values are stored as raw CSV strings; the field type @@ -16,11 +25,89 @@ * on load). */ +/** Legacy sidecar suffix (`<Name>.csv.base.json`) — kept only for path guards. */ export const DATABASE_SIDECAR_SUFFIX = '.base.json' export const DEFAULT_ID_FIELD_NAME = 'id' /** Board column key for rows whose group-by cell is empty/unmatched. */ export const EMPTY_GROUP = '__empty__' +/** A database lives in a folder whose name ends with this suffix. */ +export const FORM_DIR_SUFFIX = '.base' +/** Fixed names of a database folder's contents. */ +export const FORM_DATA_FILE = 'data.csv' +export const FORM_SCHEMA_FILE = 'schema.json' +export const FORM_PAGES_DIR = 'pages' + +const toPosixPath = (p: string): string => p.replace(/\\/g, '/') +const lastSegment = (p: string): string => { + const s = toPosixPath(p) + return s.slice(s.lastIndexOf('/') + 1) +} + +/** True when a folder name (or a path's last segment) marks a database folder. */ +export function isFormDirName(nameOrPath: string): boolean { + return lastSegment(nameOrPath).toLowerCase().endsWith(FORM_DIR_SUFFIX) +} + +/** + * The database folder path for a `data.csv` path, or null when `csvPath` isn't a + * database data file. e.g. `a/X.base/data.csv` → `a/X.base`. + */ +export function formDirFromCsvPath(csvPath: string): string | null { + const p = toPosixPath(csvPath) + const slash = p.lastIndexOf('/') + if (slash < 0) return null + const dir = p.slice(0, slash) + const file = p.slice(slash + 1) + if (file.toLowerCase() !== FORM_DATA_FILE) return null + return isFormDirName(dir) ? dir : null +} + +/** The `data.csv` path for a database folder path. e.g. `a/X.base` → `a/X.base/data.csv`. */ +export function csvPathForFormDir(formDir: string): string { + return `${toPosixPath(formDir)}/${FORM_DATA_FILE}` +} + +/** The `schema.json` path for a database's `data.csv` path, or null. */ +export function databaseSchemaPathFor(csvPath: string): string | null { + const dir = formDirFromCsvPath(csvPath) + return dir ? `${dir}/${FORM_SCHEMA_FILE}` : null +} + +/** The pages-dir path for a database's `data.csv` path, or null. */ +export function pagesDirFromCsvPath(csvPath: string): string | null { + const dir = formDirFromCsvPath(csvPath) + return dir ? `${dir}/${FORM_PAGES_DIR}` : null +} + +/** + * If `relPath` lives inside a `.base` database folder, return that folder path; + * otherwise null. e.g. `a/X.base/pages/r.md` → `a/X.base`. + */ +export function formDirContaining(relPath: string): string | null { + const parts = toPosixPath(relPath).split('/') + for (let i = 0; i < parts.length; i++) { + if (parts[i].toLowerCase().endsWith(FORM_DIR_SUFFIX)) { + return parts.slice(0, i + 1).join('/') + } + } + return null +} + +/** Display title from a database folder name/path (strips the `.base` suffix). */ +export function formTitleFromDir(nameOrPath: string): string { + const name = lastSegment(nameOrPath) + return name.toLowerCase().endsWith(FORM_DIR_SUFFIX) + ? name.slice(0, -FORM_DIR_SUFFIX.length) + : name +} + +/** Display title from a database's `data.csv` path. */ +export function formTitleFromCsvPath(csvPath: string): string { + const dir = formDirFromCsvPath(csvPath) + return dir ? formTitleFromDir(dir) : csvPath +} + export type FieldType = 'text' | 'number' | 'checkbox' | 'date' | 'select' | 'multiSelect' export interface SelectOption { @@ -94,7 +181,7 @@ export interface DbView { cardFieldIds?: string[] } -/** The sidecar JSON written to `<name>.csv.base.json`. */ +/** The sidecar JSON written to `<Name>.base/schema.json`. */ export interface DatabaseSidecar { version: 1 /** Field whose cells hold the row UUID (its `name` is the CSV header). */ @@ -116,9 +203,9 @@ export interface DbRow { /** Fully-hydrated database handed to the renderer (sidecar + rows + identity). */ export interface DatabaseDoc extends DatabaseSidecar { - /** Vault-relative POSIX path of the `.csv` — identity / cache key. */ + /** Vault-relative POSIX path of the `data.csv` — identity / cache key. */ path: string - /** Basename without `.csv`. */ + /** Database name: the `.base` folder name (legacy: the `.csv` basename). */ title: string rows: DbRow[] /** @@ -164,6 +251,8 @@ export function csvPathFromDatabaseTab(path: string | null | undefined): string export function databaseTitleFromTab(path: string | null | undefined): string { const csv = csvPathFromDatabaseTab(path) if (!csv) return 'Database' + // A database's title is its `<Name>.base` folder name, not the `data.csv` basename. + if (formDirFromCsvPath(csv)) return formTitleFromCsvPath(csv) const base = csv.split('/').filter(Boolean).pop() ?? csv return base.replace(/\.csv$/i, '') } @@ -174,11 +263,19 @@ export function isDatabaseSidecarPath(relPath: string): boolean { } /** - * True for files that belong to a database but aren't the user-facing `.csv` — - * the sidecar and any `.bak` backups. These are hidden from the note list. + * True for files that belong to a database but aren't the user-facing entity. + * New layout: inside a `.base` folder, the record-page notes (`*.md`) are + * user-facing (they nest under the database); `data.csv`, `schema.json`, and any + * backups are internal. Legacy: the co-located sidecar and `.bak` backups. + * Hidden from the note/asset list. */ export function isDatabaseInternalPath(relPath: string): boolean { - const l = relPath.toLowerCase() + const p = toPosixPath(relPath) + if (formDirContaining(p)) { + // Record pages are markdown; everything else in the folder is internal. + return !p.toLowerCase().endsWith('.md') + } + const l = p.toLowerCase() return ( l.endsWith(`.csv${DATABASE_SIDECAR_SUFFIX}`) || l.endsWith('.csv.bak') || @@ -186,15 +283,30 @@ export function isDatabaseInternalPath(relPath: string): boolean { ) } -/** True for a database data file (`*.csv`, but not the sidecar). */ +/** True for a database data file: new `<Name>.base/data.csv`, or a legacy loose `.csv`. */ export function isDatabaseCsvPath(relPath: string): boolean { - const lower = relPath.toLowerCase() + if (formDirFromCsvPath(relPath)) return true + const lower = toPosixPath(relPath).toLowerCase() + // Legacy loose CSV — but not one that lives inside a `.base` folder. + if (formDirContaining(relPath)) return false return lower.endsWith('.csv') && !lower.endsWith(`.csv${DATABASE_SIDECAR_SUFFIX}`) } -/** Given a `.csv` or its `.base.json` sidecar, return the canonical `.csv` path. */ +/** + * Given any file that belongs to a database (`data.csv`, `schema.json`, or a + * legacy `.csv`/sidecar), return the canonical `data.csv` path; null otherwise. + * Used to normalize a watcher event on any database file back to its identity. + */ export function databaseCsvPathFor(relPath: string): string | null { - if (isDatabaseSidecarPath(relPath)) return relPath.slice(0, -DATABASE_SIDECAR_SUFFIX.length) - if (isDatabaseCsvPath(relPath)) return relPath + const p = toPosixPath(relPath) + if (p.toLowerCase().endsWith(`/${FORM_SCHEMA_FILE}`)) { + const dir = p.slice(0, p.lastIndexOf('/')) + if (isFormDirName(dir)) return `${dir}/${FORM_DATA_FILE}` + } + if (formDirFromCsvPath(p)) return p + // Legacy: a `<Name>.csv.base.json` sidecar maps to its `.csv`. + if (isDatabaseSidecarPath(p)) return p.slice(0, -DATABASE_SIDECAR_SUFFIX.length) + // Legacy loose `.csv` (not inside a `.base` folder). + if (!formDirContaining(p) && isDatabaseCsvPath(p)) return p return null } diff --git a/packages/shared-domain/src/excalidraw.ts b/packages/shared-domain/src/excalidraw.ts new file mode 100644 index 00000000..bd2f47d1 --- /dev/null +++ b/packages/shared-domain/src/excalidraw.ts @@ -0,0 +1,57 @@ +// Excalidraw drawings are stored as standalone `.excalidraw` files (the native +// Excalidraw JSON scene format). They are a first-class file type alongside +// Markdown notes and `.base` databases: listed in the sidebar with their own +// icon, opened in a dedicated editor tab, and saved back as JSON. + +export const EXCALIDRAW_EXT = '.excalidraw' + +export function isExcalidrawPath(path: string | null | undefined): boolean { + return typeof path === 'string' && path.toLowerCase().endsWith(EXCALIDRAW_EXT) +} + +/** Display title for a drawing (filename without the `.excalidraw` extension). */ +export function excalidrawTitleFromPath(path: string): string { + const base = path.split('/').pop() ?? path + return base.toLowerCase().endsWith(EXCALIDRAW_EXT) + ? base.slice(0, -EXCALIDRAW_EXT.length) + : base +} + +/** The on-disk Excalidraw scene shape (a subset of the official format). */ +export interface ExcalidrawDocument { + type: 'excalidraw' + version: number + source: string + elements: unknown[] + appState: Record<string, unknown> + files: Record<string, unknown> +} + +export function emptyExcalidrawDocument(): ExcalidrawDocument { + return { + type: 'excalidraw', + version: 2, + source: 'zennotes', + elements: [], + appState: {}, + files: {} + } +} + +/** Parse on-disk JSON into a scene, falling back to an empty doc when invalid. */ +export function parseExcalidrawDocument(raw: string): ExcalidrawDocument { + try { + const parsed = JSON.parse(raw) as Partial<ExcalidrawDocument> + return { + ...emptyExcalidrawDocument(), + ...parsed, + type: 'excalidraw', + elements: Array.isArray(parsed.elements) ? parsed.elements : [], + appState: + parsed.appState && typeof parsed.appState === 'object' ? parsed.appState : {}, + files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {} + } + } catch { + return emptyExcalidrawDocument() + } +} diff --git a/packages/shared-domain/src/tasklists.ts b/packages/shared-domain/src/tasklists.ts index 021c074a..54c9ca9c 100644 --- a/packages/shared-domain/src/tasklists.ts +++ b/packages/shared-domain/src/tasklists.ts @@ -160,3 +160,137 @@ export function setTaskDueAtIndex( return `${prefix}${checkChar}]${nextTail}` }) } + +/** Replace everything after the checkbox on the task line at `taskIndex` with + * `text` (verbatim — the caller owns any `due:`/`!priority` tokens). Used by + * inline task editing. */ +export function setTaskTextAtIndex( + markdown: string, + taskIndex: number, + text: string +): string { + return editTaskAtIndex(markdown, taskIndex, (match) => { + const prefix = match[1] + const checkChar = match[2] + const tailWithBracket = match[3] + if (!tailWithBracket.startsWith(']')) return null + const trimmed = text.trim() + return `${prefix}${checkChar}]${trimmed ? ` ${trimmed}` : ''}` + }) +} + +/** Remove the task line at `taskIndex` and return both the removed line and the + * remaining body. `line` is null when the index is out of range. Fence-aware, + * counting tasks the same way the parser does so the index stays in lockstep. + * Used to move a task to another note. */ +export function takeTaskLineAtIndex( + markdown: string, + taskIndex: number +): { line: string | null; body: string } { + if (taskIndex < 0) return { line: null, body: markdown } + const lines = markdown.split('\n') + let currentTaskIndex = 0 + let inFence = false + let fenceMarker: string | null = null + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const fenceMatch = line.match(FENCE_RE) + if (fenceMatch) { + const marker = fenceMatch[2] + if (!inFence) { + inFence = true + fenceMarker = marker + } else if (marker === fenceMarker) { + inFence = false + fenceMarker = null + } + continue + } + if (inFence) continue + if (!TASK_LINE_RE.test(line)) continue + if (currentTaskIndex !== taskIndex) { + currentTaskIndex += 1 + continue + } + const removed = lines[i] + lines.splice(i, 1) + return { line: removed, body: lines.join('\n') } + } + return { line: null, body: markdown } +} + +/** Delete the task line at `taskIndex` entirely. */ +export function removeTaskAtIndex(markdown: string, taskIndex: number): string { + return takeTaskLineAtIndex(markdown, taskIndex).body +} + +function leadingIndentWidth(line: string): number { + return line.match(/^[ \t]*/)?.[0].length ?? 0 +} + +/** + * Pull every UNCHECKED task line — together with its indented continuation / + * child lines — out of `markdown`. Used to roll unfinished tasks forward from + * past daily notes into today's note. + * + * - Lines are moved verbatim, so any `due:`/`!priority`/`#tag` tokens travel + * with the task unchanged. + * - Checked tasks (`- [x]`) stay put — they're history. + * - `- [ ]` inside fenced code blocks is ignored (never a real task). + * - A task's indented children (deeper-indented following lines, up to the + * first blank line, dedent, or fence) move with it so sub-bullets aren't + * orphaned. + * + * Returns the moved raw lines (in document order) and the remaining body. + */ +export function extractUncheckedTaskBlocks(markdown: string): { + moved: string[] + rest: string +} { + const lines = markdown.split('\n') + const consumed = new Array<boolean>(lines.length).fill(false) + const moved: string[] = [] + let inFence = false + let fenceMarker: string | null = null + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const fenceMatch = line.match(FENCE_RE) + if (fenceMatch) { + const marker = fenceMatch[2] + if (!inFence) { + inFence = true + fenceMarker = marker + } else if (marker === fenceMarker) { + inFence = false + fenceMarker = null + } + continue + } + if (inFence) continue + + const taskMatch = line.match(TASK_LINE_RE) + if (!taskMatch) continue + if (taskMatch[2] !== ' ') continue // only unchecked tasks roll over + + const baseIndent = leadingIndentWidth(line) + moved.push(line) + consumed[i] = true + + // Carry indented continuation/child lines along with the task. + let j = i + 1 + while (j < lines.length) { + const next = lines[j] + if (next.trim() === '') break + if (FENCE_RE.test(next)) break + if (leadingIndentWidth(next) <= baseIndent) break + moved.push(next) + consumed[j] = true + j++ + } + i = j - 1 // skip the consumed block (its children are not new tasks) + } + + const rest = lines.filter((_, idx) => !consumed[idx]).join('\n') + return { moved, rest } +} diff --git a/packages/shared-domain/src/tasks.ts b/packages/shared-domain/src/tasks.ts index da1434b1..298bd08d 100644 --- a/packages/shared-domain/src/tasks.ts +++ b/packages/shared-domain/src/tasks.ts @@ -41,6 +41,10 @@ export interface VaultTask { checked: boolean /** ISO YYYY-MM-DD, validated via Date round-trip. */ due?: string + /** True when `due` was *derived* from the containing daily note's date + * rather than written on the line. Lets UIs tell an implicit due apart + * from an explicit `due:` token. See `inferDailyTaskDueDates`. */ + dueInferred?: boolean priority?: TaskPriority /** True if `@waiting` appears anywhere on the line. */ waiting: boolean @@ -136,7 +140,8 @@ const INLINE_DUE_RE = /(?:^|\s)due:(\S+)/i const INLINE_PRIORITY_RE = /(?:^|\s)!(high|med|medium|low|h|m|l)\b/i const INLINE_WAITING_RE = /(?:^|\s)@waiting\b/i // Match #tag-like tokens but only when preceded by start-of-string/whitespace. -const INLINE_TAG_RE = /(?:^|\s)#([a-z0-9][a-z0-9/_-]*)/gi +// Letters in any script (Cyrillic/CJK/…) plus digits, `_`, `-`, `/` (#205). +const INLINE_TAG_RE = /(?:^|\s)#([\p{L}\d][\p{L}\d/_-]*)/gu interface ExtractedTokens { due?: string @@ -356,6 +361,30 @@ export function tasksDueOn(tasks: VaultTask[], iso: string): VaultTask[] { ) } +/** + * Give undated tasks that live in a daily note an *implicit* due date equal to + * that note's own date, using a precomputed `sourcePath -> ISO date` map (built + * in app-core from the daily-note pattern). An explicit `due:` token always + * wins, so only tasks with no `due` are touched; the result is flagged + * `dueInferred` so UIs can distinguish it. Returns the same array instance when + * nothing changed (cheap to call from a memo). + */ +export function inferDailyTaskDueDates( + tasks: VaultTask[], + dueByPath: ReadonlyMap<string, string> +): VaultTask[] { + if (dueByPath.size === 0) return tasks + let changed = false + const out = tasks.map((task) => { + if (task.due) return task + const iso = dueByPath.get(task.sourcePath) + if (!iso) return task + changed = true + return { ...task, due: iso, dueInferred: true } + }) + return changed ? out : tasks +} + /** Bucket tasks by `due` ISO date. Done and waiting tasks are skipped. * Tasks without a due date land in the special `'unscheduled'` key. */ export function bucketTasksByDueDate( diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 21052fba..217464f1 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.3.0", + "version": "2.4.0", "type": "module", "exports": { ".": "./src/index.ts" diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index cfb1c973..1fdc36ae 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = zennotes-bin pkgdesc = Keyboard-first, local-first Markdown notes with vim motions and live preview - pkgver = 2.2.0 + pkgver = 2.4.0 pkgrel = 1 url = https://github.com/ZenNotes/zennotes arch = x86_64 @@ -12,7 +12,7 @@ pkgbase = zennotes-bin provides = zennotes conflicts = zennotes options = !strip - source = ZenNotes-2.2.0.AppImage::https://github.com/ZenNotes/zennotes/releases/download/v2.2.0/ZenNotes-2.2.0-linux-x86_64.AppImage - sha256sums = f7f6a67b193dad9961b3844a9ddb213ba866467999574b7f7bbaaff465895722 + source = ZenNotes-2.4.0-linux-x64.tar.gz::https://github.com/ZenNotes/zennotes/releases/download/v2.4.0/ZenNotes-2.4.0-linux-x64.tar.gz + sha256sums = SKIP pkgname = zennotes-bin diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 35b38913..8a62937a 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -2,12 +2,6 @@ # # AUR package for ZenNotes (yay -S zennotes-bin). # -# This is a -bin package: it downloads the official AppImage from the GitHub -# release and *extracts* it at build time (--appimage-extract), then installs the -# unpacked app to /opt. Extraction does not need FUSE, so the resulting install -# runs on CachyOS / Arch without libfuse2 — sidestepping the AppImage-won't-start -# problem entirely. -# # Before publishing a new version: # 1. bump pkgver to match the GitHub release tag (without the leading "v"), # 2. run `updpkgsums` to fill in sha256sums (or `makepkg -g`), @@ -16,80 +10,66 @@ pkgname=zennotes-bin _appname=ZenNotes -pkgver=2.2.0 +pkgver=2.4.0 pkgrel=1 pkgdesc="Keyboard-first, local-first Markdown notes with vim motions and live preview" arch=('x86_64') url="https://github.com/ZenNotes/zennotes" license=('MIT') -# Electron bundles its own runtime; these are the shared libs it links against. depends=('gtk3' 'nss' 'alsa-lib' 'libxss') provides=('zennotes') conflicts=('zennotes') options=('!strip') -source=("${_appname}-${pkgver}.AppImage::${url}/releases/download/v${pkgver}/${_appname}-${pkgver}-linux-x86_64.AppImage") -# sha256 of the v2.2.0 x86_64 AppImage. Regenerate with `updpkgsums` on each bump. -sha256sums=('f7f6a67b193dad9961b3844a9ddb213ba866467999574b7f7bbaaff465895722') + +source=( + "${_appname}-${pkgver}-linux-x64.tar.gz::${url}/releases/download/v${pkgver}/${_appname}-${pkgver}-linux-x64.tar.gz" +) + +# No artifact to hash yet — the v2.4.0 tarball is created at release time, so +# SKIP is a placeholder. Run `updpkgsums` against the uploaded release asset +# before publishing to AUR (step 2 above) to pin the real checksum. +sha256sums=('SKIP') package() { cd "${srcdir}" - chmod +x "${_appname}-${pkgver}.AppImage" - # FUSE-free extraction — no libfuse2 required on the build host. - ./"${_appname}-${pkgver}.AppImage" --appimage-extract >/dev/null + local _extracted="${_appname}-${pkgver}-linux-x64" + local _extras="${_extracted}/resources/arch-extras" - # Install the unpacked app under /opt. + # Install application install -dm755 "${pkgdir}/opt/${pkgname}" - cp -a squashfs-root/. "${pkgdir}/opt/${pkgname}/" - - # `cp -a` re-applies the extracted squashfs root's mode (often 0700) onto - # /opt/zennotes-bin, leaving the tree untraversable for non-root users — so - # launching the desktop entry or `zennotes` fails with "command not found" - # or "not executable" (issues #70, #74). Force every directory and - # already-executable file world-traversable and everything world-readable. - # (chmod -R skips symlinks during recursion, so AppRun's real target is - # fixed via the regular file it points to.) - chmod -R a+rX "${pkgdir}/opt/${pkgname}" + # tar.gz preserves permissions (unlike squashfs extraction) + # chmod workarounds from #70/#74/#92 are no longer needed. + cp -a "${_extracted}/." "${pkgdir}/opt/${pkgname}/" - # `a+rX` only PRESERVES an existing execute bit — it won't restore one the - # source mode dropped during extraction/cp, so on some build hosts the - # Electron launcher lands non-executable and `/usr/bin/zennotes` reports - # "exists but is not an executable file" (#92). Force it on the launcher - # entry points; chmod follows the AppRun symlink to its real target. - for _exe in "${_appname}" AppRun chrome_crashpad_handler; do - if [ -e "${pkgdir}/opt/${pkgname}/${_exe}" ]; then - chmod a+x "${pkgdir}/opt/${pkgname}/${_exe}" - fi - done - - # The Chromium sandbox helper must be setuid-root to work without - # --no-sandbox. Keep this AFTER the chmod -R above so the setuid bit stands. - if [ -f "${pkgdir}/opt/${pkgname}/chrome-sandbox" ]; then + # Chromium sandbox + if [[ -f "${pkgdir}/opt/${pkgname}/chrome-sandbox" ]]; then chmod 4755 "${pkgdir}/opt/${pkgname}/chrome-sandbox" fi - # CLI launcher. + # CLI launcher install -dm755 "${pkgdir}/usr/bin" - ln -s "/opt/${pkgname}/AppRun" "${pkgdir}/usr/bin/zennotes" + ln -s "/opt/${pkgname}/${_appname}" "${pkgdir}/usr/bin/zennotes" - # Desktop entry — repoint Exec/Icon at the installed paths. - local desktop - desktop=$(find squashfs-root -maxdepth 1 -name '*.desktop' | head -n1) - if [ -n "${desktop}" ]; then - install -Dm644 "${desktop}" "${pkgdir}/usr/share/applications/zennotes.desktop" - sed -i \ - -e 's|^Exec=.*|Exec=zennotes %U|' \ - -e 's|^Icon=.*|Icon=zennotes|' \ + # Desktop entry + if [[ -f "${_extras}/zennotes.desktop" ]]; then + install -Dm644 \ + "${_extras}/zennotes.desktop" \ "${pkgdir}/usr/share/applications/zennotes.desktop" fi - # Icons shipped inside the AppImage. - local size icon - for size in 16 32 48 64 128 256 512; do - icon="squashfs-root/usr/share/icons/hicolor/${size}x${size}/apps/${_appname}.png" - if [ -f "${icon}" ]; then - install -Dm644 "${icon}" \ + # Icons + local size + for size in 16 24 32 48 64 128 256 512; do + if [[ -f "${_extras}/icons/${size}x${size}.png" ]]; then + install -Dm644 \ + "${_extras}/icons/${size}x${size}.png" \ "${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/zennotes.png" fi done + + # LICENSE + install -Dm644 \ + "${_extracted}/LICENSE" \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" } diff --git a/packaging/aur/arch-extras/zennotes.desktop b/packaging/aur/arch-extras/zennotes.desktop new file mode 100644 index 00000000..4f9a5eb8 --- /dev/null +++ b/packaging/aur/arch-extras/zennotes.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=ZenNotes +Comment=ZenNotes desktop shell +Exec=zennotes %U +Icon=zennotes +Terminal=false +Type=Application +Categories=Office; +MimeType=text/markdown;x-scheme-handler/zennotes; +StartupWMClass=ZenNotes diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md new file mode 100644 index 00000000..8f0a484c --- /dev/null +++ b/packaging/flatpak/README.md @@ -0,0 +1,73 @@ +# Linux packaging (Flatpak) + +This directory holds the Flatpak packaging for ZenNotes. + +## Why this exists + +The same class of problem reported in +[#65](https://github.com/ZenNotes/zennotes/issues/65) — the AppImage failing to +start on some distros — also hits other setups. AppImages rely on the host's +`libfuse2` and system libraries; on Fedora Atomic/Silverblue, some Arch +variants, and minimal installs the image simply won't launch. + +Flatpak sidesteps all of that: the app ships against a self-contained +[org.freedesktop.Platform](https://docs.flatpak.org/) runtime plus the +[Electron base app](https://github.com/flathub/org.electronjs.Electron2.BaseApp), +so it does not depend on host FUSE or system libraries, and it runs sandboxed. + +## How it works + +Like the AUR `PKGBUILD`, this manifest downloads the official AppImage from the +GitHub release and **extracts** it at build time (`--appimage-extract`, which +does not need FUSE). No source rebuild is required. The unpacked Electron app is +installed into `/app` and launched through +[`zypak`](https://github.com/refi64/zypak) (provided by the Electron base app), +which makes Chromium's sandbox work inside the Flatpak sandbox without the SUID +`chrome-sandbox` helper. + +Files: + +- `com.adibhanna.zennotes.yml` — flatpak-builder manifest +- `zennotes.sh` — launcher that wraps the binary with `zypak-wrapper` +- `com.adibhanna.zennotes.desktop` — desktop entry (Markdown + `zennotes://` handler) +- `com.adibhanna.zennotes.metainfo.xml` — AppStream metadata + +## Build & install locally + +Requires `flatpak` and `flatpak-builder`. + +```sh +cd packaging/flatpak + +# one-time: runtime, SDK and Electron base app (from Flathub) +flatpak install -y flathub org.freedesktop.Platform//25.08 \ + org.freedesktop.Sdk//25.08 org.electronjs.Electron2.BaseApp//25.08 + +flatpak-builder --user --install --force-clean build-dir com.adibhanna.zennotes.yml + +flatpak run com.adibhanna.zennotes +``` + +## Updating to a new release + +```sh +cd packaging/flatpak +# 1. bump the `url` in com.adibhanna.zennotes.yml to the new release tag +# 2. update the `sha256`: +curl -L -o /tmp/ZenNotes.AppImage \ + https://github.com/ZenNotes/zennotes/releases/download/v<version>/ZenNotes-<version>-linux-x86_64.AppImage +sha256sum /tmp/ZenNotes.AppImage +# 3. bump the <release> entry in com.adibhanna.zennotes.metainfo.xml +# 4. rebuild and smoke-test (see above) +``` + +## Notes & limitations + +- **Sandbox permissions:** `--filesystem=home` is granted so notes (plain + Markdown files) are reachable. Tighten it to a specific path (e.g. + `--filesystem=~/Notes`) if you prefer. +- **Auto-update is disabled** inside Flatpak (`electron-updater` cannot replace a + read-only `/app`). Update via `flatpak update` once published, or rebuild with + a new `url`/`sha256` for a local install. +- **Publishing to Flathub** would be a follow-up: it needs the `com.adibhanna.*` + app-id owner's sign-off plus screenshots in the AppStream metadata. diff --git a/packaging/flatpak/com.adibhanna.zennotes.desktop b/packaging/flatpak/com.adibhanna.zennotes.desktop new file mode 100644 index 00000000..a8e649a9 --- /dev/null +++ b/packaging/flatpak/com.adibhanna.zennotes.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=ZenNotes +Comment=Keyboard-first Markdown notes app +Exec=zennotes %U +Terminal=false +Type=Application +Icon=com.adibhanna.zennotes +StartupWMClass=ZenNotes +MimeType=text/markdown;x-scheme-handler/zennotes; +Categories=Office;Utility;TextEditor; diff --git a/packaging/flatpak/com.adibhanna.zennotes.metainfo.xml b/packaging/flatpak/com.adibhanna.zennotes.metainfo.xml new file mode 100644 index 00000000..0be35b0e --- /dev/null +++ b/packaging/flatpak/com.adibhanna.zennotes.metainfo.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> +<component type="desktop-application"> + <id>com.adibhanna.zennotes</id> + <metadata_license>CC0-1.0</metadata_license> + <project_license>MIT</project_license> + <name>ZenNotes</name> + <summary>Keyboard-first Markdown notes app</summary> + <description> + <p> + ZenNotes is a keyboard-first Markdown notes app that keeps your notes as + ordinary Markdown files on disk. It adds Vim-friendly editing, split and + preview workflows, tasks, tags, archive/trash, diagrams, search, daily + notes and CSV databases (Notion-style Table and Board views over plain + .csv files) on top of the files you already own. + </p> + </description> + <launchable type="desktop-id">com.adibhanna.zennotes.desktop</launchable> + <url type="homepage">https://zennotes.org</url> + <url type="bugtracker">https://github.com/ZenNotes/zennotes/issues</url> + <url type="vcs-browser">https://github.com/ZenNotes/zennotes</url> + <developer id="com.adibhanna"> + <name>Adib Hanna</name> + </developer> + <content_rating type="oars-1.1"/> + <releases> + <release version="2.4.0" date="2026-06-19"/> + <release version="2.3.0" date="2026-06-13"/> + </releases> +</component> diff --git a/packaging/flatpak/com.adibhanna.zennotes.yml b/packaging/flatpak/com.adibhanna.zennotes.yml new file mode 100644 index 00000000..dcde0307 --- /dev/null +++ b/packaging/flatpak/com.adibhanna.zennotes.yml @@ -0,0 +1,61 @@ +app-id: com.adibhanna.zennotes +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk +base: org.electronjs.Electron2.BaseApp +base-version: '25.08' +command: zennotes +separate-locales: false + +finish-args: + - --share=ipc + - --share=network + - --socket=fallback-x11 + - --socket=wayland + - --device=dri + - --socket=pulseaudio + # Notes are plain Markdown files on disk — allow access to the home dir. + - --filesystem=home + - --talk-name=org.freedesktop.Notifications + +modules: + - name: zennotes + buildsystem: simple + build-commands: + # Unpack the official AppImage. `--appimage-extract` uses the embedded + # squashfs extractor and needs no FUSE, so it runs inside the build sandbox. + - chmod +x ZenNotes.AppImage + - ./ZenNotes.AppImage --appimage-extract + # Install icons from the AppImage's hicolor tree, renamed to the app-id. + - | + for s in 16 24 32 48 64 128 256 512; do + install -Dm644 "squashfs-root/usr/share/icons/hicolor/${s}x${s}/apps/ZenNotes.png" \ + "/app/share/icons/hicolor/${s}x${s}/apps/com.adibhanna.zennotes.png" + done + # Copy the unpacked Electron app into /app, dropping AppImage-only bits. + - mkdir -p /app/zennotes + - cp -a squashfs-root/. /app/zennotes/ + - rm -rf /app/zennotes/usr /app/zennotes/AppRun /app/zennotes/.DirIcon + /app/zennotes/ZenNotes.png /app/zennotes/ZenNotes.desktop + # The SUID chrome-sandbox is unusable in Flatpak; zypak handles sandboxing. + - rm -f /app/zennotes/chrome-sandbox + - chmod -R a+rX /app/zennotes + - install -Dm755 zennotes.sh /app/bin/zennotes + - install -Dm644 com.adibhanna.zennotes.desktop + /app/share/applications/com.adibhanna.zennotes.desktop + - install -Dm644 com.adibhanna.zennotes.metainfo.xml + /app/share/metainfo/com.adibhanna.zennotes.metainfo.xml + sources: + # Bump `url` + `sha256` on each release (see README.md). The sha256 below + # is a placeholder — set it to the real hash of the v2.4.0 AppImage once + # the release is published: `shasum -a 256 ZenNotes-2.4.0-linux-x86_64.AppImage`. + - type: file + url: https://github.com/ZenNotes/zennotes/releases/download/v2.4.0/ZenNotes-2.4.0-linux-x86_64.AppImage + sha256: 0000000000000000000000000000000000000000000000000000000000000000 + dest-filename: ZenNotes.AppImage + - type: file + path: zennotes.sh + - type: file + path: com.adibhanna.zennotes.desktop + - type: file + path: com.adibhanna.zennotes.metainfo.xml diff --git a/packaging/flatpak/zennotes.sh b/packaging/flatpak/zennotes.sh new file mode 100644 index 00000000..d349c0b7 --- /dev/null +++ b/packaging/flatpak/zennotes.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Wrap the Electron binary with zypak so Chromium's sandbox works inside the +# Flatpak sandbox (the SUID chrome-sandbox is unavailable here). +exec zypak-wrapper /app/zennotes/ZenNotes "$@" diff --git a/packaging/nix/README.md b/packaging/nix/README.md new file mode 100644 index 00000000..fdc35849 --- /dev/null +++ b/packaging/nix/README.md @@ -0,0 +1,124 @@ +# Linux packaging (Nix / NixOS) + +This directory holds the Nix packaging for ZenNotes. + +## Test it before installing + +You can use `nix run` to run the application without installing it: + +For the desktop app: +```sh +nix run github:ZenNotes/zennotes +``` + +For the server: +```sh +nix run github:ZenNotes/zennotes#zennotes-server +``` + +## Installing on NixOS + +For now as the package is not in the official nixpkgs repo you will need to add an input in your `flake.nix`, like this: + +```nix +inputs = { + # ... + + zennotes.url = "github:ZenNotes/zennotes"; +}; + +outputs = { nixpkgs, ... } @ inputs: +{ + # ... +}; +``` + +And then you can add it to your system packages: + +```nix +{ pkgs, inputs, ... }: + +{ + environment.systemPackages = [ + inputs.zennotes.packages.${pkgs.system}.zennotes-desktop + inputs.zennotes.packages.${pkgs.system}.zennotes-server + ]; +} +``` + + +If you don't use flakes you'll need to copy the `package-desktop.nix` file into your NixOS configuration and add it to your system packages: + +```nix +environment.systemPackages = [ + (pkgs.callPackage ./package-desktop.nix { }) +]; +``` + +Same goes for the server package: + +```nix +environment.systemPackages = [ + (pkgs.callPackage ./package-server.nix { }) +]; +``` + +## Updating to a new release + +1. Open `release-data.json` +2. Bump `version`: + +```json +{ + "version": "2.3.0", // => 2.4.0 + // ... +} +``` + +2. Update the source hash +To obtain a new hash (replace X.X.X with the desired version): + +```sh +nix-prefetch-github ZenNotes zennotes --rev "vX.X.X" +``` + +```json +{ + // ... + "hash": "sha256-+tLPVnnMbtMa5blSwHav9ZMlnkUsrdG62mMGxhbmy6g=", // update to new hash + // ... +} + +``` + +3. Update the npmDepsHash (if needed) and vendorHash (if needed) +To obtain a new npmDepsHash use this command in an updated project root: + +```sh +prefetch-npm-deps package-lock.json +``` + +```json +{ + // ... + "npmDepsHash": "sha256-7IpGnxVjaJvfSZyKjOylGMhFqa1bx8Ry5O1yqYfNnCE=", + "vendorHash": "sha256-wYBF7CjM6AvoWMWql9hFmIaj6pCmli4vOef6POyGkfU=" +} +``` + +4. Build and test + +```sh +nix build +./result/bin/zennotes-desktop +``` + +```sh +nix build .#server +./result/bin/zennotes-server +``` + +## Notes & limitations + +* Automatic updates inside ZenNotes are disabled because Nix packages are immutable. +* Updates should be performed through Nix by updating the package definition. diff --git a/packaging/nix/package-desktop.nix b/packaging/nix/package-desktop.nix new file mode 100644 index 00000000..df4b9cdd --- /dev/null +++ b/packaging/nix/package-desktop.nix @@ -0,0 +1,95 @@ +{ + stdenv, + lib, + buildNpmPackage, + fetchFromGitHub, + makeDesktopItem, + copyDesktopItems, + electron, + makeWrapper, + + installCLI ? false, + CLIcommand ? "zen", + commandLineArgs ? "", +}: +let + releaseData = lib.importJSON ./release-data.json; +in +buildNpmPackage (finalAttrs: { + pname = "zennotes-desktop"; + inherit (releaseData) version npmDepsHash; + + src = fetchFromGitHub { + owner = "ZenNotes"; + repo = "zennotes"; + tag = "v${finalAttrs.version}"; + inherit (releaseData) hash; + }; + + npmWorkspace = "apps/desktop"; + + env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; + + nativeBuildInputs = [ + makeWrapper + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + copyDesktopItems + ]; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/node_modules/zennotes-monorepo + cp -r . $out/lib/node_modules/zennotes-monorepo/ + + for icon in apps/desktop/build/icons/*.png; do + size="$(basename "$icon" .png)" + install -Dm644 $icon $out/share/icons/hicolor/$size/apps/${finalAttrs.pname}.png + done + + mkdir -p $out/bin + makeWrapper ${electron}/bin/electron $out/bin/${finalAttrs.pname} \ + --add-flags "$out/lib/node_modules/zennotes-monorepo/apps/desktop" \ + --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto}}" \ + ${lib.optionalString (commandLineArgs != "") "--add-flags ${lib.escapeShellArg commandLineArgs}"} + + ${lib.optionalString installCLI '' + makeWrapper ${electron}/libexec/electron/electron $out/bin/${CLIcommand} \ + --set ELECTRON_RUN_AS_NODE 1 \ + --add-flags "$out/lib/node_modules/zennotes-monorepo/apps/desktop/out/main/cli.js" + ''} + + runHook postInstall + ''; + + desktopItems = [ + (makeDesktopItem { + name = finalAttrs.pname; + desktopName = "ZenNotes"; + exec = "${finalAttrs.pname} %U"; + icon = finalAttrs.pname; + comment = "Keyboard-first local Markdown notes"; + categories = [ + "Office" + "Utility" + "TextEditor" + ]; + startupWMClass = "ZenNotes"; + mimeTypes = [ + "text/markdown" + "x-scheme-handler/zennotes" + ]; + }) + ]; + + meta = { + description = "Keyboard-first local Markdown notes with Vim motions, diagrams, and MCP integration"; + homepage = "https://zennotes.org/"; + changelog = "https://github.com/ZenNotes/zennotes/releases/tag/v${finalAttrs.version}"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ justkrysteq ]; + mainProgram = finalAttrs.pname; + inherit (electron.meta) platforms; + }; +}) diff --git a/packaging/nix/package-server.nix b/packaging/nix/package-server.nix new file mode 100644 index 00000000..dced319a --- /dev/null +++ b/packaging/nix/package-server.nix @@ -0,0 +1,66 @@ +{ + lib, + buildGoModule, + fetchFromGitHub, + buildNpmPackage, +}: +let + releaseData = lib.importJSON ./release-data.json; + + src = fetchFromGitHub { + owner = "ZenNotes"; + repo = "zennotes"; + tag = "v${releaseData.version}"; + inherit (releaseData) hash; + }; + + web = buildNpmPackage { + pname = "zennotes-web"; + + inherit (releaseData) version npmDepsHash; + inherit src; + + npmWorkspace = "apps/web"; + + env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; + + installPhase = '' + runHook preInstall + + mkdir -p "$out" + cp -R apps/web/dist/. "$out/" + + runHook postInstall + ''; + }; +in +buildGoModule (finalAttrs: { + pname = "zennotes-server"; + + inherit (releaseData) version vendorHash; + inherit src; + + modRoot = "apps/server"; + + subPackages = [ "cmd/zennotes-server" ]; + ldflags = [ + "-s" + "-w" + ]; + + preBuild = '' + rm -rf web/dist + mkdir -p web/dist + cp -R ${web}/. web/dist/ + ''; + + meta = { + description = "A server API for hosting remote ZenNotes vaults"; + homepage = "https://zennotes.org/"; + changelog = "https://github.com/ZenNotes/zennotes/releases/tag/v${finalAttrs.version}"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ justkrysteq ]; + mainProgram = finalAttrs.pname; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; +}) diff --git a/packaging/nix/release-data.json b/packaging/nix/release-data.json new file mode 100644 index 00000000..593808ac --- /dev/null +++ b/packaging/nix/release-data.json @@ -0,0 +1,6 @@ +{ + "version": "2.3.0", + "hash": "sha256-+tLPVnnMbtMa5blSwHav9ZMlnkUsrdG62mMGxhbmy6g=", + "npmDepsHash": "sha256-7IpGnxVjaJvfSZyKjOylGMhFqa1bx8Ry5O1yqYfNnCE=", + "vendorHash": "sha256-wYBF7CjM6AvoWMWql9hFmIaj6pCmli4vOef6POyGkfU=" +}