diff --git a/.gitignore b/.gitignore
index ba1e798d..2cb485e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,10 @@
node_modules
out
dist
+.turbo
+apps/*/.turbo
+apps/web/dist
+apps/server/bin
.DS_Store
*.log
.env
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..8fc31b9e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,62 @@
+# syntax=docker/dockerfile:1.7
+
+# ZenNotes self-hosted build from the monorepo.
+#
+# Stages:
+# 1. web-build -> npm workspace install + Vite build for apps/web
+# 2. server-build -> Go build for apps/server with the web bundle embedded
+# 3. runtime -> minimal image with only the server binary
+
+FROM node:22-alpine AS web-build
+WORKDIR /app
+
+COPY package.json package-lock.json turbo.json tsconfig.base.json tsconfig.json tailwind.config.js postcss.config.js ./
+COPY apps/web/package.json apps/web/package.json
+COPY packages/app-core/package.json packages/app-core/package.json
+COPY packages/bridge-contract/package.json packages/bridge-contract/package.json
+COPY packages/shared-domain/package.json packages/shared-domain/package.json
+COPY packages/shared-ui/package.json packages/shared-ui/package.json
+COPY apps/desktop/package.json apps/desktop/package.json
+COPY apps/server/package.json apps/server/package.json
+
+RUN npm ci --no-audit --no-fund --loglevel=error
+
+COPY apps apps
+COPY packages packages
+
+RUN npm run build --workspace @zennotes/web
+
+FROM golang:1.22-alpine AS server-build
+WORKDIR /app
+
+COPY apps/server/go.mod apps/server/go.sum ./apps/server/
+WORKDIR /app/apps/server
+RUN go mod download
+
+WORKDIR /app
+COPY apps/server apps/server
+COPY --from=web-build /app/apps/web/dist/ /app/apps/server/web/dist/
+
+ENV CGO_ENABLED=0 \
+ GOOS=linux \
+ GOFLAGS=-trimpath
+
+WORKDIR /app/apps/server
+RUN go build -ldflags="-s -w" -o /out/zennotes-server ./cmd/zennotes-server
+
+FROM scratch
+LABEL org.opencontainers.image.title="ZenNotes" \
+ org.opencontainers.image.description="Self-hosted ZenNotes web/server bundle from the monorepo." \
+ org.opencontainers.image.source="https://github.com/ZenNotes/zennotes"
+
+COPY --from=server-build /out/zennotes-server /zennotes-server
+
+ENV ZENNOTES_BIND=0.0.0.0:7878 \
+ ZENNOTES_CONFIG_PATH=/data/server.json \
+ ZENNOTES_DEFAULT_VAULT_PATH=/workspace \
+ ZENNOTES_BROWSE_ROOTS=/workspace
+
+EXPOSE 7878
+VOLUME ["/workspace", "/data"]
+
+ENTRYPOINT ["/zennotes-server"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..6186e74d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,112 @@
+IMAGE ?= zennotes-selfhosted:local
+PORT ?= 7878
+CONTENT_ROOT ?= ./vault
+DATA ?= ./data
+APP_URL ?= http://localhost:$(PORT)
+COMPOSE := $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
+OPEN_BROWSER := $(shell command -v open 2>/dev/null || command -v xdg-open 2>/dev/null)
+
+.PHONY: help install dev desktop web-dev server-dev web-stack \
+ build desktop-build web-build server-build \
+ up down restart logs status open rebuild nuke clean
+
+help:
+ @echo ""
+ @echo " Setup"
+ @echo " make install — install npm workspace dependencies"
+ @echo ""
+ @echo " Local development"
+ @echo " make desktop — run the Electron desktop app in dev mode"
+ @echo " make web-dev — run the Vite web client in dev mode"
+ @echo " make server-dev — run the Go server in dev mode"
+ @echo " make web-stack — run server + web dev together"
+ @echo ""
+ @echo " Local builds"
+ @echo " make build — build the full monorepo"
+ @echo " make desktop-build — build the Electron desktop app"
+ @echo " make web-build — build apps/web"
+ @echo " make server-build — build apps/server with the latest embedded web bundle"
+ @echo ""
+ @echo " Docker"
+ @echo " make up — build and start the self-hosted server"
+ @echo " make down — stop the container"
+ @echo " make restart — restart the container"
+ @echo " make logs — follow logs"
+ @echo " make status — show compose status"
+ @echo " make open — open the app in your browser"
+ @echo " make rebuild — force a full rebuild"
+ @echo " make nuke — tear down and remove local image/build output"
+ @echo " make clean — remove local web/server build output"
+ @echo ""
+ @echo " Useful Docker vars"
+ @echo " CONTENT_ROOT=~/iCloud Drive/Obsidian — host folder mounted into the container"
+ @echo " PORT=7878 — host port"
+ @echo ""
+
+install:
+ npm ci
+
+dev: desktop
+
+desktop:
+ npm run dev:desktop
+
+web-dev:
+ npm run dev:web
+
+server-dev:
+ npm run dev:server
+
+web-stack:
+ npm run dev:web-stack
+
+build:
+ npm run build
+
+desktop-build:
+ npm run build --workspace @zennotes/desktop
+
+web-build:
+ npm run build --workspace @zennotes/web
+
+up:
+ @mkdir -p "$(CONTENT_ROOT)" "$(DATA)"
+ @ZENNOTES_IMAGE="$(IMAGE)" ZENNOTES_HOST_PORT="$(PORT)" ZENNOTES_HOST_CONTENT_ROOT="$(CONTENT_ROOT)" ZENNOTES_HOST_DATA="$(DATA)" $(COMPOSE) up --build -d
+ @printf "\nZenNotes is running at $(APP_URL)\n\n"
+ifneq ($(strip $(OPEN_BROWSER)),)
+ @$(OPEN_BROWSER) $(APP_URL) >/dev/null 2>&1 || true
+endif
+
+down:
+ @$(COMPOSE) down
+
+restart:
+ @$(COMPOSE) restart zennotes 2>/dev/null || $(MAKE) --no-print-directory up
+
+logs:
+ @$(COMPOSE) logs -f
+
+status:
+ @$(COMPOSE) ps
+
+open:
+ifneq ($(strip $(OPEN_BROWSER)),)
+ @$(OPEN_BROWSER) $(APP_URL)
+else
+ @echo "Open $(APP_URL) manually."
+endif
+
+rebuild:
+ @mkdir -p "$(CONTENT_ROOT)" "$(DATA)"
+ @ZENNOTES_IMAGE="$(IMAGE)" ZENNOTES_HOST_PORT="$(PORT)" ZENNOTES_HOST_CONTENT_ROOT="$(CONTENT_ROOT)" ZENNOTES_HOST_DATA="$(DATA)" $(COMPOSE) build --no-cache
+ @$(MAKE) --no-print-directory up
+
+nuke:
+ @$(COMPOSE) down --rmi local --volumes || true
+ @rm -rf apps/web/dist apps/server/bin apps/server/web/dist $(DATA)
+
+server-build: web-build
+ npm run build --workspace @zennotes/server
+
+clean:
+ rm -rf apps/web/dist apps/server/bin apps/server/web/dist
diff --git a/README.md b/README.md
index 7eacf4f6..87a1005d 100644
--- a/README.md
+++ b/README.md
@@ -1,47 +1,56 @@
# ZenNotes
-
+
-ZenNotes is a keyboard-first desktop notes app built on Electron,
-React, TypeScript, and CodeMirror 6. It keeps notes as ordinary local
-Markdown files, adds a Vim-friendly editing and navigation model, and
-ships a first-party MCP server so tools like Claude Code, Claude
-Desktop, and Codex can work directly against the same vault.
+ZenNotes is a keyboard-first Markdown notes app with a shared product core and multiple runtimes:
-The goal is not "another markdown renderer." The goal is a local-first
-notes environment that still feels fast and intentional when you live in
-it all day.
+- a desktop app built with Electron
+- a self-hosted web app backed by a Go server
+- a future hosted deployment mode built on the same web/server stack
-Download the latest build from [GitHub Releases](https://github.com/ZenNotes/zennotes/releases/latest).
+ZenNotes 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 MCP integration on top of the files you already own.
+
+Download the latest desktop build from [GitHub Releases](https://github.com/ZenNotes/zennotes/releases/latest).
+Website: [zennotes.org](https://zennotes.org)
## What ZenNotes is for
- writing and organizing plain-file Markdown notes without a database
- moving quickly with keyboard-first navigation and Vim motions
- working across edit, split, and preview modes without losing context
-- keeping task management, tags, search, archive, and trash inside the
- same vault
+- keeping tasks, tags, search, archive, trash, and quick capture inside the same vault
- rendering math and diagrams directly from Markdown
-- letting MCP-capable coding / note-taking tools read and write the
- vault safely through a bundled server
+- exposing the vault to MCP-capable tools through a first-party server
+- self-hosting the app on your own machine or home server
+
+## Product modes
+
+ZenNotes now ships from one monorepo with one shared app core.
-## Core product ideas
+- `desktop`: Electron shell, native menus, updater, floating windows, desktop packaging
+- `self-hosted`: browser frontend plus Go server, suitable for home servers and LAN use
+- `hosted`: planned as the same web/server stack with auth and multi-user storage added later
+
+The source of truth for user-facing features is the shared UI in `packages/app-core`.
+
+## Core ideas
### Plain files first
-Every note lives on disk as a normal `.md` file inside a chosen vault.
-ZenNotes does not invent a hidden store for note content. The app adds
-views, metadata extraction, search, and rendering on top of the files
-you already own.
+Every note is a normal `.md` file inside a chosen vault. ZenNotes does not store note content in a hidden database.
### Keyboard-first by default
-The app assumes you want fast navigation, not pointer-heavy chrome.
-There is first-class Vim mode, leader-key flows, remappable shortcuts,
-buffer switching, pane motion, command palette access, local ex prompts,
-and which-key style hint overlays.
+ZenNotes assumes you want to move fast:
+
+- first-class Vim mode
+- leader-key flows
+- command palette
+- pane and tab motion
+- local ex commands
+- built-in help
### Preview is part of the workflow
@@ -51,36 +60,39 @@ ZenNotes supports:
- preview mode
- split mode
- pinned reference panes
-- detached note windows
+- detached note windows on desktop
-That makes it useful both as a writing tool and as a reading /
-researching tool.
+### Shared vault, shared tooling
-### AI tooling should work with the real vault
-
-ZenNotes includes a standalone MCP server entry and a settings UI that
-can install the server into supported clients. The intent is simple:
-your assistant should operate on the same notes you do, using normal
-Markdown files and safe vault operations.
+ZenNotes includes a first-party MCP server and desktop install flows for compatible clients, so tools can work on the same vault you do instead of a copy.
## Feature overview
### Notes, folders, and lifecycle
-Each vault is organized into four top-level folders:
+ZenNotes can:
+
+- create, rename, duplicate, move, archive, unarchive, trash, restore, and reveal notes and folders
+- watch the vault for external changes
+- reopen your workspace layout with tabs and panes
+
+System folders still exist, but the vault model is more flexible now:
-- `inbox/` for active work
-- `quick/` for fast capture
-- `archive/` for cold storage
-- `trash/` for recoverable deletion
+- `quick`, `archive`, and `trash` remain built-in lifecycle areas
+- the main notes area can be either:
+ - `inbox/`
+ - the vault root directly, for Obsidian-style flat vaults
-ZenNotes can create, rename, duplicate, move, archive, unarchive, trash,
-restore, and reveal notes and folders. The app also watches the vault on
-disk, so external edits are reflected back into the UI.
+The built-in folder labels are also customizable in the UI without changing the underlying internal ids.
-Attachments are stored under a vault-local attachments directory
-(`attachements/` in the current implementation, with legacy `_assets/`
-support still recognized).
+### Daily notes
+
+Daily 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`
### Editor and preview
@@ -89,13 +101,13 @@ The editor stack is CodeMirror 6 with a Markdown-oriented workflow:
- live preview behavior in the editor
- heading folding
- outline extraction and jumps
-- word wrap controls
- configurable line numbers
+- configurable line-height and typography controls
- syntax highlighting for fenced code blocks
-- local asset embedding
-- inline PDF support
+- wiki links, callouts, tables, footnotes, and local embeds
+- Vim block cursor and keyboard navigation
-Preview mode renders:
+Preview and split mode support:
- GitHub-flavored Markdown
- KaTeX math
@@ -107,254 +119,365 @@ Preview mode renders:
- footnotes
- wiki links and backlinks
-Expanded diagram viewing is built in for diagram-heavy notes.
-
-### Search, tags, tasks, and built-in views
+### Search, tasks, tags, and built-in views
ZenNotes includes:
-- note search by title / path
+- note search by title and path
- vault-wide text search
- tags view
- tasks view
- archive view
- trash view
- quick notes view
-- built-in help view
+- built-in help/manual
-Vault text search can use the built-in engine, `ripgrep`, or `fzf`,
-with auto-detection and optional custom binary paths exposed in
-Settings.
+Vault text search can use the built-in engine, `ripgrep`, or `fzf`, with auto-detection and optional custom binary paths.
-Task parsing is markdown-native: checkboxes stay as normal `- [ ]` /
-`- [x]` lines in the note body and are surfaced into the global Tasks
-view.
+### Obsidian-friendly vault support
-### Themes, fonts, and app customization
+ZenNotes now works better with existing Obsidian-style vaults.
-The app exposes a substantial settings surface:
+- primary notes can live at the vault root instead of requiring `inbox/`
+- loose files anywhere in the vault are surfaced as files/assets
+- embedded files like `![[image.png]]` resolve more like Obsidian
+- new referenced files default to the vault root instead of a required attachments folder
+- legacy `attachements/` and `_assets/` folders are still recognized
-- multiple theme families and light / dark / auto modes
-- independent interface, text, and monospace font selection
+This means imported vaults with top-level notes, folders, and loose images/files behave much more naturally.
+
+### Files and local assets
+
+ZenNotes supports local files in notes and in the sidebar/list views.
+
+- local images and files can appear directly in the vault tree
+- desktop context menus include reveal-in-file-manager actions
+- desktop uses Finder on macOS and the platform file manager on Windows/Linux
+- watcher updates now include non-Markdown file changes, so deleting files externally updates the UI without a manual refresh
+
+### Themes, fonts, and customization
+
+The settings surface includes:
+
+- theme families and light/dark/auto modes
+- interface, text, and monospace font selection
- editor font size and line-height controls
- preview and editor width controls
-- dark-sidebar option
- content alignment
- keymap overrides
- Vim toggles and leader hint behavior
- search backend selection
+- vault layout settings
+- daily notes settings
+- system-folder display labels
-### MCP integration
+## Desktop vs web
-ZenNotes ships a dedicated MCP server and installation flows for:
+Both runtimes share the same core app, but they do not expose identical platform features.
-- Claude Code
-- Claude Desktop
-- Codex CLI
-
-The app can:
+Desktop-only features include:
-- detect whether the ZenNotes MCP entry is installed
-- install or uninstall it for each supported client
-- show the exact runtime used to launch the server
-- edit the server's default note-shaping instructions from Settings
+- native menus
+- app updater
+- floating note windows
+- MCP install/uninstall flows for supported clients
+- reveal in Finder / platform file manager
+- packaging and signed releases
-The MCP server exposes vault operations like reading notes, creating
-notes, moving notes, appending to notes, searching text, listing notes,
-listing assets, toggling tasks, and related filesystem-safe actions.
+Web/self-hosted mode includes:
-## Download
+- the same shared note UI and workflows
+- a Go backend for vault access and file watching
+- a server-side vault picker/browser
+- browser access on a LAN or home server
-The fastest way to get the app is the latest GitHub release:
+## Monorepo layout
-- [Latest release](https://github.com/ZenNotes/zennotes/releases/latest)
+ZenNotes now uses a single monorepo.
-Release assets are built per platform in GitHub Actions and uploaded to
-the matching release automatically:
+```text
+apps/
+ desktop/ Electron shell, preload, updater, packaging
+ web/ Vite/PWA shell and HTTP bridge
+ server/ Go server for self-hosted and hosted deployments
+packages/
+ app-core/ Shared React application and renderer logic
+ bridge-contract/ Typed runtime contract between UI and host
+ shared-domain/ Shared types and note/task/view models
+ shared-ui/ Reusable UI primitives
+tooling/
+ scripts/ Shared tooling hooks and migration scripts
+docs/
+```
-- macOS: `.dmg` and `.zip`
-- Windows: installer `.exe` and `.zip`
-- Linux: `.AppImage` and `.deb`
+Read [docs/monorepo-architecture.md](docs/monorepo-architecture.md) for the architectural boundary between the shared app core and the platform-specific shells.
-If the repository is private, GitHub access to the release assets follows
-the repository's normal permissions.
+## Quick start
-## Vault model
+### Requirements
-ZenNotes expects a chosen vault root and will ensure the basic folder
-layout exists on first open. The app also seeds a welcome note the first
-time a vault is initialized.
+- Node.js 22+
+- npm
+- Go 1.22+ for the server build path
+- Docker optional, for self-hosting
-High-level behavior:
+### Install dependencies
-- only `inbox`, `quick`, and `archive` are treated as searchable note
- folders
-- `trash` is recoverable deletion, not part of normal search
-- tags and wiki links are extracted from note bodies
-- attachment presence is inferred from local links in Markdown
-- vault changes are watched and pushed into the renderer over IPC
+```bash
+npm ci
+```
-## Development
+## Local development
-### Requirements
+### Desktop app
-- Node.js 22+ recommended
-- npm
-- macOS, Windows, or Linux for Electron development
+```bash
+npm run dev:desktop
+```
-### Install
+or:
```bash
-npm install
+make desktop
```
-### Run the app in development
+### Web client
```bash
-npm run dev
+npm run dev:web
```
-This starts the Electron main process, preload bundle, and renderer via
-`electron-vite`.
+or:
-### Typecheck
+```bash
+make web-dev
+```
+
+### Go server
```bash
-npm run typecheck
+npm run dev:server
```
-### Build
+or:
```bash
-npm run build
+make server-dev
```
-This produces:
+### Web + server together
-- `out/main` for the Electron main process
-- `out/preload` for the preload bridge
-- `out/renderer` for the renderer bundle
+```bash
+npm run dev:web-stack
+```
-The standalone MCP server is built as `out/main/mcp.js`.
+or:
-## Packaging
+```bash
+make web-stack
+```
-Available package scripts:
+Important dev note:
+
+- the browser app and the Go server are separate processes in dev mode
+- frontend-only changes usually need only the web dev server
+- backend changes need the Go server restarted
+- if the web client is newer than the running server, ZenNotes now shows a clearer error instead of raw 404 noise for newer API flows like the vault picker
+
+## Root scripts
+
+From the repository root:
+
+| Script | Purpose |
+| ----------------------- | ----------------------------------------------- |
+| `npm run dev` | Alias for `npm run dev:desktop` |
+| `npm run dev:desktop` | Run the Electron desktop app in development |
+| `npm run dev:web` | Run the Vite web client |
+| `npm run dev:server` | Run the Go server |
+| `npm run dev:web-stack` | Run web + server development together |
+| `npm run start` | Start the built desktop app |
+| `npm run typecheck` | Run monorepo typechecks |
+| `npm run test` | Run monorepo tests |
+| `npm run test:run` | Run the full test suite |
+| `npm run build` | Build the monorepo and then build the Go server |
+| `npm run build:prod` | Typecheck + test + build |
+| `npm run pack` | Desktop packaged output |
+| `npm run dist:mac` | Build macOS desktop distributables |
+| `npm run dist:win` | Build Windows desktop distributables |
+| `npm run dist:linux` | Build Linux desktop distributables |
+
+## Makefile commands
+
+The root `Makefile` provides a simpler interface:
+
+| Command | Purpose |
+| -------------------- | ------------------------------------------------------- |
+| `make install` | Install workspace dependencies |
+| `make desktop` | Run the Electron app in dev mode |
+| `make web-dev` | Run the web client |
+| `make server-dev` | Run the Go server |
+| `make web-stack` | Run web + server together |
+| `make build` | Build the full monorepo |
+| `make desktop-build` | Build the Electron app |
+| `make web-build` | Build `apps/web` |
+| `make server-build` | Build `apps/server` with the latest embedded web bundle |
+| `make up` | Build and start the self-hosted Docker stack |
+| `make down` | Stop the Docker stack |
+| `make restart` | Restart the Docker stack |
+| `make logs` | Follow Docker logs |
+| `make status` | Show Docker status |
+| `make open` | Open the self-hosted app in a browser |
+| `make rebuild` | Force a full Docker rebuild |
+| `make nuke` | Remove local Docker image/build output |
+| `make clean` | Remove local web/server build output |
+
+Run `make help` to print the same summary.
+
+## Self-hosting with Docker
+
+### Start the self-hosted app
-| Script | Purpose |
-| --- | --- |
-| `npm run dev` | Run the app in development mode |
-| `npm run build` | Build all Electron bundles |
-| `npm run start` | Preview the built app |
-| `npm run test:run` | Run the automated test suite |
-| `npm run typecheck` | Run node + web TypeScript checks |
-| `npm run pack` | Build and create unpacked app output |
-| `npm run dist:mac` | Build macOS distributables |
-| `npm run dist:win` | Build Windows distributables |
-| `npm run dist:linux` | Build Linux distributables |
+```bash
+make up
+```
-Icon packaging notes live in [build/README.md](build/README.md).
+Then open:
-### Signed macOS releases
+- [http://localhost:7878](http://localhost:7878)
-Public macOS releases are wired for hardened runtime signing and
-notarization. The release workflow expects these GitHub Actions secrets:
+### Default Docker mounts
-- `MACOS_CERTIFICATE_P12`
-- `MACOS_CERTIFICATE_PASSWORD`
-- `APPLE_ID`
-- `APPLE_APP_SPECIFIC_PASSWORD`
-- `APPLE_TEAM_ID`
+The current Docker setup mounts:
-Optional Windows signing can be supplied with:
+- host `./vault` -> container `/workspace`
+- host `./data` -> container `/data`
-- `WINDOWS_CERTIFICATE_P12`
-- `WINDOWS_CERTIFICATE_PASSWORD`
+The server stores its config under `/data/server.json` by default.
-Tagged releases fail the macOS release job if the required Apple signing
-or notarization secrets are missing, which prevents accidentally shipping
-an unsigned public mac build.
+### Choosing a different host folder
-## Repository layout
+You can mount a different host content root:
-```text
-src/
- main/ Electron main process, vault I/O, watchers, TikZ, MCP install management
- preload/ Context bridge / IPC surface exposed to the renderer
- renderer/ React app, editor UI, preview UI, settings, panes, styles
- mcp/ Standalone MCP server entry, tool definitions, default instructions
- shared/ Shared IPC contracts and cross-process types
-build/ Packaging resources (icons, installer assets)
-out/ Built output generated by electron-vite
+```bash
+CONTENT_ROOT="$HOME/Library/Mobile Documents/com~apple~CloudDocs" make up
```
-## Architecture notes
+That works for paths with spaces too.
-### Main process
+Useful variables:
-`src/main/` is responsible for:
+- `CONTENT_ROOT`: host folder mounted into the container
+- `DATA`: host directory used for persisted server config
+- `PORT`: published host port
+- `IMAGE`: Docker image tag
-- window lifecycle
-- persisted app config
-- vault selection and layout bootstrapping
-- filesystem operations for notes, folders, archive, trash, and assets
-- vault watching
-- task scanning
-- vault-wide text search
-- TikZ rendering
-- MCP client install / uninstall flows
+### Docker browse model
+
+Important limitation:
+
+- the Docker container can only browse folders that are mounted into it
+- it cannot browse your entire host filesystem
+
+So if you want to browse an Obsidian vault, iCloud Drive, or another directory from the web picker, that directory needs to be mounted into the container first.
+
+### Relevant container env vars
+
+The current compose/runtime flow supports:
+
+- `ZENNOTES_BIND`
+- `ZENNOTES_CONFIG_PATH`
+- `ZENNOTES_DEFAULT_VAULT_PATH`
+- `ZENNOTES_BROWSE_ROOTS`
+- `ZENNOTES_VAULT_PATH`
+
+Behavior notes:
+
+- `ZENNOTES_VAULT_PATH` hard-locks the server to a specific vault path
+- `ZENNOTES_DEFAULT_VAULT_PATH` sets the starting vault when no saved selection exists
+- `ZENNOTES_BROWSE_ROOTS` limits what the web picker can browse
+
+## Web vault picker
+
+The self-hosted web build now includes a server-backed vault chooser.
+
+- it browses folders on the server, not the browser machine
+- it starts from sensible locations instead of requiring blind path typing
+- it supports a simpler folder-picker flow for choosing the active vault
+- on macOS-hosted servers, common shortcuts like iCloud Drive are supported when available
+
+If you start the server with `ZENNOTES_VAULT_PATH`, manual vault switching is intentionally disabled.
+
+## MCP integration
+
+ZenNotes ships a dedicated MCP server and desktop install flows for:
-### Preload
+- Claude Code
+- Claude Desktop
+- Codex
-`src/preload/index.ts` exposes the app's typed bridge through
-`window.zen`, including vault operations, search, task scanning, window
-controls, TikZ rendering, clipboard helpers, and MCP settings actions.
+The desktop app can:
-### Renderer
+- detect whether the ZenNotes MCP entry is installed
+- install or uninstall it for each supported client
+- show the exact runtime used to launch the server
+- edit the server's default instructions from Settings
-`src/renderer/` holds the desktop UI:
+The MCP server exposes vault operations such as:
-- sidebar, note list, editor, preview, floating note windows
-- command palette, help view, tags/tasks/archive/trash views
-- pane layout and tab state
-- settings UI
-- theme system
-- diagram rendering for Mermaid, TikZ, JSXGraph, and function-plot
+- reading notes
+- creating notes
+- moving notes
+- appending to notes
+- listing notes
+- searching vault text
+- listing files/assets
+- toggling tasks
-State is managed with Zustand.
+## Building and packaging desktop releases
-### MCP server
+### Build everything
-`src/mcp/index.ts` is a standalone stdio server built on
-`@modelcontextprotocol/sdk`. It exposes vault operations to compatible
-clients and ships opinionated note-writing instructions tailored to
-ZenNotes' markdown features and vault model.
+```bash
+npm run build:prod
+```
-Those instructions can be overridden by the user and are persisted as a
-plain Markdown file under the app's user-data directory.
+### Desktop package scripts
-## Why the MCP story matters here
+```bash
+npm run pack
+npm run dist:mac
+npm run dist:win
+npm run dist:linux
+```
-ZenNotes is intentionally opinionated about how assistants should write
-notes:
+### Signed macOS releases
-- use the vault as shared storage, not as a scratchpad
-- prefer surgical edits over blind overwrites
-- lean on KaTeX and diagram fences instead of ASCII approximations
-- connect notes through wiki links
-- preserve user-owned content and frontmatter
+Public macOS releases are wired for hardened runtime signing and notarization.
-That behavior is encoded in the bundled MCP instructions and surfaced in
-Settings so users can tune it without forking the app.
+The GitHub Actions release workflow expects:
+
+- `MACOS_CERTIFICATE_P12`
+- `MACOS_CERTIFICATE_PASSWORD`
+- `APPLE_ID`
+- `APPLE_APP_SPECIFIC_PASSWORD`
+- `APPLE_TEAM_ID`
+
+Optional Windows signing can be supplied with:
+
+- `WINDOWS_CERTIFICATE_P12`
+- `WINDOWS_CERTIFICATE_PASSWORD`
+
+Tagged releases fail the macOS release job if the required Apple signing or notarization secrets are missing. That prevents accidentally shipping an unsigned public mac build.
## Current status
-ZenNotes is still an actively changing codebase. The app already has a
-meaningful feature surface, but the product and interaction model are
-still being refined quickly.
+ZenNotes is actively evolving. The desktop app is the more mature runtime today, while the self-hosted web/server path is being brought into parity through the shared monorepo core.
+
+That means:
-If you are contributing, expect UI polish, keyboard flows, diagram
-rendering, and MCP ergonomics to keep evolving.
+- many features are shared already
+- some platform-specific behavior still lives in the shell layers
+- the README will keep evolving as desktop, web, and self-hosted flows converge further
## License
diff --git a/build/Icon/Icon-iOS-Dark-1024x1024@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-1024x1024@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-1024x1024@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-1024x1024@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-128x128@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-128x128@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-128x128@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-128x128@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-128x128@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-128x128@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-128x128@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-128x128@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-16x16@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-16x16@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-16x16@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-16x16@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-16x16@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-16x16@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-16x16@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-16x16@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-20x20@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-20x20@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-20x20@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-20x20@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-20x20@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-20x20@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-20x20@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-20x20@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-256x256@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-256x256@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-256x256@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-256x256@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-256x256@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-256x256@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-256x256@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-256x256@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-29x29@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-29x29@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-29x29@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-29x29@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-29x29@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-29x29@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-29x29@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-29x29@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-32x32@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-32x32@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-32x32@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-32x32@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-32x32@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-32x32@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-32x32@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-32x32@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-38x38@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-38x38@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-38x38@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-38x38@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-38x38@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-38x38@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-38x38@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-38x38@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-40x40@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-40x40@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-40x40@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-40x40@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-40x40@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-40x40@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-40x40@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-40x40@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-512x512@1x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-512x512@1x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-512x512@1x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-512x512@1x.png
diff --git a/build/Icon/Icon-iOS-Dark-60x60@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-60x60@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-60x60@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-60x60@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-60x60@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-60x60@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-60x60@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-60x60@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-64x64@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-64x64@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-64x64@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-64x64@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-64x64@3x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-64x64@3x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-64x64@3x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-64x64@3x.png
diff --git a/build/Icon/Icon-iOS-Dark-68x68@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-68x68@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-68x68@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-68x68@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-76x76@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-76x76@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-76x76@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-76x76@2x.png
diff --git a/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png b/apps/desktop/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png
similarity index 100%
rename from build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png
rename to apps/desktop/build/Icon/Icon-iOS-Dark-83.5x83.5@2x.png
diff --git a/build/README.md b/apps/desktop/build/README.md
similarity index 100%
rename from build/README.md
rename to apps/desktop/build/README.md
diff --git a/build/after-install.sh b/apps/desktop/build/after-install.sh
similarity index 100%
rename from build/after-install.sh
rename to apps/desktop/build/after-install.sh
diff --git a/build/entitlements.mac.inherit.plist b/apps/desktop/build/entitlements.mac.inherit.plist
similarity index 100%
rename from build/entitlements.mac.inherit.plist
rename to apps/desktop/build/entitlements.mac.inherit.plist
diff --git a/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist
similarity index 100%
rename from build/entitlements.mac.plist
rename to apps/desktop/build/entitlements.mac.plist
diff --git a/build/icon.png b/apps/desktop/build/icon.png
similarity index 100%
rename from build/icon.png
rename to apps/desktop/build/icon.png
diff --git a/build/icons/128x128.png b/apps/desktop/build/icons/128x128.png
similarity index 100%
rename from build/icons/128x128.png
rename to apps/desktop/build/icons/128x128.png
diff --git a/build/icons/16x16.png b/apps/desktop/build/icons/16x16.png
similarity index 100%
rename from build/icons/16x16.png
rename to apps/desktop/build/icons/16x16.png
diff --git a/build/icons/24x24.png b/apps/desktop/build/icons/24x24.png
similarity index 100%
rename from build/icons/24x24.png
rename to apps/desktop/build/icons/24x24.png
diff --git a/build/icons/256x256.png b/apps/desktop/build/icons/256x256.png
similarity index 100%
rename from build/icons/256x256.png
rename to apps/desktop/build/icons/256x256.png
diff --git a/build/icons/32x32.png b/apps/desktop/build/icons/32x32.png
similarity index 100%
rename from build/icons/32x32.png
rename to apps/desktop/build/icons/32x32.png
diff --git a/build/icons/48x48.png b/apps/desktop/build/icons/48x48.png
similarity index 100%
rename from build/icons/48x48.png
rename to apps/desktop/build/icons/48x48.png
diff --git a/build/icons/512x512.png b/apps/desktop/build/icons/512x512.png
similarity index 100%
rename from build/icons/512x512.png
rename to apps/desktop/build/icons/512x512.png
diff --git a/build/icons/64x64.png b/apps/desktop/build/icons/64x64.png
similarity index 100%
rename from build/icons/64x64.png
rename to apps/desktop/build/icons/64x64.png
diff --git a/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts
similarity index 83%
rename from electron.vite.config.ts
rename to apps/desktop/electron.vite.config.ts
index a62395df..a25eb99e 100644
--- a/electron.vite.config.ts
+++ b/apps/desktop/electron.vite.config.ts
@@ -75,7 +75,8 @@ export default defineConfig({
},
resolve: {
alias: {
- '@shared': resolve(__dirname, 'src/shared')
+ '@shared': resolve(__dirname, '../../packages/shared-domain/src'),
+ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
}
}
},
@@ -89,7 +90,8 @@ export default defineConfig({
},
resolve: {
alias: {
- '@shared': resolve(__dirname, 'src/shared')
+ '@shared': resolve(__dirname, '../../packages/shared-domain/src'),
+ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
}
}
},
@@ -109,8 +111,9 @@ export default defineConfig({
},
resolve: {
alias: {
- '@renderer': resolve(__dirname, 'src/renderer/src'),
- '@shared': resolve(__dirname, 'src/shared')
+ '@renderer': resolve(__dirname, '../../packages/app-core/src'),
+ '@shared': resolve(__dirname, '../../packages/shared-domain/src'),
+ '@bridge-contract': resolve(__dirname, '../../packages/bridge-contract/src')
}
},
plugins: [react()]
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
new file mode 100644
index 00000000..b6b0d903
--- /dev/null
+++ b/apps/desktop/package.json
@@ -0,0 +1,167 @@
+{
+ "name": "@zennotes/desktop",
+ "productName": "ZenNotes",
+ "version": "1.0.4",
+ "description": "ZenNotes desktop shell",
+ "private": true,
+ "main": "./out/main/index.js",
+ "author": {
+ "name": "Adib Hanna",
+ "email": "adibhanna@gmail.com"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ZenNotes/zennotes.git"
+ },
+ "bugs": {
+ "url": "https://github.com/ZenNotes/zennotes/issues"
+ },
+ "homepage": "https://github.com/ZenNotes/zennotes/releases/latest",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "dev": "electron-vite dev",
+ "build": "electron-vite build",
+ "build:prod": "npm run typecheck && npm run test:run && npm run build",
+ "start": "electron-vite preview",
+ "test": "vitest",
+ "test:run": "vitest run",
+ "typecheck:node": "tsc --noEmit -p tsconfig.node.json",
+ "typecheck:web": "tsc --noEmit -p tsconfig.web.json",
+ "typecheck": "npm run typecheck:node && npm run typecheck:web",
+ "pack": "npm run build:prod && electron-builder --dir",
+ "dist:mac": "npm run build:prod && electron-builder --mac --publish never",
+ "dist:win": "npm run build:prod && electron-builder --win --publish never",
+ "dist:linux": "npm run build:prod && electron-builder --linux --publish never"
+ },
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.3",
+ "@codemirror/commands": "^6.7.1",
+ "@codemirror/lang-markdown": "^6.3.1",
+ "@codemirror/language": "^6.10.6",
+ "@codemirror/language-data": "^6.5.1",
+ "@codemirror/search": "^6.5.8",
+ "@codemirror/state": "^6.5.0",
+ "@codemirror/view": "^6.35.3",
+ "@lezer/highlight": "^1.2.1",
+ "@modelcontextprotocol/sdk": "^1.29.0",
+ "@replit/codemirror-vim": "^6.3.0",
+ "@zennotes/app-core": "*",
+ "@zennotes/bridge-contract": "*",
+ "@zennotes/shared-domain": "*",
+ "chokidar": "^4.0.3",
+ "codemirror": "^6.0.1",
+ "dompurify": "^3.3.4",
+ "electron-updater": "^6.8.3",
+ "font-list": "^2.0.2",
+ "function-plot": "^1.25.3",
+ "fuse.js": "^7.0.0",
+ "gray-matter": "^4.0.3",
+ "highlight.js": "^11.10.0",
+ "jsxgraph": "^1.12.2",
+ "katex": "^0.16.15",
+ "mermaid": "^11.4.1",
+ "node-tikzjax": "^1.0.5",
+ "prettier": "^3.8.2",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "rehype-highlight": "^7.0.1",
+ "rehype-katex": "^7.0.1",
+ "rehype-raw": "^7.0.0",
+ "rehype-stringify": "^10.0.1",
+ "remark-breaks": "^4.0.0",
+ "remark-frontmatter": "^5.0.0",
+ "remark-gfm": "^4.0.0",
+ "remark-math": "^6.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.1.1",
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.0.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@electron/notarize": "^3.1.0",
+ "@types/node": "^25.6.0",
+ "@types/react": "^18.3.17",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "electron": "^41.2.1",
+ "electron-builder": "^25.1.8",
+ "electron-vite": "^2.3.0",
+ "jsdom": "^29.0.2",
+ "postcss": "^8.5.10",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.2",
+ "vite": "^5.4.11",
+ "vitest": "^2.1.8"
+ },
+ "build": {
+ "appId": "com.adibhanna.zennotes",
+ "executableName": "ZenNotes",
+ "productName": "ZenNotes",
+ "artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
+ "asar": true,
+ "compression": "maximum",
+ "npmRebuild": false,
+ "buildDependenciesFromSource": false,
+ "directories": {
+ "buildResources": "build",
+ "output": "../../dist"
+ },
+ "files": [
+ "out/**/*",
+ "!**/*.map"
+ ],
+ "extraResources": [
+ {
+ "from": "build/icon.png",
+ "to": "icon.png"
+ }
+ ],
+ "afterSign": "scripts/notarize.cjs",
+ "publish": [
+ {
+ "provider": "github",
+ "owner": "ZenNotes",
+ "repo": "zennotes"
+ }
+ ],
+ "electronUpdaterCompatibility": ">=2.16",
+ "mac": {
+ "category": "public.app-category.productivity",
+ "hardenedRuntime": true,
+ "gatekeeperAssess": false,
+ "entitlements": "build/entitlements.mac.plist",
+ "entitlementsInherit": "build/entitlements.mac.inherit.plist",
+ "extendInfo": {
+ "CFBundleName": "ZenNotes",
+ "CFBundleDisplayName": "ZenNotes"
+ },
+ "target": [
+ "dmg",
+ "zip"
+ ]
+ },
+ "win": {
+ "target": [
+ "nsis",
+ "zip"
+ ]
+ },
+ "linux": {
+ "target": [
+ "AppImage",
+ "deb"
+ ],
+ "icon": "build/icons",
+ "maintainer": "Adib Hanna ",
+ "category": "Office"
+ },
+ "deb": {
+ "afterInstall": "build/after-install.sh"
+ }
+ }
+}
diff --git a/apps/desktop/postcss.config.js b/apps/desktop/postcss.config.js
new file mode 100644
index 00000000..85f717cc
--- /dev/null
+++ b/apps/desktop/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+}
diff --git a/scripts/notarize.cjs b/apps/desktop/scripts/notarize.cjs
similarity index 100%
rename from scripts/notarize.cjs
rename to apps/desktop/scripts/notarize.cjs
diff --git a/src/main/demo-tour-data.ts b/apps/desktop/src/main/demo-tour-data.ts
similarity index 83%
rename from src/main/demo-tour-data.ts
rename to apps/desktop/src/main/demo-tour-data.ts
index 0f7078aa..cf306fa7 100644
--- a/src/main/demo-tour-data.ts
+++ b/apps/desktop/src/main/demo-tour-data.ts
@@ -6,7 +6,7 @@ export interface DemoTourTemplateFile {
export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
{
path: "inbox/demo/00 — Start Here.md",
- body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local attachments\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n"
+ body: "# Start here — ZenNotes feature tour\n\nThis folder is a guided demo vault for ZenNotes as it exists today. It covers markdown rendering, keyboard-first workflows, search, views, settings, and the vault-level features that sit on top of plain files.\n\n## How to use this tour\n\n- Open notes in **Edit**, **Split**, and **Preview** to see where each feature is most useful.\n- Use `Space p` or the outline panel on longer notes.\n- Use `Space f` to search notes by title and path.\n- Use `Space s t` to fuzzy-search text across the vault.\n- Open **Help** from the footer or type `:help` from normal mode for the built-in manual.\n- Try `⌘.` to toggle **Zen mode** while reading any note here.\n\n## The tour\n\n1. [[01 — Markdown Basics]] — headings, emphasis, lists, blockquotes, frontmatter, and slash-command-friendly structure\n2. [[02 — Code Blocks]] — fenced code blocks, inline code, syntax highlighting, and code-writing workflows\n3. [[03 — Tables and Task Lists]] — tables, task metadata, and the vault-wide Tasks view\n4. [[04 — Math with KaTeX]] — inline math, block math, aligned equations, and formulas in preview\n5. [[05 — Mermaid Diagrams]] — flow, sequence, state, gantt, and graph diagrams rendered from markdown fences\n6. [[05b — Math Diagrams]] — TikZ, JSXGraph, and function-plot for paper-grade figures, interactive geometry, and quick plots\n7. [[06 — Callouts and Footnotes]] — callouts, footnotes, highlights, images, and local files\n8. [[07 — Wiki Links and Tags]] — wikilinks, tags, backlinks, connections, and search\n9. [[08 — Daily Notes]] — daily logs, quick capture, date shortcuts, and date-friendly note habits\n10. [[09 — Vim Cheat Sheet]] — the app-specific motions, leader flows, folds, and ex commands\n11. [[10 — Ideas and Tasks]] — a realistic note that composes multiple features at once\n12. [[11 — Workspace, Search, and Views]] — tabs, splits, outline, archive, trash, quick notes, and session restore\n13. [[12 — Settings and Keymaps]] — themes, fonts, leader hints, search backends, custom binary paths, and remappable shortcuts\n14. [[13 — Commands, Help, and Demo Tour]] — command palette discovery, ex commands, built-in Help, and starter-tour generation\n15. [[14 — Reference Pane and Floating Windows]] — pinned notes, research context, and detached note windows\n16. [[15 — Search Backends and Fuzzy Workflows]] — note search, vault text search, Auto resolution, fzf, ripgrep, and custom binary paths\n\n## What this demo folder covers\n\nZenNotes is more than a markdown renderer. Across this folder you can try:\n\n- plain file-based notes with no hidden database\n- live preview plus dedicated preview and split modes\n- heading folding and outline jumps\n- wikilinks, tags, backlinks, and unresolved-link discovery\n- quick capture via Quick Notes\n- Inbox, Archive, and Trash as separate lifecycle stages\n- vault-wide Tasks and Tags views\n- note search and vault text search\n- Mermaid, TikZ, JSXGraph, and function-plot diagram rendering\n- optional external search backends like `fzf` and `ripgrep`\n- slash commands and `@` date insertion\n- Vim mode, leader hints, ex commands, and pane motion\n- settings, keymap overrides, and appearance controls\n- command palette, built-in Help, and seeded onboarding content\n- reference-pane and floating-window workflows\n- session restore for panes, tabs, built-in views, and window bounds\n\n## The point\n\nEvery file here is ordinary markdown on disk. Open the folder in ZenNotes, `vim`, VS Code, or another markdown editor and the notes are still yours.\n\n#demo #reference #tour\n"
},
{
path: "inbox/demo/01 — Markdown Basics.md",
@@ -34,7 +34,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
},
{
path: "inbox/demo/06 — Callouts and Footnotes.md",
- body: "# Callouts, footnotes, attachments, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local attachments\n\nAttachments stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file under the vault attachments folder.\n\nExample image:\n\n\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n\n```\n\n## Attachment workflows\n\n- Use the footer **Assets** action to browse attachment files.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because attachments are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and assets live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n"
+ body: "# Callouts, footnotes, files, and embeds\n\nThis note covers the rich block-level extras that still live comfortably inside markdown files.\n\n## Callouts\n\nCallouts are blockquotes that start with `> [!type]`.\n\n> [!note]\n> Use note callouts for extra context that should stand out without becoming a new section.\n\n> [!tip] Keyboard tip\n> Press `Space o` to open the buffer switcher when tabs are hidden or you want to jump fast between open buffers.\n\n> [!warning]\n> Moving a note to Trash asks for confirmation, but permanently deleting from Trash is still destructive.\n\n> [!info] Multi-line\n> Callouts can contain:\n> - lists\n> - `inline code`\n> - [[07 — Wiki Links and Tags|wikilinks]]\n> - and multiple paragraphs\n\n> [!quote] Portable by design\n> ZenNotes adds workflow around markdown, not lock-in around data.\n\n## Footnotes\n\nFootnotes link both ways and stay readable in the raw file.[^workflow]\n\nFootnotes are useful for side comments that should not interrupt the main flow.[^tip]\n\n[^workflow]: Footnote references use `[^label]` inline and `[^label]: text` at the bottom of the note.\n[^tip]: They work well in long writing, specs, and research notes where parenthetical digressions get noisy.\n\n## Strikethrough and highlights\n\n~~Legacy wording~~ can stay visible for history, while ==highlights== are good for passages you want to notice quickly during review.\n\n## Images and local files\n\nFiles stay local to the vault. Dropping a file into the editor inserts a normal markdown reference to the file, and by default ZenNotes places it in the vault root.\n\nExample image:\n\n\n\nThat relative path is the recommended form because it keeps the note portable inside the vault:\n\n```md\n\n```\n\n## File workflows\n\n- Use the footer **Files** action to browse files anywhere in the vault.\n- Image embeds render inline in preview and split mode.\n- PDFs can be opened in the pinned reference pane so you can read beside your notes.\n- Because these are just files, reveal them in Finder and manage them with normal tools if you want.\n\nFor the larger reading workflow around pinned notes, PDFs, and detached note windows, see [[14 — Reference Pane and Floating Windows]].\n\n## Why this matters\n\nZenNotes is strongest when prose, references, and files live together:\n\n- callouts for guidance or warnings\n- footnotes for side context\n- images for screenshots and visual notes\n- PDFs in the reference pane for side-by-side reading\n\n#demo #reference #attachments\n"
},
{
path: "inbox/demo/07 — Wiki Links and Tags.md",
@@ -54,7 +54,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
},
{
path: "inbox/demo/11 — Workspace, Search, and Views.md",
- body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, attachments, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Assets\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Assets** for local attachments\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n"
+ body: "# Workspace, search, and views\n\nThis note covers the part of ZenNotes that is not just markdown rendering: how the workspace behaves while you are moving around a vault.\n\n## The three working zones\n\nZenNotes is organized around three persistent areas:\n\n1. **Sidebar** for folders, built-in rows, tags, and utility entry points\n2. **Note list** for the current folder, files, or list-like result sets\n3. **Editor pane** for tabs, splits, preview, built-in views, and focused writing\n\nThe useful part is that each zone has its own keyboard loop, so you can stay off the mouse without losing place.\n\n## Edit, split, and preview\n\nEach note can be viewed in three ways:\n\n- **Edit** for raw markdown authoring\n- **Split** for source and rendered output side by side\n- **Preview** for reading-only rendering\n\nYou can switch modes from the toolbar, from the command palette, or from ex commands like:\n\n```vim\n:view edit\n:view split\n:view preview\n```\n\n## Tabs, buffers, and panes\n\n- tabs can be on or off\n- panes can split right or down\n- if tabs are hidden, buffers are still open behind the scenes\n- `Space o` or `:buffers` opens the buffer switcher\n\nThis keeps ZenNotes usable for both tab-heavy and low-chrome workflows.\n\n## Search modes\n\n### Note search\n\n- `⌘P` globally\n- `Space f` in Vim mode\n- `⌘F` or `Ctrl+F` as an extra direct shortcut when Vim mode is off\n- searches note titles and paths\n\n### Vault text search\n\n- `Space s t`\n- searches matching text lines across note contents\n- opens the note and jumps to the matching line\n- can run on built-in search, `ripgrep`, or `fzf`\n- Settings show the runtime backend that is actually being used\n\n## Quick Notes, Inbox, Archive, Trash\n\nThese four areas represent different stages of note life:\n\n- **Quick Notes** for fast capture\n- **Inbox** for active notes\n- **Archive** for cold storage\n- **Trash** for recoverable deletion\n\nBehavior differs by design:\n\n- clicking **Quick Notes** still folds and unfolds the sidebar section\n- Quick Notes can also open as a dedicated list tab from its context menu\n- **Archive** opens as a main-pane list view\n- **Trash** opens as a main-pane recovery view\n\nThat keeps the sidebar singular instead of turning it into a second file browser.\n\n## Outline, connections, and references\n\n- **Outline** gives you a heading list for the active note\n- **Connections** show backlinks, outbound links, and unresolved links\n- **Reference pane** is for pinning a note or PDF beside your current work\n\nThis is the part of the app that becomes valuable once a vault turns into more than a pile of files.\n\n## Help, Settings, and Files\n\nThe footer utilities keep the secondary surfaces discoverable:\n\n- **Files** for local files\n- **Help** for the built-in manual\n- **Settings** for personalization, Vim behavior, search backends, fonts, layout, and keymaps\n\nFor the command palette and seeded onboarding flow, see [[13 — Commands, Help, and Demo Tour]].\nFor detached note workflows and side-by-side reading context, see [[14 — Reference Pane and Floating Windows]].\n\n## Zen mode\n\nZen mode hides:\n\n- title bar\n- sidebar\n- note list\n- tabs\n- pane header chrome\n- outline and connections\n- status bar\n\nOnly the active editor, preview, or split content remains. It is the cleanest way to focus on a single note.\n\n## Session restore\n\nZenNotes remembers:\n\n- open tabs\n- splits\n- built-in views like Help, Tasks, Archive, or Trash\n- sidebar layout\n- main window position, size, and maximized state\n\nClosing and reopening the app should bring you back to roughly where you left off instead of starting from a blank shell.\n\n#demo #workspace #search #reference\n"
},
{
path: "inbox/demo/12 — Settings and Keymaps.md",
@@ -62,11 +62,11 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
},
{
path: "inbox/demo/13 — Commands, Help, and Demo Tour.md",
- body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo attachment under the vault attachments folder\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo attachment\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n"
+ body: "# Commands, help, and demo tour\n\nZenNotes is keyboard-first, so discoverability matters. This note covers the command palette, the built-in Help manual, and the demo-tour commands that can seed a starter vault for new users.\n\n## Command palette\n\nOpen the command palette with:\n\n- `⇧⌘P`\n- `:commands`\n- `:cmd query`\n\nUse it when you cannot remember a shortcut, when Vim mode is off, or when you want to browse what the app can do without digging through menus.\n\nTypical commands worth trying:\n\n- `Open Help`\n- `Open Settings`\n- `Search notes`\n- `Generate Demo Tour Notes`\n- `Remove Demo Tour Notes`\n- `Switch to Edit Mode`\n- `Switch to Split Mode`\n- `Switch to Preview Mode`\n- `Open Tasks`\n- `Open Trash`\n\n## Ex commands\n\nIf you live in normal mode, the ex line is the fastest path for many actions:\n\n```vim\n:help\n:tasks\n:trash\n:buffers\n:view split\n:zen\n:cmd help\n```\n\nThe ex line also supports completion with `Tab`, including command arguments like `:view edit|split|preview` and `:zen toggle|on|off`.\n\n## Built-in Help\n\nZenNotes ships with an in-app manual instead of making you leave the app to learn it.\n\nWays to open it:\n\n- footer **Help**\n- `:help`\n- command palette → `Open Help`\n\nThe Help view covers:\n\n- quick start\n- core concepts\n- shortcuts\n- Vim flows\n- ex commands\n- settings\n- search backends\n\n## Demo tour commands\n\nThe demo vault itself is seedable from inside the app.\n\nUse:\n\n- command palette → `Generate Demo Tour Notes`\n- command palette → `Remove Demo Tour Notes`\n- `:demo_generate`\n- `:demo_remove`\n\n### What generation does\n\n- creates a guided note set under `inbox/demo`\n- adds the bundled demo file at the vault root\n- opens the tour start note so the onboarding flow begins immediately\n\n### What removal does\n\n- removes the seeded demo notes\n- removes the bundled demo file\n- leaves the rest of the vault alone\n\nThat makes the tour useful for:\n\n- first-time users\n- resettable demos\n- showing the product to someone else\n- smoke-testing renderer features in one place\n\n## Why this matters\n\nThe app can stay low-chrome and still be discoverable if:\n\n- commands are searchable\n- Help is built in\n- the starter content is one command away\n\nThat combination is a large part of what makes a keyboard-first app approachable instead of intimidating.\n\n## Try this now\n\n- Open the command palette and search for `help`\n- Run `:cmd zen`\n- Run `Generate Demo Tour Notes` in a test vault\n- Open [[12 — Settings and Keymaps]] after this note to see how the shortcuts behind these commands can be remapped\n\n#demo #commands #help #onboarding\n"
},
{
path: "inbox/demo/14 — Reference Pane and Floating Windows.md",
- body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local attachments\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n"
+ body: "# Reference pane and floating windows\n\nZenNotes is strongest when you can keep context visible while still writing. This note covers the pinned reference pane, link preview workflows, and floating notes.\n\n## Reference pane\n\nThe reference pane is for keeping a second document visible while you work in the main note.\n\nGood uses:\n\n- drafting against a spec\n- reading a PDF while taking notes\n- comparing two notes side by side\n- keeping a glossary or checklist open while editing\n\n## What can live there\n\n- another markdown note\n- a PDF\n- a linked document opened from the current note\n\nThis keeps the main pane focused on writing while the side pane holds supporting material.\n\n## Link-following flows\n\nWhen the cursor is on a wikilink or markdown link:\n\n- `gd` follows it in Vim mode\n- PDFs can pin into the reference pane\n- missing notes can be created from the link target\n\nThat means links are not just navigation. They can become working context.\n\n## Connections + reference workflow\n\nThe **Connections** panel works well with the reference pane:\n\n- inspect backlinks\n- move to a related note\n- peek a backlink\n- pin the most useful one beside the current draft\n\nThis is especially useful for research notes and longer documentation trees.\n\n## Floating windows\n\nSometimes you do not want a second pane inside the same layout. In that case, a note can open in its own floating window from the context menu.\n\nFloating windows are useful when:\n\n- you want a scratch note on another monitor\n- you are comparing two notes without disturbing the main layout\n- you want a temporary detached reference\n\nThey are intentional, separate work surfaces, not just accidental duplicate tabs.\n\n## Research pattern\n\nOne practical pattern:\n\n1. Keep the current draft in **Edit** or **Split**\n2. Open **Connections**\n3. Find a related note or PDF\n4. Pin it in the reference pane or open it in a floating window\n5. Keep writing without losing context\n\n## Good companion notes in this tour\n\n- [[07 — Wiki Links and Tags]] for backlinks, tags, and search\n- [[11 — Workspace, Search, and Views]] for the larger pane model\n- [[06 — Callouts and Footnotes]] for local files\n- [[10 — Ideas and Tasks]] for a note that benefits from supporting context\n\n## Try this now\n\n- Open this note, then pin [[11 — Workspace, Search, and Views]]\n- Open **Connections** on [[10 — Ideas and Tasks]]\n- Follow a wikilink with `gd`\n- Open a note in a floating window from its context menu\n\n#demo #reference #research #windows\n"
},
{
path: "inbox/demo/15 — Search Backends and Fuzzy Workflows.md",
@@ -76,7 +76,7 @@ export const DEMO_TOUR_NOTES: DemoTourTemplateFile[] = [
export const DEMO_TOUR_ASSETS: DemoTourTemplateFile[] = [
{
- path: "attachements/zennotes-demo-card.svg",
+ path: "zennotes-demo-card.svg",
body: "\n"
},
]
\ No newline at end of file
diff --git a/src/main/index.ts b/apps/desktop/src/main/index.ts
similarity index 98%
rename from src/main/index.ts
rename to apps/desktop/src/main/index.ts
index 4eaeecf1..535e0dad 100644
--- a/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -14,7 +14,6 @@ import type {
import {
absolutePath,
archiveNote,
- assetsAbsolutePath,
createFolder,
createNote,
deleteFolder,
@@ -25,6 +24,7 @@ import {
ensureVaultLayout,
folderAbsolutePath,
generateDemoTour,
+ getVaultSettings,
hasAssetsDir,
importFiles,
listAssets,
@@ -40,6 +40,7 @@ import {
restoreFromTrash,
searchVaultTextCapabilities,
searchVaultText,
+ setVaultSettings,
type PersistedWindowState,
updateConfig,
unarchiveNote,
@@ -625,6 +626,16 @@ function registerIpc(): void {
return await setVault(result.filePaths[0])
})
+ ipcMain.handle(IPC.VAULT_GET_SETTINGS, async () => {
+ const v = requireVault()
+ return await getVaultSettings(v.root)
+ })
+
+ ipcMain.handle(IPC.VAULT_SET_SETTINGS, async (_e, next) => {
+ const v = requireVault()
+ return await setVaultSettings(v.root, next)
+ })
+
ipcMain.handle(IPC.VAULT_LIST_NOTES, async () => {
const v = requireVault()
return await listNotes(v.root)
@@ -798,15 +809,14 @@ function registerIpc(): void {
IPC.VAULT_REVEAL_FOLDER,
async (_e, folder: NoteFolder, subpath: string) => {
const v = requireVault()
- const abs = folderAbsolutePath(v.root, folder, subpath)
+ const abs = await folderAbsolutePath(v.root, folder, subpath)
await shell.openPath(abs)
}
)
ipcMain.handle(IPC.VAULT_REVEAL_ASSETS_DIR, async () => {
const v = requireVault()
- const abs = assetsAbsolutePath(v.root)
- await shell.openPath(abs)
+ await shell.openPath(v.root)
})
// Route window chrome controls to the window that actually sent the
diff --git a/src/main/mcp-integrations.ts b/apps/desktop/src/main/mcp-integrations.ts
similarity index 100%
rename from src/main/mcp-integrations.ts
rename to apps/desktop/src/main/mcp-integrations.ts
diff --git a/src/main/perf.ts b/apps/desktop/src/main/perf.ts
similarity index 100%
rename from src/main/perf.ts
rename to apps/desktop/src/main/perf.ts
diff --git a/src/main/tasks.ts b/apps/desktop/src/main/tasks.ts
similarity index 89%
rename from src/main/tasks.ts
rename to apps/desktop/src/main/tasks.ts
index 555a3fa6..8bfe2ba1 100644
--- a/src/main/tasks.ts
+++ b/apps/desktop/src/main/tasks.ts
@@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'
import path from 'node:path'
import type { NoteFolder, NoteMeta } from '@shared/ipc'
import { parseTasksFromBody, type VaultTask } from '@shared/tasks'
-import { listNotes } from './vault'
+import { folderForRelativePath, listNotes } from './vault'
// Trash is excluded — trashed notes should never surface as live tasks.
function includesFolder(folder: NoteFolder): boolean {
@@ -49,8 +49,8 @@ export async function scanTasksForPath(
relPath: string
): Promise {
const posix = relPath.split(path.sep).join('/')
- const firstSeg = posix.split('/', 1)[0] as NoteFolder
- if (!LIVE_FOLDERS.has(firstSeg)) return []
+ const folder = folderForRelativePath(posix)
+ if (!folder || !LIVE_FOLDERS.has(folder)) return []
const abs = path.join(root, posix.split('/').join(path.sep))
let body: string
@@ -60,5 +60,5 @@ export async function scanTasksForPath(
return []
}
const title = path.basename(posix, path.extname(posix))
- return parseTasksFromBody(body, { path: posix, title, folder: firstSeg })
+ return parseTasksFromBody(body, { path: posix, title, folder })
}
diff --git a/src/main/tikz.ts b/apps/desktop/src/main/tikz.ts
similarity index 100%
rename from src/main/tikz.ts
rename to apps/desktop/src/main/tikz.ts
diff --git a/src/main/updater.ts b/apps/desktop/src/main/updater.ts
similarity index 100%
rename from src/main/updater.ts
rename to apps/desktop/src/main/updater.ts
diff --git a/src/main/vault.test.ts b/apps/desktop/src/main/vault.test.ts
similarity index 100%
rename from src/main/vault.test.ts
rename to apps/desktop/src/main/vault.test.ts
diff --git a/src/main/vault.ts b/apps/desktop/src/main/vault.ts
similarity index 81%
rename from src/main/vault.ts
rename to apps/desktop/src/main/vault.ts
index 6bf6b084..2b4575a6 100644
--- a/src/main/vault.ts
+++ b/apps/desktop/src/main/vault.ts
@@ -3,8 +3,11 @@ import { execFile, spawn } from 'node:child_process'
import path from 'node:path'
import { promisify } from 'node:util'
import { app } from 'electron'
-import type {
+import {
+ DEFAULT_DAILY_NOTES_DIRECTORY,
AssetMeta,
+ type PrimaryNotesLocation,
+ type VaultSettings,
FolderEntry,
ImportedAsset,
ImportedAssetKind,
@@ -24,9 +27,13 @@ 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(FOLDERS)
const PRIMARY_ATTACHMENTS_DIR = 'attachements'
const LEGACY_ATTACHMENTS_DIRS = ['_assets']
const ATTACHMENTS_DIRS = [PRIMARY_ATTACHMENTS_DIR, ...LEGACY_ATTACHMENTS_DIRS]
+const INTERNAL_VAULT_DIR = '.zennotes'
+const VAULT_SETTINGS_FILE = 'vault.json'
+const RESERVED_ROOT_NAMES = new Set([...FOLDERS, ...ATTACHMENTS_DIRS, INTERNAL_VAULT_DIR])
const FENCED_CODE_BLOCK_RE = /(^|\n)```[^\n]*\n[\s\S]*?\n```[ \t]*(?=\n|$)/g
const IMAGE_EXTENSIONS = new Set([
'.apng',
@@ -50,6 +57,14 @@ const SEARCH_EXECUTABLE_NAMES = {
fzf: new Set(['fzf', 'fzf.exe'])
} as const
+const DEFAULT_VAULT_SETTINGS: VaultSettings = {
+ primaryNotesLocation: 'inbox',
+ dailyNotes: {
+ enabled: false,
+ directory: DEFAULT_DAILY_NOTES_DIRECTORY
+ }
+}
+
interface VaultTextSearchCandidate {
path: string
title: string
@@ -165,20 +180,157 @@ export async function updateConfig(
return nextConfig
}
+function vaultSettingsPath(root: string): string {
+ return path.join(root, INTERNAL_VAULT_DIR, VAULT_SETTINGS_FILE)
+}
+
+function cloneVaultSettings(settings: VaultSettings): VaultSettings {
+ return {
+ primaryNotesLocation: settings.primaryNotesLocation,
+ dailyNotes: {
+ enabled: settings.dailyNotes.enabled,
+ directory: settings.dailyNotes.directory
+ }
+ }
+}
+
+function normalizeDailyNotesDirectory(value: unknown): string {
+ if (typeof value !== 'string') return DEFAULT_DAILY_NOTES_DIRECTORY
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, '')
+ return trimmed || DEFAULT_DAILY_NOTES_DIRECTORY
+}
+
+function normalizePrimaryNotesLocation(value: unknown): PrimaryNotesLocation {
+ return value === 'root' ? 'root' : 'inbox'
+}
+
+function normalizeVaultSettings(
+ value: unknown,
+ fallbackPrimary: PrimaryNotesLocation = DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+): VaultSettings {
+ if (!value || typeof value !== 'object') {
+ return {
+ primaryNotesLocation: fallbackPrimary,
+ dailyNotes: {
+ enabled: DEFAULT_VAULT_SETTINGS.dailyNotes.enabled,
+ directory: DEFAULT_DAILY_NOTES_DIRECTORY
+ }
+ }
+ }
+ const candidate = value as {
+ primaryNotesLocation?: unknown
+ dailyNotes?: { enabled?: unknown; directory?: unknown } | null
+ }
+ return {
+ primaryNotesLocation: normalizePrimaryNotesLocation(
+ candidate.primaryNotesLocation ?? fallbackPrimary
+ ),
+ dailyNotes: {
+ enabled:
+ typeof candidate.dailyNotes?.enabled === 'boolean'
+ ? candidate.dailyNotes.enabled
+ : DEFAULT_VAULT_SETTINGS.dailyNotes.enabled,
+ directory: normalizeDailyNotesDirectory(candidate.dailyNotes?.directory)
+ }
+ }
+}
+
+async function inferPrimaryNotesLocation(root: string): Promise {
+ let entries: Dirent[]
+ try {
+ entries = await fs.readdir(root, { withFileTypes: true })
+ } catch {
+ return DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue
+ if (RESERVED_ROOT_NAMES.has(entry.name)) continue
+ if (entry.isDirectory()) return 'root'
+ if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) return 'root'
+ }
+ return DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+}
+
+async function vaultLooksEmpty(root: string): Promise {
+ let entries: Dirent[]
+ try {
+ entries = await fs.readdir(root, { withFileTypes: true })
+ } catch {
+ return true
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue
+ if (entry.name === INTERNAL_VAULT_DIR) continue
+ return false
+ }
+ return true
+}
+
+export async function getVaultSettings(root: string): Promise {
+ let fallbackPrimary = DEFAULT_VAULT_SETTINGS.primaryNotesLocation
+ try {
+ fallbackPrimary = await inferPrimaryNotesLocation(root)
+ const raw = await fs.readFile(vaultSettingsPath(root), 'utf8')
+ return normalizeVaultSettings(JSON.parse(raw), fallbackPrimary)
+ } catch {
+ return normalizeVaultSettings(null, fallbackPrimary)
+ }
+}
+
+export async function setVaultSettings(
+ root: string,
+ next: VaultSettings
+): Promise {
+ const fallbackPrimary = await inferPrimaryNotesLocation(root)
+ const normalized = normalizeVaultSettings(next, fallbackPrimary)
+ await fs.mkdir(path.dirname(vaultSettingsPath(root)), { recursive: true })
+ await fs.writeFile(vaultSettingsPath(root), JSON.stringify(normalized, null, 2), 'utf8')
+ if (normalized.primaryNotesLocation === 'inbox') {
+ await fs.mkdir(path.join(root, 'inbox'), { recursive: true })
+ }
+ return cloneVaultSettings(normalized)
+}
+
+async function primaryNotesRoot(root: string): Promise {
+ const settings = await getVaultSettings(root)
+ return settings.primaryNotesLocation === 'root' ? root : path.join(root, 'inbox')
+}
+
+async function folderRoot(root: string, folder: NoteFolder): Promise {
+ if (folder === 'inbox') return await primaryNotesRoot(root)
+ return path.join(root, folder)
+}
+
+export function folderForRelativePath(rel: string): NoteFolder | null {
+ const normalized = toPosix(rel)
+ const top = normalized.split('/')[0]
+ if (SYSTEM_FOLDERS.has(top as NoteFolder)) return top as NoteFolder
+ if (!top || top.startsWith('.')) return null
+ if (RESERVED_ROOT_NAMES.has(top)) return null
+ return 'inbox'
+}
+
/**
* Ensure the expected vault folder layout exists and seed a welcome note
* the very first time a vault is opened.
*/
export async function ensureVaultLayout(root: string): Promise {
await fs.mkdir(root, { recursive: true })
+ const wasEmpty = await vaultLooksEmpty(root)
+ const settings = await getVaultSettings(root)
for (const f of FOLDERS) {
+ if (f === 'inbox' && settings.primaryNotesLocation === 'root') continue
await fs.mkdir(path.join(root, f), { recursive: true })
}
- const welcomePath = path.join(root, 'inbox', 'Welcome.md')
- try {
- await fs.access(welcomePath)
- } catch {
- await fs.writeFile(welcomePath, WELCOME_NOTE, 'utf8')
+ if (wasEmpty) {
+ const welcomeDir = await primaryNotesRoot(root)
+ await fs.mkdir(welcomeDir, { recursive: true })
+ const welcomePath = path.join(welcomeDir, 'Welcome.md')
+ try {
+ await fs.access(welcomePath)
+ } catch {
+ await fs.writeFile(welcomePath, WELCOME_NOTE, 'utf8')
+ }
}
}
@@ -195,10 +347,7 @@ function markdownDestination(p: string): string {
}
function folderOf(root: string, absPath: string): NoteFolder | null {
- const rel = toPosix(path.relative(root, absPath))
- const top = rel.split('/')[0]
- if (FOLDERS.includes(top as NoteFolder)) return top as NoteFolder
- return null
+ return folderForRelativePath(path.relative(root, absPath))
}
function stripCodeContent(body: string): string {
@@ -208,6 +357,18 @@ function stripCodeContent(body: string): string {
.replace(/`[^`\n]*`/g, ' ')
}
+function localAssetTargetKind(target: string): ImportedAssetKind | null {
+ const clean = target.split('#')[0]?.split('?')[0] ?? target
+ const lastDot = clean.lastIndexOf('.')
+ if (lastDot === -1) return null
+ const ext = clean.slice(lastDot).toLowerCase()
+ if (IMAGE_EXTENSIONS.has(ext)) return 'image'
+ if (PDF_EXTENSIONS.has(ext)) return 'pdf'
+ if (AUDIO_EXTENSIONS.has(ext)) return 'audio'
+ if (VIDEO_EXTENSIONS.has(ext)) return 'video'
+ return 'file'
+}
+
/** Pull unique `#tags` out of markdown text, ignoring fenced/inline code. */
function extractTags(body: string): string[] {
const stripped = stripCodeContent(body)
@@ -226,6 +387,7 @@ function extractTags(body: string): string[] {
function bodyHasLocalAsset(body: string): boolean {
const stripped = stripCodeContent(body)
const linkRe = /(!?)\[[^\]]*\]\((<[^>]+>|[^)\s]+)(?:\s+"[^"]*")?\)/g
+ const embedRe = /!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g
let m: RegExpExecArray | null
while ((m = linkRe.exec(stripped)) !== null) {
let href = (m[2] ?? '').trim()
@@ -233,18 +395,10 @@ function bodyHasLocalAsset(body: string): boolean {
if (!href || href.startsWith('#') || href.startsWith('//')) continue
// Skip URLs (anything with a scheme like http:, mailto:, file:, …).
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href)) continue
- const clean = href.split('#')[0]?.split('?')[0] ?? href
- const lastDot = clean.lastIndexOf('.')
- if (lastDot === -1) continue
- const ext = clean.slice(lastDot).toLowerCase()
- if (
- IMAGE_EXTENSIONS.has(ext) ||
- PDF_EXTENSIONS.has(ext) ||
- AUDIO_EXTENSIONS.has(ext) ||
- VIDEO_EXTENSIONS.has(ext)
- ) {
- return true
- }
+ if (localAssetTargetKind(href)) return true
+ }
+ while ((m = embedRe.exec(stripped)) !== null) {
+ if (localAssetTargetKind((m[1] ?? '').trim())) return true
}
return false
}
@@ -253,11 +407,15 @@ function bodyHasLocalAsset(body: string): boolean {
* `[[target|label]]` by discarding the label. Ignores fenced/inline code. */
function extractWikilinks(body: string): string[] {
const stripped = stripCodeContent(body)
- const re = /\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g
+ const re = /(!?)\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g
const seen = new Set()
let m: RegExpExecArray | null
while ((m = re.exec(stripped)) !== null) {
- seen.add(m[1].trim())
+ const bang = m[1] ?? ''
+ const target = (m[2] ?? '').trim()
+ if (!target) continue
+ if (bang === '!' && localAssetTargetKind(target)) continue
+ seen.add(target)
}
return [...seen]
}
@@ -268,6 +426,7 @@ function buildExcerpt(body: string): string {
const text = stripCodeContent(withoutFront)
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
+ .replace(/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a)
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_, a, b) => b || a)
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*_~>]+/g, '')
@@ -420,14 +579,17 @@ function resolveSearchBackend(
}
function noteFolderFromRelPath(relPath: string): NoteFolder | null {
- const top = relPath.split('/')[0]
- if (top === 'inbox' || top === 'quick' || top === 'archive') return top
- return null
+ return folderForRelativePath(relPath)
}
async function collectBuiltinSearchCandidates(root: string): Promise {
const candidates: VaultTextSearchCandidate[] = []
- const walkFolder = async (folder: NoteFolder, dirAbs: string): Promise => {
+ const walkFolder = async (
+ folder: NoteFolder,
+ dirAbs: string,
+ topAbs: string,
+ isPrimaryRoot: boolean
+ ): Promise => {
let entries: Dirent[]
try {
entries = await fs.readdir(dirAbs, { withFileTypes: true })
@@ -439,7 +601,8 @@ async function collectBuiltinSearchCandidates(root: string): Promise {
+ const dir = await folderRoot(root, folder)
+ return toPosix(path.relative(root, dir)) || '.'
+ })
+ )
const result = await execFileAsync(
ripgrep,
[
@@ -496,7 +667,7 @@ async function collectRipgrepSearchCandidates(
'-g',
'*.md',
'^',
- ...SEARCHABLE_TEXT_FOLDERS
+ ...searchRoots
],
{
cwd: root,
@@ -731,7 +902,8 @@ async function readSiblingOrder(abs: string): Promise {
export async function listFolders(root: string): Promise {
const out: FolderEntry[] = []
for (const folder of FOLDERS) {
- const topAbs = path.join(root, folder)
+ const topAbs = await folderRoot(root, folder)
+ const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root)
const walk = async (dirAbs: string, subpath: string): Promise => {
let entries: Dirent[]
try {
@@ -742,6 +914,7 @@ export async function listFolders(root: string): Promise {
for (const [index, e] of entries.entries()) {
if (!e.isDirectory()) continue
if (e.name.startsWith('.')) continue
+ if (isPrimaryRoot && dirAbs === topAbs && RESERVED_ROOT_NAMES.has(e.name)) continue
const nextSub = subpath ? `${subpath}/${e.name}` : e.name
out.push({ folder, subpath: nextSub, siblingOrder: index })
await walk(path.join(dirAbs, e.name), nextSub)
@@ -754,7 +927,12 @@ export async function listFolders(root: string): Promise {
export async function listNotes(root: string): Promise {
const metas: NoteMeta[] = []
- const walkFolder = async (folder: NoteFolder, dirAbs: string): Promise => {
+ const walkFolder = async (
+ folder: NoteFolder,
+ dirAbs: string,
+ topAbs: string,
+ isPrimaryRoot: boolean
+ ): Promise => {
let entries: Dirent[]
try {
entries = await fs.readdir(dirAbs, { withFileTypes: true })
@@ -765,7 +943,8 @@ export async function listNotes(root: string): Promise {
const full = path.join(dirAbs, entry.name)
if (entry.isDirectory()) {
if (entry.name.startsWith('.')) continue
- await walkFolder(folder, full)
+ if (isPrimaryRoot && dirAbs === topAbs && RESERVED_ROOT_NAMES.has(entry.name)) continue
+ await walkFolder(folder, full, topAbs, isPrimaryRoot)
continue
}
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
@@ -775,7 +954,9 @@ export async function listNotes(root: string): Promise {
}
for (const folder of FOLDERS) {
- await walkFolder(folder, path.join(root, folder))
+ const topAbs = await folderRoot(root, folder)
+ const isPrimaryRoot = folder === 'inbox' && path.resolve(topAbs) === path.resolve(root)
+ await walkFolder(folder, topAbs, topAbs, isPrimaryRoot)
}
return metas
}
@@ -900,9 +1081,8 @@ export async function createNote(
): Promise {
const base = (title && title.trim()) || 'Untitled'
const clean = subpath.replace(/^\/+|\/+$/g, '')
- const dir = clean
- ? resolveSafe(root, path.posix.join(folder, clean))
- : path.join(root, folder)
+ const topRoot = await folderRoot(root, folder)
+ const dir = clean ? resolveSafe(topRoot, clean) : topRoot
await fs.mkdir(dir, { recursive: true })
const finalTitle = await uniqueTitle(dir, base)
const abs = path.join(dir, `${finalTitle}.md`)
@@ -952,7 +1132,7 @@ async function moveBetweenFolders(
): Promise {
const abs = resolveSafe(root, rel)
const filename = path.basename(abs)
- const destDir = path.join(root, target)
+ const destDir = await folderRoot(root, target)
await fs.mkdir(destDir, { recursive: true })
const baseTitle = path.basename(filename, path.extname(filename))
const finalTitle = await uniqueTitle(destDir, baseTitle)
@@ -1008,7 +1188,7 @@ export async function createFolder(
): Promise {
const trimmed = subpath.replace(/^\/+|\/+$/g, '')
if (!trimmed) throw new Error('Folder name is required')
- const abs = resolveSafe(root, path.posix.join(topFolder, trimmed))
+ const abs = resolveSafe(await folderRoot(root, topFolder), trimmed)
await fs.mkdir(abs, { recursive: true })
}
@@ -1029,8 +1209,9 @@ export async function renameFolder(
if (!oldClean) throw new Error('Cannot rename the top-level folder')
if (!newClean) throw new Error('Target folder name is required')
- const oldAbs = resolveSafe(root, path.posix.join(topFolder, oldClean))
- const newAbs = resolveSafe(root, path.posix.join(topFolder, newClean))
+ const topRoot = await folderRoot(root, topFolder)
+ const oldAbs = resolveSafe(topRoot, oldClean)
+ const newAbs = resolveSafe(topRoot, newClean)
if (newAbs === oldAbs) return newClean
const sep = path.sep
@@ -1076,7 +1257,7 @@ export async function deleteFolder(
): Promise {
const clean = subpath.replace(/^\/+|\/+$/g, '')
if (!clean) throw new Error('Cannot delete the top-level folder')
- const abs = resolveSafe(root, path.posix.join(topFolder, clean))
+ const abs = resolveSafe(await folderRoot(root, topFolder), clean)
await fs.rm(abs, { recursive: true, force: true })
}
@@ -1092,7 +1273,8 @@ export async function duplicateFolder(
): Promise {
const clean = subpath.replace(/^\/+|\/+$/g, '')
if (!clean) throw new Error('Cannot duplicate the top-level folder')
- const oldAbs = resolveSafe(root, path.posix.join(topFolder, clean))
+ const topRoot = await folderRoot(root, topFolder)
+ const oldAbs = resolveSafe(topRoot, clean)
const parentAbs = path.dirname(oldAbs)
const baseName = path.basename(oldAbs)
let copyName = `${baseName} copy`
@@ -1108,10 +1290,7 @@ export async function duplicateFolder(
}
const newAbs = path.join(parentAbs, copyName)
await fs.cp(oldAbs, newAbs, { recursive: true })
- return path
- .relative(path.join(root, topFolder), newAbs)
- .split(path.sep)
- .join('/')
+ return path.relative(topRoot, newAbs).split(path.sep).join('/')
}
/** Build the absolute on-disk path for a vault folder / subfolder. */
@@ -1119,11 +1298,12 @@ export function folderAbsolutePath(
root: string,
topFolder: NoteFolder,
subpath: string
-): string {
+): Promise {
const clean = subpath.replace(/^\/+|\/+$/g, '')
- return clean
- ? resolveSafe(root, path.posix.join(topFolder, clean))
- : path.join(root, topFolder)
+ return (async () => {
+ const topRoot = await folderRoot(root, topFolder)
+ return clean ? resolveSafe(topRoot, clean) : topRoot
+ })()
}
export function assetsAbsolutePath(root: string): string {
@@ -1186,6 +1366,8 @@ export async function removeDemoTour(root: string): Promise
}
export async function hasAssetsDir(root: string): Promise {
+ const assets = await listAssets(root)
+ if (assets.length > 0) return true
for (const dirName of ATTACHMENTS_DIRS) {
try {
const stat = await fs.stat(path.join(root, dirName))
@@ -1199,43 +1381,37 @@ export async function hasAssetsDir(root: string): Promise {
export async function listAssets(root: string): Promise {
const out: AssetMeta[] = []
- const walk = async (dirAbs: string): Promise => {
+ const walk = async (dirAbs: string, topAbs = dirAbs): Promise => {
let entries: Dirent[]
try {
entries = await fs.readdir(dirAbs, { withFileTypes: true })
} catch {
return
}
- for (const entry of entries) {
+ for (const [index, entry] of entries.entries()) {
if (entry.name.startsWith('.')) continue
const full = path.join(dirAbs, entry.name)
if (entry.isDirectory()) {
- await walk(full)
+ if (dirAbs === root && entry.name === INTERNAL_VAULT_DIR) continue
+ await walk(full, topAbs)
continue
}
if (!entry.isFile()) continue
+ if (entry.name.toLowerCase().endsWith('.md')) continue
const stat = await fs.stat(full)
const rel = toPosix(path.relative(root, full))
out.push({
path: rel,
name: path.basename(full),
kind: classifyImportedAsset(entry.name),
+ siblingOrder: index,
size: stat.size,
updatedAt: stat.mtimeMs
})
}
}
- for (const dirName of ATTACHMENTS_DIRS) {
- const attachmentsRoot = path.join(root, dirName)
- try {
- const stat = await fs.stat(attachmentsRoot)
- if (!stat.isDirectory()) continue
- } catch {
- continue
- }
- await walk(attachmentsRoot)
- }
+ await walk(root, root)
out.sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
return out
}
@@ -1256,9 +1432,8 @@ export async function moveNote(
const oldAbs = resolveSafe(root, oldRel)
const filename = path.basename(oldAbs)
const cleanSub = targetSubpath.replace(/^\/+|\/+$/g, '')
- const destDir = cleanSub
- ? resolveSafe(root, path.posix.join(targetFolder, cleanSub))
- : path.join(root, targetFolder)
+ const targetRoot = await folderRoot(root, targetFolder)
+ const destDir = cleanSub ? resolveSafe(targetRoot, cleanSub) : targetRoot
// No-op if the source already lives at the destination.
if (path.dirname(oldAbs) === destDir) {
@@ -1295,8 +1470,7 @@ export async function importFiles(
noteRelPath: string,
sourcePaths: string[]
): Promise {
- const assetsDir = assetsAbsolutePath(root)
- await fs.mkdir(assetsDir, { recursive: true })
+ await fs.mkdir(root, { recursive: true })
const noteDir = path.posix.dirname(toPosix(noteRelPath))
const imported: ImportedAsset[] = []
@@ -1306,8 +1480,8 @@ export async function importFiles(
const stat = await fs.stat(sourceAbs)
if (!stat.isFile()) continue
- const finalName = await uniqueFilename(assetsDir, path.basename(sourceAbs))
- const destAbs = path.join(assetsDir, finalName)
+ const finalName = await uniqueFilename(root, path.basename(sourceAbs))
+ const destAbs = path.join(root, finalName)
await fs.copyFile(sourceAbs, destAbs)
const vaultRelPath = toPosix(path.relative(root, destAbs))
diff --git a/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts
similarity index 82%
rename from src/main/watcher.ts
rename to apps/desktop/src/main/watcher.ts
index 06dee977..e1365f65 100644
--- a/src/main/watcher.ts
+++ b/apps/desktop/src/main/watcher.ts
@@ -1,8 +1,9 @@
import path from 'node:path'
import chokidar, { FSWatcher } from 'chokidar'
import type { NoteFolder, VaultChangeEvent, VaultChangeKind } from '@shared/ipc'
+import { folderForRelativePath } from './vault'
-const FOLDERS: NoteFolder[] = ['inbox', 'quick', 'archive', 'trash']
+const ATTACHMENTS_DIRS = new Set(['attachements', '_assets'])
function toPosix(p: string): string {
return p.split(path.sep).join('/')
@@ -10,9 +11,10 @@ function toPosix(p: string): string {
function folderOf(root: string, abs: string): NoteFolder | null {
const rel = toPosix(path.relative(root, abs))
+ const folder = folderForRelativePath(rel)
+ if (folder) return folder
const top = rel.split('/')[0]
- if (FOLDERS.includes(top as NoteFolder)) return top as NoteFolder
- return null
+ return ATTACHMENTS_DIRS.has(top) ? 'inbox' : null
}
export class VaultWatcher {
@@ -36,7 +38,8 @@ export class VaultWatcher {
})
const handler = (kind: VaultChangeKind) => (absPath: string) => {
- if (!absPath.toLowerCase().endsWith('.md')) return
+ const base = path.basename(absPath)
+ if (base.startsWith('.')) return
if (!this.root) return
const folder = folderOf(this.root, absPath)
if (!folder) return
diff --git a/src/mcp/index.ts b/apps/desktop/src/mcp/index.ts
similarity index 100%
rename from src/mcp/index.ts
rename to apps/desktop/src/mcp/index.ts
diff --git a/src/mcp/instructions-store.ts b/apps/desktop/src/mcp/instructions-store.ts
similarity index 100%
rename from src/mcp/instructions-store.ts
rename to apps/desktop/src/mcp/instructions-store.ts
diff --git a/src/mcp/instructions.ts b/apps/desktop/src/mcp/instructions.ts
similarity index 100%
rename from src/mcp/instructions.ts
rename to apps/desktop/src/mcp/instructions.ts
diff --git a/src/mcp/vault-ops.ts b/apps/desktop/src/mcp/vault-ops.ts
similarity index 100%
rename from src/mcp/vault-ops.ts
rename to apps/desktop/src/mcp/vault-ops.ts
diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts
new file mode 100644
index 00000000..863365f4
--- /dev/null
+++ b/apps/desktop/src/preload/index.d.ts
@@ -0,0 +1,9 @@
+import type { ZenBridge } from '@zennotes/bridge-contract/bridge'
+
+declare global {
+ interface Window {
+ zen: ZenBridge
+ }
+}
+
+export {}
diff --git a/src/preload/index.ts b/apps/desktop/src/preload/index.ts
similarity index 88%
rename from src/preload/index.ts
rename to apps/desktop/src/preload/index.ts
index 8127b4ad..f29550c4 100644
--- a/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -1,15 +1,19 @@
import { clipboard, contextBridge, ipcRenderer, webUtils } from 'electron'
import path from 'node:path'
+import appPackage from '../../package.json'
+import type { ZenAppInfo, ZenBridge, ZenCapabilities } from '@zennotes/bridge-contract/bridge'
import { IPC } from '@shared/ipc'
import type {
AppUpdateState,
AssetMeta,
+ DirectoryBrowseResult,
VaultDemoTourResult,
FolderEntry,
ImportedAsset,
NoteContent,
NoteFolder,
NoteMeta,
+ VaultSettings,
VaultTextSearchBackendPreference,
VaultTextSearchCapabilities,
VaultTextSearchToolPaths,
@@ -25,7 +29,26 @@ import type {
McpServerRuntime
} from '@shared/mcp-clients'
-const api = {
+const DESKTOP_CAPABILITIES: ZenCapabilities = {
+ supportsUpdater: true,
+ supportsNativeMenus: true,
+ supportsFloatingWindows: true,
+ supportsLocalFilesystemPickers: true,
+ supportsDesktopNotifications: true
+}
+
+const DESKTOP_APP_INFO: ZenAppInfo = {
+ name: appPackage.name,
+ productName: appPackage.productName,
+ version: appPackage.version,
+ description: appPackage.description,
+ homepage: appPackage.homepage,
+ runtime: 'desktop'
+}
+
+const api: ZenBridge = {
+ getCapabilities: (): ZenCapabilities => DESKTOP_CAPABILITIES,
+ getAppInfo: (): ZenAppInfo => DESKTOP_APP_INFO,
platform: (): Promise => ipcRenderer.invoke(IPC.APP_PLATFORM),
platformSync: (): NodeJS.Platform => process.platform,
listSystemFonts: (): Promise => ipcRenderer.invoke(IPC.APP_LIST_FONTS),
@@ -45,6 +68,15 @@ const api = {
getCurrentVault: (): Promise => ipcRenderer.invoke(IPC.VAULT_GET_CURRENT),
pickVault: (): Promise => ipcRenderer.invoke(IPC.VAULT_PICK),
+ selectVaultPath: async (): Promise => {
+ throw new Error('Manual vault path selection is only available in the web build')
+ },
+ browseServerDirectories: async (): Promise => {
+ throw new Error('Server filesystem browsing is only available in the web build')
+ },
+ getVaultSettings: (): Promise => ipcRenderer.invoke(IPC.VAULT_GET_SETTINGS),
+ setVaultSettings: (next: VaultSettings): Promise =>
+ ipcRenderer.invoke(IPC.VAULT_SET_SETTINGS, next),
listNotes: (): Promise => ipcRenderer.invoke(IPC.VAULT_LIST_NOTES),
listFolders: (): Promise =>
@@ -211,6 +243,6 @@ const api = {
clipboardReadText: (): string => clipboard.readText()
}
-export type ZenApi = typeof api
+export type ZenApi = ZenBridge
contextBridge.exposeInMainWorld('zen', api)
diff --git a/src/renderer/index.html b/apps/desktop/src/renderer/index.html
similarity index 96%
rename from src/renderer/index.html
rename to apps/desktop/src/renderer/index.html
index 9478560f..0616beaf 100644
--- a/src/renderer/index.html
+++ b/apps/desktop/src/renderer/index.html
@@ -32,6 +32,6 @@
-
+