From 42696d0956751ff7e36562380f18274b0daaf674 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 17 Aug 2026 17:31:33 -0500 Subject: [PATCH 01/15] Fix(tasks): keep the keyboard after clicking or focusing inside the Tasks view Touching the list's filter box and pressing j walked the SIDEBAR cursor instead of the task list, and after any click inside the view the 1/2/3 view switcher, /, and : went dead until the tab was reopened. The pane's capture handlers claim focusedPanel 'editor' for every click or focus inside a pane (#477), and the Tasks view's window keydown handler bails unless the panel is 'tasks' (#412). Any interaction inside the view therefore handed its keys to VimNav, whose global fallback moved the sidebar cursor. The view now re-claims 'tasks' on its root for both capture events. Outer capture handlers run before inner ones, so the re-claim always lands after the pane's claim and wins. TagView and Quick Notes share the latent pattern and are left for their own change. --- packages/app-core/src/components/TasksView.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index e2aa7f3f..e0b30cf5 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -60,6 +60,7 @@ export function TasksView(): JSX.Element { const closeTasksView = useStore((s) => s.closeTasksView) const reorderTaskInNote = useStore((s) => s.reorderTaskInNote) const newTaskFile = useStore((s) => s.newTaskFile) + const setFocusedPanel = useStore((s) => s.setFocusedPanel) // Tasks written inside a daily note inherit that note's date as an implicit // due date (a clean line, no `due:` token) so they appear on the calendar. @@ -573,6 +574,15 @@ export function TasksView(): JSX.Element {
setFocusedPanel('tasks')} + onFocusCapture={() => setFocusedPanel('tasks')} >
From e609ae8bf1484f6c69e83e2056055c39d9825231 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 17 Aug 2026 17:32:03 -0500 Subject: [PATCH 02/15] Feat(tasks): one filter across the list, the calendar, and the Kanban board Narrowing tasks to one project only worked in the list; the board and the calendar always showed everything (#583, and the core of #222). The header's filter box now mounts in all three sub-views and the query survives switching between them. Matching gains inline @key:value fields (they are stripped from display content, so they were unreachable) and tags with the leading #, so @project:alpha on a status-grouped board is a one-project Kanban. / focuses the box, :filter sets it from the ex line (:filter alone clears), Esc clears from anywhere, and the header counts the slice (4 of 7). On the board the filter narrows cards, never columns: the unfiltered columns remain the source of truth for group-by discovery and card-order persistence, and a drop resolves its index against the visible anchor card before splicing into the full column, so a drag on a filtered board cannot prune hidden cards out of a hand-made arrangement. Deliberately not done: frontmatter tag inheritance for inline tasks (it would change what tags a task carries in every mirrored parser) and structured filter chips with AND/OR, which stay with #222. --- .../app-core/src/components/TasksKanban.tsx | 49 +++++++++++-- .../app-core/src/components/TasksView.tsx | 73 ++++++++++++------- packages/app-core/src/lib/help.ts | 5 ++ .../app-core/src/lib/tasks-filter.test.ts | 52 ++++++++++++- packages/app-core/src/lib/tasks-filter.ts | 13 +++- 5 files changed, 154 insertions(+), 38 deletions(-) diff --git a/packages/app-core/src/components/TasksKanban.tsx b/packages/app-core/src/components/TasksKanban.tsx index d20cc0f2..de129676 100644 --- a/packages/app-core/src/components/TasksKanban.tsx +++ b/packages/app-core/src/components/TasksKanban.tsx @@ -30,6 +30,7 @@ import type { NoteFolder } from '@shared/ipc' import type { VaultTask } from '@shared/tasks' import { groupTasks, isOverdue as isTaskOverdue, toIsoDateLocal } from '@shared/tasks' import { useStore, type KanbanGroupBy, type TaskMutation } from '../store' +import { filterTasks } from '../lib/tasks-filter' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { buildTaskMenuItems } from '../lib/task-context-menu' import { ArrowUpRightIcon, PencilIcon } from './icons' @@ -44,6 +45,10 @@ import { interface Props { tasks: VaultTask[] + /** The Tasks header's filter query. Narrows the cards on the board; the + * column set, group-by options, and card-order persistence keep working + * from the full task list. (#583) */ + filter?: string today: Date onOpenTask: (task: VaultTask) => void onToggleTask: (task: VaultTask) => void @@ -531,7 +536,7 @@ interface DragPreview { const POINTER_DRAG_THRESHOLD = 5 -export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): JSX.Element { +export function TasksKanban({ tasks, filter, today, onOpenTask, onToggleTask }: Props): JSX.Element { const groupBy = useStore((s) => s.kanbanGroupBy) const setGroupBy = useStore((s) => s.setKanbanGroupBy) const kanbanColumnTitles = useStore((s) => s.kanbanColumnTitles) @@ -575,6 +580,7 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): }) const columnOrderRef = useRef(initialCardOrder) const columnsRef = useRef([]) + const fullColumnsRef = useRef([]) const columnTitleInputRef = useRef(null) const boardRef = useRef(null) const pointerDragRef = useRef(null) @@ -628,7 +634,7 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): setDisplayTasks(mergedTasks) }, [mergeTasksWithPendingMoves, tasks]) - const columns = useMemo( + const fullColumns = useMemo( () => { const orderedColumns = applyColumnOrder( groupBy, @@ -654,7 +660,28 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): today ] ) + + // The filter narrows cards, never columns: the board keeps its full column + // set while typing (a discovered-value column doesn't vanish because its + // cards are filtered out), and the unfiltered columns stay the source of + // truth for card-order persistence so a focused board can't prune hidden + // cards out of a hand-made arrangement. (#583) + const filterQuery = (filter ?? '').trim() + const columns = useMemo(() => { + if (!filterQuery) return fullColumns + return fullColumns.map((column) => { + const visible = filterTasks(column.tasks, filterQuery) + if (visible.length === column.tasks.length) return column + let badge = column.badge + if (badge?.kind === 'overdue') { + const value = visible.filter((t) => isTaskOverdue(t, today)).length + badge = value > 0 ? { kind: 'overdue', value } : undefined + } + return { ...column, tasks: visible, badge } + }) + }, [fullColumns, filterQuery, today]) columnsRef.current = columns + fullColumnsRef.current = fullColumns // Group-by options: the three static boards, the default custom-status field, // then one option per `@key:` field discovered across the current tasks. This @@ -920,17 +947,29 @@ export function TasksKanban({ tasks, today, onOpenTask, onToggleTask }: Props): if (targetIndex == null) return const movingKey = taskIdentityKey(task) + // The drop index counts the VISIBLE cards (the DOM the user aimed at), + // which the filter may have narrowed. Resolve it to the visible card the + // drop landed in front of, then splice next to that card in the FULL + // column, so filtered-out cards keep their hand-arranged positions + // instead of being pruned by the persist below. Unfiltered, the two + // boards are identical and this is the plain bounded insert. + const visibleTarget = columnsRef.current.find((column) => column.id === targetColumnId) + const visibleKeys = (visibleTarget?.tasks ?? []) + .map((columnTask) => taskIdentityKey(columnTask)) + .filter((key) => key !== movingKey) + const anchorKey = visibleKeys[targetIndex] ?? null + const nextOrderMap = new Map(columnOrderRef.current) const persistedEntries: Record = {} - for (const column of columnsRef.current) { + for (const column of fullColumnsRef.current) { const keys = column.tasks .map((columnTask) => taskIdentityKey(columnTask)) .filter((key) => key !== movingKey) if (column.id === targetColumnId) { - const boundedIndex = Math.max(0, Math.min(targetIndex, keys.length)) - keys.splice(boundedIndex, 0, movingKey) + const anchorIndex = anchorKey ? keys.indexOf(anchorKey) : -1 + keys.splice(anchorIndex === -1 ? keys.length : anchorIndex, 0, movingKey) } nextOrderMap.set(columnOrderKey(groupBy, column.id), keys) diff --git a/packages/app-core/src/components/TasksView.tsx b/packages/app-core/src/components/TasksView.tsx index e0b30cf5..8911d4fa 100644 --- a/packages/app-core/src/components/TasksView.tsx +++ b/packages/app-core/src/components/TasksView.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isTasksViewActive, useStore, type TasksViewMode } from '../store' import { filterTasksForDisplay, inferDailyTaskDueDates, type VaultTask } from '@shared/tasks' import { buildDailyNoteDateByPath } from '../lib/vault-layout' -import { computeTasksRender, isOverdue } from '../lib/tasks-filter' +import { computeTasksRender, filterTasks, isOverdue } from '../lib/tasks-filter' import { forwardTaskWithPicker } from '../lib/forward-task' import { TasksRow } from './TasksRow' import { TasksCalendar } from './TasksCalendar' @@ -75,6 +75,11 @@ export function TasksView(): JSX.Element { () => inferDailyTaskDueDates(filterTasksForDisplay(rawTasks, showArchivedTasks), dueByPath), [rawTasks, showArchivedTasks, dueByPath] ) + // One filter, three sub-views: the list applies it inside computeTasksRender, + // the calendar takes this pre-filtered list, and the Kanban receives the raw + // query so it can narrow cards without losing the full board (its group-by + // options and card-order persistence must keep seeing every task). + const filteredTasks = useMemo(() => filterTasks(tasks, filter), [tasks, filter]) const keymapOverrides = useStore((s) => s.keymapOverrides) const vimMode = useStore((s) => s.vimMode) const viewMode = useStore((s) => s.tasksViewMode) @@ -300,8 +305,18 @@ export function TasksView(): JSX.Element { const runExCommand = useCallback( (raw: string): void => { - const cmd = raw.trim().replace(/^:/, '').toLowerCase() - if (!cmd) return + const input = raw.trim().replace(/^:/, '') + if (!input) return + // `:filter ` sets the shared filter (all three sub-views); + // bare `:filter` (or `:f`) clears it. Parsed off the un-lowercased + // input so the query lands in the box as typed. + const spaceIdx = input.indexOf(' ') + const head = (spaceIdx === -1 ? input : input.slice(0, spaceIdx)).toLowerCase() + if (head === 'filter' || head === 'f') { + setFilter(spaceIdx === -1 ? '' : input.slice(spaceIdx + 1).trim()) + return + } + const cmd = input.toLowerCase() const store = useStore.getState() const path = store.selectedPath switch (cmd) { @@ -369,7 +384,7 @@ export function TasksView(): JSX.Element { return } }, - [closeTasksView, refreshTasks, setViewMode] + [closeTasksView, refreshTasks, setFilter, setViewMode] ) // Window-level handler with two responsibilities: @@ -588,7 +603,7 @@ export function TasksView(): JSX.Element {

Tasks

- {tasks.length} total + {filter.trim() ? `${filteredTasks.length} of ${tasks.length}` : `${tasks.length} total`} {loading && scanning…} @@ -616,28 +631,29 @@ export function TasksView(): JSX.Element {
- {viewMode === 'list' && ( - setFilter(e.target.value)} - onKeyDown={(e) => { - // While composing (IME), let the input own Enter/Arrows. (#183) - if (isImeComposing(e)) return - if (e.key === 'Escape') { - e.stopPropagation() - if (filter) setFilter('') - else e.currentTarget.blur() - } - if (e.key === 'Enter') { - e.currentTarget.blur() - } - }} - className="w-56 rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-1 text-xs outline-none focus:border-paper-400/70" - /> - )} + {/* Mounted in every sub-view: the same query narrows the list, the + calendar, and the Kanban board, so switching views keeps the + focus you typed. (#583) */} + setFilter(e.target.value)} + onKeyDown={(e) => { + // While composing (IME), let the input own Enter/Arrows. (#183) + if (isImeComposing(e)) return + if (e.key === 'Escape') { + e.stopPropagation() + if (filter) setFilter('') + else e.currentTarget.blur() + } + if (e.key === 'Enter') { + e.currentTarget.blur() + } + }} + className="w-56 rounded-md border border-paper-300/60 bg-paper-200/60 px-2 py-1 text-xs outline-none focus:border-paper-400/70" + /> + + +
+ + diff --git a/docs/ideas/atlas.md b/docs/ideas/atlas.md new file mode 100644 index 00000000..6d54fe3a --- /dev/null +++ b/docs/ideas/atlas.md @@ -0,0 +1,270 @@ +# Atlas + +A map of the vault: every note, every connection, in one navigable place. + +> **Status (August 2026).** Design document, nothing shipped. Written as an +> answer to the recurring request for "a graph view like Obsidian's", from a +> position of not wanting Obsidian's graph. An interactive prototype of the +> feel (fake vault, real interactions) sits next to this doc as +> `atlas-prototype.html`; open it in a browser and press `?`. + +## Problem Statement + +**How might we** let someone see their whole vault at once, follow the +connections between notes, and come away knowing something they did not know +before, without shipping a screensaver? + +People keep asking for "the Obsidian thing". What they are actually asking for +is the promise of that feature: my notes are a body of knowledge, show it to +me. The graph view is the most famous answer and it does not keep the promise. +Naming precisely where it fails tells us what to build instead. + +## Where Obsidian's graph fails + +1. **It is a physics simulation, not a map.** The force layout re-runs every + time the view opens. Nothing is ever where it was yesterday. Spatial + memory, the one superpower humans bring to maps ("the cooking stuff lives + bottom-left"), never gets a chance to form. +2. **Every node is an identical dot.** Which note is a hub, which is abandoned, + which is a week old: invisible. The screen shows topology and hides all + semantics. +3. **It answers no questions.** You can stare at it, zoom it, and post a + screenshot of it. You cannot ask it anything. There is no path from + "pretty" to "I learned something about my own thinking". +4. **It is mouse-only.** Disqualifying for this app on its own. + +At 1,000+ notes these compound into the famous hairball. The antidotes +(filters, groups) are buried in a settings drawer and reset with the layout. + +## The idea: cartography, not physics + +Atlas treats the vault as territory and draws it the way real maps are drawn: +computed once, updated incrementally, stable for years. + +**Pillar 1: a stable map.** The layout is deterministic and cached in the +vault. A new note lands next to its strongest connection; nothing else moves. +Opening Atlas next month shows the same geography as today, grown at the +edges. Reflowing the whole map is an explicit command the user runs on +purpose, never a side effect of opening the view. Stability is the feature; +everything else builds on it. + +**Pillar 2: regions with names.** Notes cluster by their link structure and +tag kinship into regions, each drawn as a soft territory with a name derived +from its dominant tag (or hub note), renamable by the user. The vault reads +like a map: Systems Programming up north, Book Notes to the east, the Journal +running along the south. + +**Pillar 3: semantic zoom.** Like a real map, altitude controls detail. Zoomed +out: region names and hub notes only, a continent view. Mid zoom: every note, +labels on hubs. Close: every label, every edge. There is no zoom level at +which the screen is a cloud of unlabeled dots. + +**Pillar 4: keyboard-first.** Nobody has shipped a graph you can drive without +a mouse. Atlas is fully navigable with vim keys, hint mode included. This is +the part only ZenNotes can build, because the machinery (VimNav, HintOverlay, +which-key) already exists. + +**Pillar 5: lenses, and a map that writes back.** The map is an instrument. +Lenses recolor it to answer questions (what is alive, what is orphaned, what +bridges two fields, how did I get from A to B). And when Atlas notices two +notes that should be connected and are not, it shows the ghost of that edge; +accepting it writes a real wikilink into the note. Insight becomes structure. +Obsidian's graph is a poster. Atlas closes the loop. + +## Vocabulary (ships on the surface) + +| Word | Meaning | +| ---------- | ------------------------------------------------------------ | +| **Atlas** | The view itself. `Space g`, `:atlas`, a Home tile. | +| **Region** | A named cluster territory. Renamable, stable. | +| **Lens** | An overlay that recolors the map to answer one question. | +| **Orbit** | The local view: one note centered, its connections in rings. | +| **Replay** | The time lens: watch the vault grow along a date scrubber. | + +Per house rule, every one of these words appears in the UI, not just in docs. + +## What the repo already has + +This design adds no new parsing and no new synced copy of anything. The +extraction layer exists in app-core and is already the renderer-side sibling +of the synced-copies family: + +- `lib/wikilinks.ts`: `extractWikilinkTargets`, `resolveWikilinkTarget`, + `extractMarkdownLinkHrefs`, `extractMentionSnippet` (unlinked mentions!), + all behind `stripCodeContent`. ConnectionsPanel already computes backlinks, + mentions, and missing links for one note; Atlas is that computation for all + notes at once, cached. +- `lib/tags.ts`: tag extraction with counts, frontmatter included. +- VimNav + HintOverlay + which-key: the entire keyboard model is assembled + from parts that exist. `Space g` is unclaimed (taken today: o a f s t e p v + l q i d w m c). +- NoteHoverPreview: hover/focus previews come free. +- The sidebar virtualization lesson: thousands of DOM nodes are a mistake we + already made once. Atlas renders to a single canvas from day one. +- The perf harness (`perf:desktop-runtime`, 5,000-note vault) is the natural + home for Atlas budgets. + +Because Atlas lives entirely in app-core and reads through the existing +extractors, it ships on desktop and web in the same change, and the Go server +is not involved. + +## Design + +### The index + +A vault-wide connection index, built in a web worker, updated incrementally on +note save/rename/delete events the store already emits: + +- **Link edges**: resolved wikilinks and internal markdown links. Directed, + weighted by occurrence count. These are the only edges drawn by default. +- **Kinship edges**: shared tags (weight by rarity: two notes sharing a + 20-note tag are closer kin than two sharing a 400-note tag). Used by layout + and suggestions, not drawn. +- **Mention edges**: unlinked title mentions, via the existing extractor. + Drawn only as ghosts in the Suggestions lens. + +Cold build for 5,000 notes is a read of bodies the store mostly already has, +plus regexes we already run per-note elsewhere; the budget below keeps us +honest. + +### Layout and persistence + +- Clustering: label propagation over link + kinship edges, deterministic tie + breaks, majority tag naming with hub-title fallback. +- Placement: regions on a golden-angle spiral (big regions claim space first), + notes within a region by phyllotaxis, refined by a short, seeded, damped + relaxation. Then **frozen**. +- Incremental: a new note is placed at the weighted centroid of its neighbors + with deterministic jitter; an unconnected note parks at its folder-mates' + region edge until it earns links. Existing positions never shift. +- Persistence: `.zennotes/atlas.json` in the vault. Positions, region names, + user renames. Small, derived, safe to delete (deleting = voluntary reflow). + In-vault so desktop, web, and any future device share one geography. +- `Atlas: reflow map` is a command with a confirm, and the old file is kept as + a one-step undo. + +### Rendering + +One canvas layer, no per-note DOM. Nodes and relationships, kept deliberately +plain: node radius by degree, hue by region, soft nebula glow per cluster, +small-caps serif region names. Labels appear by zoom band (regions, then +hubs, then everything). Edges are quiet by default (a three-state toggle: +quiet, all, off), brightening when an endpoint is hovered or focused. The +focused note carries a gold ring; hover shows the existing NoteHoverPreview. + +The prototype renders this map in **both dimensions behind one toggle** (`v`): +the **sky** (3D) puts regions as constellations on a flattened sphere with a +perspective camera that orbits and tilts (hand-rolled projection, still one +canvas, no libraries), depth fog, parallax stars, and a gentle idle drift; +the **map** (2D) is the flat cartographic layout. Both layouts are computed +once from the same seed and frozen, and toggling morphs every note between +its two homes while the camera swings level, so the two views feel like one +place seen two ways. In the map, hjkl and drag pan; in the sky they orbit +and tilt. Everything else (lenses, rings, trace, replay, hints, filter) is +identical in both. + +An isometric-city rendering (notes as blocks on district platforms) was +prototyped at length and rejected: even at its most restrained it pulled +attention to the buildings instead of the relationships. The node form stays. +What survives from that detour: links stay quiet until asked for, and the +detail on screen at once is a budget, not an accident. + +### Keyboard model (vim ON) + +| Keys | Action | +| -------------------- | ------------------------------------------------------------ | +| `Space g` / `:atlas` | Open Atlas | +| `h j k l` | Pan | +| `+` / `-` | Zoom in / out (semantic zoom bands) | +| `f` | Hint mode: two-letter labels on visible notes, type to focus | +| `/` | Filter: dims every note not matching title/tag, live | +| `Enter` | Open the focused note in the editor | +| `o` | Orbit the focused note | +| `zz` | Center on the focused note | +| `[` / `]` | Previous / next region | +| `1..5` | Lenses: Structure, Heat, Orphans, Bridges, Suggestions | +| `t` | Replay (time scrubber; `Space` plays/pauses) | +| `m` twice | Trace: mark two notes, shortest link paths light up | +| `?` / `Esc` | Help / unwind (lens, filter, orbit, then leave) | + +With vim OFF, per house rule: arrows pan, Enter opens, Escape unwinds, and +every single-key shortcut above is disabled; the toolbar and mouse carry the +full feature set (wheel zoom, drag pan, click focus, lens buttons). + +### Lenses + +- **Heat**: recency of `updated` as glow. The living edge of the vault in one + glance; six months of neglect reads as a dark continent. +- **Orphans**: notes with no links in or out. The to-connect (or to-archive) + pile, spatially grouped by kinship so it is actionable. +- **Bridges**: approximate betweenness; notes that connect regions glow. + These are reliably the most interesting notes a vault owner owns and no + other tool surfaces them. +- **Suggestions**: ghost edges from kinship + unlinked mentions, ranked. + Focus a ghost, `Enter` accepts: a `[[wikilink]]` is appended under a + `## Related` heading through the normal note-write path (undoable, atomic, + nothing bespoke). The ghost becomes a real edge on screen. +- **Trace**: mark two notes, see the shortest link paths between them. "How + does my Rust reading connect to my cooking notes" has an actual answer. +- **Replay**: scrub the vault through time by `created` date, watch regions + be born. Also, frankly, the demo clip for the release. + +### Orbit + +`o` on any note animates the map away and lays the note's neighborhood in +rings: direct links (ring 1), two hops (ring 2), unlinked mentions as ghosts. +`Esc` animates back to the exact map you left. Orbit is the "local graph" +people ask for, but ranked and readable instead of a star of dots. + +## Performance budgets + +Wired into `perf:desktop-runtime` (5,000-note vault), enforced under +`ZEN_PERF_ENFORCE=1`: + +- Cold index + layout build: under 2s, in the worker, UI never blocks. +- Incremental update on note save: under 16ms applied. +- Steady-state render: 60fps pan/zoom at 5,000 notes, one canvas. +- Atlas code is lazy-loaded (`LazyAtlasView` like Excalidraw/Workflows) and + adds nothing to boot. No named manualChunks rule, per the packaging scars. + +## Phasing + +- **Phase 1, the map.** Index worker, deterministic layout + `atlas.json`, + canvas renderer, regions, semantic zoom, hint-mode focus, `/` filter, + open/preview, `Space g`. Ships alone; already better than the thing people + ask for. +- **Phase 2, the instrument.** Lenses (Heat, Orphans, Bridges, Trace), Orbit, + Suggestions with write-back. +- **Phase 3, time and show.** Replay, PNG/SVG export of the current map view + (people share these; let them be beautiful), perf gates, docs in both + surfaces (help.ts + website). + +## Non-goals + +- No VR, no physics toy mode. The absence of a physics simulation is a + feature of this design, not a savings. (An earlier draft ruled out 3D too; + the maintainer asked for a 3D pass and the prototype now has one. Whether + the shipped view is 2D, 3D, or a toggle is an open question below.) +- No query language. Live Queries (see `live-queries.md`) is the text answer; + Atlas is the spatial one. They can feed each other later (a lens defined by + a query is an obvious Phase 4), but neither waits for the other. +- No folder-hierarchy view. Folders already have the sidebar; Atlas draws the + link structure folders cannot show. +- Nothing here touches the Go server or adds a fourth synced parser copy. + +## Open questions + +- The prototype now ships the sky and the map behind one toggle; the open + question is which is the **default**. 3D is the more evocative first + impression and depth separates clusters; 2D is likely stronger for spatial + memory (positions on a plane are easier to remember than in a volume). A + reasonable answer: open in the map, keep the sky one keypress away, and + remember the user's choice in the portable prefs. + +- Region naming quality on tag-poor vaults (fallback ladder: dominant tag, + hub title, top folder name; needs testing on a real messy vault). +- Whether kinship edges should influence layout by default or only when link + density is too low to cluster on (sparse-vault cold start). +- `atlas.json` merge behavior under sync conflict (positions are derived, so + last-writer-wins is probably fine; region renames are the part worth a + keep-both prompt). diff --git a/packages/app-core/src/components/AtlasView.tsx b/packages/app-core/src/components/AtlasView.tsx new file mode 100644 index 00000000..a933fb7d --- /dev/null +++ b/packages/app-core/src/components/AtlasView.tsx @@ -0,0 +1,991 @@ +// Atlas: the vault as a map of notes and links. Canvas-rendered (no per-note +// DOM, per the sidebar-virtualization lesson), theme-driven via CSS variables, +// with a 2D map and a 3D sky behind one toggle. Design: docs/ideas/atlas.md. +import { useEffect, useMemo, useRef, useState } from 'react' +import { useStore, isAtlasViewActive } from '../store' +import { + applyExtraLinkEdges, + buildAtlasGraph, + collectAtlasPositions, + layoutAtlas, + type AtlasGraph, + type AtlasPositions +} from '../lib/atlas' +import { isAppOverlayOpen } from '../lib/overlay-open' +import { extractMarkdownLinkHrefs } from '../lib/wikilinks' +import { resolveInternalNoteHref } from '../lib/internal-links' + +type Lens = 1 | 2 | 3 | 4 +const LENS_NAMES: Record = { 1: 'structure', 2: 'heat', 3: 'orphans', 4: 'bridges' } +const F = 900 +const HINTKEYS = 'asdfghjkl' + +interface ThemeColors { + bg: string + fg: string + muted: string + dim: string + accent: string + accentTriplet: string + regionHues: string[] +} + +// Themes style Atlas through CSS variables. A custom theme can override the +// region palette with --z-atlas-region-1 .. --z-atlas-region-8 (space-separated +// RGB triplets like every other token); otherwise the syntax hues are used. +function readThemeColors(): ThemeColors { + const cs = getComputedStyle(document.documentElement) + const triplet = (name: string, fallback: string): string => { + const v = cs.getPropertyValue(name).trim() + return v.length > 0 ? v : fallback + } + const rgb = (t: string): string => `rgb(${t})` + const regionHues: string[] = [] + for (let i = 1; i <= 8; i++) { + const v = cs.getPropertyValue(`--z-atlas-region-${i}`).trim() + if (v.length > 0) regionHues.push(rgb(v)) + } + if (regionHues.length === 0) { + for (const name of [ + '--z-accent', + '--z-blue', + '--z-purple', + '--z-aqua', + '--z-green', + '--z-yellow', + '--z-red', + '--z-accent-soft' + ]) { + const v = cs.getPropertyValue(name).trim() + if (v.length > 0) regionHues.push(rgb(v)) + } + } + return { + bg: rgb(triplet('--z-atlas-bg', triplet('--z-bg', '40 40 40'))), + fg: rgb(triplet('--z-fg', '235 219 178')), + muted: rgb(triplet('--z-grey-1', '146 131 116')), + dim: rgb(triplet('--z-grey-dim', '124 111 100')), + accent: rgb(triplet('--z-accent', '215 153 33')), + accentTriplet: triplet('--z-accent', '215 153 33'), + regionHues: regionHues.length > 0 ? regionHues : ['rgb(215 153 33)'] + } +} + +interface Cam { + yaw: number + pitch: number + dist: number + cx: number + cy: number + cz: number + yawT: number + pitchT: number + distT: number + cxT: number + cyT: number + czT: number +} + +export function AtlasView(): JSX.Element { + const notes = useStore((s) => s.notes) + const vaultRoot = useStore((s) => s.vault?.root ?? '') + const vimMode = useStore((s) => s.vimMode) + const selectNote = useStore((s) => s.selectNote) + const setFocusedPanel = useStore((s) => s.setFocusedPanel) + const isActive = useStore(isAtlasViewActive) + const canvasRef = useRef(null) + const [mode3d, setMode3d] = useState(false) + const [lens, setLens] = useState(1) + const [edgeMode, setEdgeMode] = useState(0) // 0 quiet · 1 all · 2 off + const [filterQ, setFilterQ] = useState('') + const [filterOpen, setFilterOpen] = useState(false) + const [status, setStatus] = useState('') + const filterRef = useRef(null) + + const graph: AtlasGraph = useMemo(() => { + const g = buildAtlasGraph(notes) + let previous: AtlasPositions | null = null + const cacheKey = `zen-atlas-pos:${vaultRoot}` + try { + const raw = localStorage.getItem(cacheKey) + if (raw) previous = JSON.parse(raw) as AtlasPositions + } catch { + previous = null + } + layoutAtlas(g, previous) + try { + localStorage.setItem(cacheKey, JSON.stringify(collectAtlasPositions(g))) + } catch { + // Cache is an optimization; a full deterministic relayout is the fallback. + } + return g + }, [notes, vaultRoot]) + + // Everything mutable at 60fps lives in refs; React state is for chrome only. + const world = useRef({ + cam: { + yaw: 0, + pitch: 0, + dist: 2600, + cx: 0, + cy: 0, + cz: 0, + yawT: 0, + pitchT: 0, + distT: 2600, + cxT: 0, + cyT: 0, + czT: 0 + } as Cam, + focusPath: null as string | null, + hoverPath: null as string | null, + hint: null as null | { labels: Map; typed: string }, + display: [] as Array<{ dx: number; dy: number; dz: number; alpha: number }>, + betweenness: null as Float64Array | null, + lastFrame: 0, + interacted: 0 + }) + const stateRef = useRef({ mode3d, lens, edgeMode, filterQ, vimMode, graph }) + stateRef.current = { mode3d, lens, edgeMode, filterQ, vimMode, graph } + + useEffect(() => { + world.current.display = graph.nodes.map((n) => ({ dx: 0, dy: 0, dz: 0, alpha: 0 })) + graph.nodes.forEach((n, i) => { + const d = world.current.display[i] + d.dx = stateRef.current.mode3d ? n.x : n.x2 + d.dy = stateRef.current.mode3d ? n.y : n.y2 + d.dz = stateRef.current.mode3d ? n.z : 0 + }) + world.current.betweenness = null + fitAll() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]) + + useEffect(() => { + if (!isActive) return + useStore.getState().setFocusedPanel('atlas') + const el = document.activeElement as HTMLElement | null + el?.blur?.() + }, [isActive]) + + // The map is a flat plane: it must be viewed level or panning slides on + // skewed axes. The sky keeps (and remembers) its oblique angle. + const savedView = useRef({ yaw: 0.6, pitch: 0.3 }) + useEffect(() => { + const cam = world.current.cam + if (mode3d) { + cam.yawT = savedView.current.yaw + cam.pitchT = savedView.current.pitch + } else { + savedView.current = { yaw: cam.yawT, pitch: cam.pitchT } + cam.yawT = Math.round(cam.yaw / (Math.PI * 2)) * Math.PI * 2 + cam.pitchT = 0 + } + }, [mode3d]) + + // Obsidian parity, kept view-local: markdown-style note links become edges + // too, scanned lazily from bodies AFTER first paint through the existing + // read-only bridge, cached per note mtime so the full scan runs once. + const [, setEdgeVersion] = useState(0) + useEffect(() => { + if (graph.nodes.length === 0) return + let cancelled = false + const cacheKey = `zen-atlas-mdlinks:${vaultRoot}` + const run = async (): Promise => { + let cache: Record = {} + try { + cache = JSON.parse(localStorage.getItem(cacheKey) ?? '{}') + } catch { + cache = {} + } + const pairs: Array<[string, string]> = [] + const pending = graph.nodes.filter((n) => cache[n.path]?.u !== n.updatedAt) + for (let i = 0; i < pending.length; i += 40) { + if (cancelled) return + await Promise.all( + pending.slice(i, i + 40).map(async (n) => { + try { + const body = (await window.zen.readNote(n.path)).body + const targets: string[] = [] + for (const href of extractMarkdownLinkHrefs(body)) { + const resolved = resolveInternalNoteHref(n.path, href, notes) + if (resolved) targets.push(resolved.path) + } + cache[n.path] = { u: n.updatedAt, t: targets } + } catch { + cache[n.path] = { u: n.updatedAt, t: [] } + } + }) + ) + await new Promise((r) => setTimeout(r, 0)) + } + for (const n of graph.nodes) { + for (const t of cache[n.path]?.t ?? []) pairs.push([n.path, t]) + } + if (cancelled) return + try { + localStorage.setItem(cacheKey, JSON.stringify(cache)) + } catch { + // cache is an optimization only + } + if (applyExtraLinkEdges(graph, pairs) > 0) { + world.current.betweenness = null + setEdgeVersion((v) => v + 1) + } + } + void run() + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graph]) + + const colorsRef = useRef(readThemeColors()) + useEffect(() => { + const observer = new MutationObserver(() => { + colorsRef.current = readThemeColors() + }) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme', 'data-theme-mode'] + }) + return () => observer.disconnect() + }, []) + + function target(n: { x: number; y: number; z: number; x2: number; y2: number }): [number, number, number] { + return stateRef.current.mode3d ? [n.x, n.y, n.z] : [n.x2, n.y2, 0] + } + function bounds(): { cx: number; cy: number; cz: number; R: number } { + const g = stateRef.current.graph + if (g.nodes.length === 0) return { cx: 0, cy: 0, cz: 0, R: 400 } + const pts = g.nodes.map((n) => target(n)) + const xs = pts.map((p) => p[0]) + const ys = pts.map((p) => p[1]) + const zs = pts.map((p) => p[2]) + const cx = (Math.min(...xs) + Math.max(...xs)) / 2 + const cy = (Math.min(...ys) + Math.max(...ys)) / 2 + const cz = (Math.min(...zs) + Math.max(...zs)) / 2 + const R = Math.max(...pts.map((p) => Math.hypot(p[0] - cx, p[1] - cy, p[2] - cz))) + 140 + return { cx, cy, cz, R } + } + function fitAll(): void { + const c = canvasRef.current + const w = c?.clientWidth || 1200 + const h = c?.clientHeight || 800 + const B = bounds() + const cam = world.current.cam + cam.cxT = B.cx + cam.cyT = B.cy + cam.czT = B.cz + cam.distT = Math.max(B.R * 1.15, Math.min(B.R * 2.4, (B.R * F) / (Math.min(w, h) * 0.62))) + } + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + let raf = 0 + const draw = (now: number): void => { + raf = requestAnimationFrame(draw) + const st = stateRef.current + const w = world.current + const g = st.graph + const vw = canvas.clientWidth + const vh = canvas.clientHeight + if (vw < 40 || vh < 40) return + const dpr = window.devicePixelRatio || 1 + if (canvas.width !== Math.round(vw * dpr)) { + canvas.width = Math.round(vw * dpr) + canvas.height = Math.round(vh * dpr) + } + const dt = Math.min(500, now - (w.lastFrame || now)) + w.lastFrame = now + const k = 1 - Math.exp(-dt / 150) + const cam = w.cam + if (st.mode3d && now - w.interacted > 4000) cam.yawT += dt * 0.000045 + cam.yaw += (cam.yawT - cam.yaw) * k + cam.pitch += (cam.pitchT - cam.pitch) * k + cam.dist += (cam.distT - cam.dist) * k + cam.cx += (cam.cxT - cam.cx) * k + cam.cy += (cam.cyT - cam.cy) * k + cam.cz += (cam.czT - cam.cz) * k + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + const project = (x: number, y: number, z: number): null | { sx: number; sy: number; s: number; fog: number; depth: number } => { + const px = x - cam.cx + const py = y - cam.cy + const pz = z - cam.cz + const xr = px * cosY - pz * sinY + const z1 = px * sinY + pz * cosY + const yr = py * cosP - z1 * sinP + const zr = py * sinP + z1 * cosP + const depth = zr + cam.dist + if (depth < 60) return null + const s = F / depth + const rel = depth / cam.dist + return { + sx: vw / 2 + xr * s, + sy: vh / 2 + yr * s, + s, + fog: Math.max(0.15, Math.min(1, 1.55 - rel * 0.55)), + depth + } + } + const col = colorsRef.current + const q = st.filterQ.toLowerCase() + const emphasis = (i: number): { a: number; glow: number } => { + const n = g.nodes[i] + let a = 1 + let glow = 0 + if (q.length > 0) { + const match = n.title.toLowerCase().includes(q) || n.tags.some((t) => t.includes(q)) + if (!match) return { a: 0.06, glow: 0 } + glow = 0.3 + } + if (st.lens === 2) { + const heat = Math.max(0, 1 - (Date.now() - n.updatedAt) / (240 * 86400000)) + a = 0.25 + 0.75 * heat + glow = heat + } + if (st.lens === 3) { + if (n.degree === 0) glow = 0.8 + else a = Math.min(a, 0.13) + } + if (st.lens === 4 && w.betweenness) { + const max = w.betweenness[g.nodes.length] || 1 + const b = w.betweenness[i] / max + a = 0.18 + 0.82 * Math.sqrt(b) + glow = Math.pow(b, 0.6) + } + return { a, glow } + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.fillStyle = col.bg + ctx.fillRect(0, 0, vw, vh) + const projected: Array> = [] + g.nodes.forEach((n, i) => { + const d = w.display[i] + if (!d) return + const [tx, ty, tz] = target(n) + d.dx += (tx - d.dx) * k + d.dy += (ty - d.dy) * k + d.dz += (tz - d.dz) * k + const em = emphasis(i) + d.alpha += (em.a - d.alpha) * k + projected[i] = project(d.dx, d.dy, d.dz) + }) + // halos give clusters their nebula ground + const order = g.nodes + .map((_, i) => i) + .filter((i) => projected[i] && w.display[i].alpha > 0.02) + .sort((a, b) => projected[b]!.depth - projected[a]!.depth) + for (const i of order) { + const p = projected[i]! + const n = g.nodes[i] + const r = (24 + Math.sqrt(n.degree) * 8) * p.s + if (r < 3 || p.sx < -r || p.sy < -r || p.sx > vw + r || p.sy > vh + r) continue + const hue = col.regionHues[n.region % col.regionHues.length] + const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, r) + grad.addColorStop(0, hue.replace('rgb(', 'rgba(').replace(')', ' / 0.14)')) + grad.addColorStop(1, hue.replace('rgb(', 'rgba(').replace(')', ' / 0)')) + ctx.globalAlpha = 0.9 * w.display[i].alpha * p.fog + ctx.fillStyle = grad + ctx.beginPath() + ctx.arc(p.sx, p.sy, r, 0, 7) + ctx.fill() + } + ctx.globalAlpha = 1 + // links + if (st.edgeMode !== 2) { + for (const [a, b] of g.edges) { + const pa = projected[a] + const pb = projected[b] + if (!pa || !pb) continue + const active = + g.nodes[a].path === w.hoverPath || + g.nodes[b].path === w.hoverPath || + g.nodes[a].path === w.focusPath || + g.nodes[b].path === w.focusPath + const fog = Math.min(pa.fog, pb.fog) + const al = + Math.min(w.display[a].alpha, w.display[b].alpha) * (st.edgeMode === 1 ? 0.5 : 0.22) * fog + if (al < 0.02 && !active) continue + ctx.strokeStyle = active ? col.muted : col.dim + ctx.globalAlpha = active ? 0.75 : al + ctx.lineWidth = Math.max(0.4, (pa.s + pb.s) * (active ? 0.55 : 0.4)) + ctx.beginPath() + ctx.moveTo(pa.sx, pa.sy) + ctx.lineTo(pb.sx, pb.sy) + ctx.stroke() + } + ctx.globalAlpha = 1 + } + // region names + const zl = F / cam.dist + const regionAlpha = Math.max(0, Math.min(1, (1.6 - zl) / 1.0)) + if (regionAlpha > 0.02 && g.regions.length > 1) { + ctx.textAlign = 'center' + g.regions.forEach((reg, ri) => { + if (reg.count === 0) return + const p = st.mode3d ? project(reg.cx, reg.cy, reg.cz) : project(reg.cx2, reg.cy2, 0) + if (!p) return + ctx.font = `500 ${Math.max(10, Math.min(19, 15 * p.s))}px Iowan Old Style, Palatino, Georgia, serif` + ctx.fillStyle = col.regionHues[ri % col.regionHues.length] + ctx.globalAlpha = regionAlpha * 0.8 * p.fog + ctx.fillText(reg.label.toUpperCase().split('').join(' '), p.sx, p.sy - 20 * p.s) + }) + ctx.globalAlpha = 1 + } + // nodes + for (const i of order) { + const p = projected[i]! + if (p.sx < -60 || p.sy < -60 || p.sx > vw + 60 || p.sy > vh + 60) continue + const n = g.nodes[i] + const d = w.display[i] + const em = emphasis(i) + const hue = col.regionHues[n.region % col.regionHues.length] + const base = Math.max(1.4, (3.4 + Math.sqrt(n.degree) * 2.1) * p.s) + if (em.glow > 0.05) { + const grad = ctx.createRadialGradient(p.sx, p.sy, 0, p.sx, p.sy, base * 4.2) + const glowHue = st.lens === 2 ? col.accent : hue + grad.addColorStop(0, glowHue.replace('rgb(', 'rgba(').replace(')', ' / 0.32)')) + grad.addColorStop(1, glowHue.replace('rgb(', 'rgba(').replace(')', ' / 0)')) + ctx.globalAlpha = em.glow * d.alpha * p.fog + ctx.fillStyle = grad + ctx.beginPath() + ctx.arc(p.sx, p.sy, base * 4.2, 0, 7) + ctx.fill() + } + ctx.globalAlpha = d.alpha * p.fog + ctx.fillStyle = hue + ctx.beginPath() + ctx.arc(p.sx, p.sy, base, 0, 7) + ctx.fill() + if (n.path === w.focusPath || n.path === w.hoverPath) { + ctx.strokeStyle = n.path === w.focusPath ? col.accent : col.fg + ctx.lineWidth = n.path === w.focusPath ? 1.7 : 1.1 + ctx.globalAlpha = 0.95 + ctx.beginPath() + ctx.arc(p.sx, p.sy, base + 4, 0, 7) + ctx.stroke() + } + } + ctx.globalAlpha = 1 + // labels by zoom band, with collision skip + const showAll = zl > 1.1 + if (zl > 0.5) { + const placed: Array<{ x: number; y: number, wd: number }> = [] + ctx.textAlign = 'left' + const byDeg = [...order].sort((a, b) => g.nodes[b].degree - g.nodes[a].degree) + for (const i of byDeg) { + const p = projected[i]! + const n = g.nodes[i] + const d = w.display[i] + if (d.alpha < 0.25 || p.fog < 0.4) continue + const isHub = n.degree >= 6 + const forced = n.path === w.focusPath || n.path === w.hoverPath + if (!showAll && !isHub && !forced) continue + if (p.sx < -40 || p.sy < -20 || p.sx > vw + 40 || p.sy > vh + 20) continue + ctx.font = `${isHub || forced ? '600' : '400'} ${(forced ? 12 : isHub ? 11.5 : 10.5).toFixed(1)}px ui-sans-serif, system-ui, sans-serif` + const tw = ctx.measureText(n.title).width + const lx = p.sx + 8 + const ly = p.sy + 4 + let collide = false + for (const pl of placed) + if (Math.abs(pl.y - ly) < 13 && lx < pl.x + pl.wd && lx + tw > pl.x) { + collide = true + break + } + if (collide && !forced) continue + placed.push({ x: lx, y: ly, wd: tw }) + const la = Math.min(1, d.alpha) * Math.max(0.8, p.fog) + ctx.globalAlpha = la * 0.62 + ctx.fillStyle = col.bg + ctx.beginPath() + ctx.roundRect(lx - 3, ly - 10, tw + 6, 13, 3) + ctx.fill() + ctx.fillStyle = forced || isHub ? col.fg : col.muted + ctx.globalAlpha = la + ctx.fillText(n.title, lx, ly) + } + ctx.globalAlpha = 1 + } + // hint labels + if (w.hint) { + ctx.textAlign = 'center' + ctx.font = '700 11px ui-monospace, Menlo, monospace' + for (const [i, code] of w.hint.labels) { + if (w.hint.typed && !code.startsWith(w.hint.typed)) continue + const p = projected[i] + if (!p) continue + ctx.fillStyle = col.accent + ctx.globalAlpha = 0.96 + ctx.beginPath() + ctx.roundRect(p.sx - 12, p.sy - 26, 24, 16, 4) + ctx.fill() + ctx.fillStyle = col.bg + ctx.fillText(code, p.sx, p.sy - 14) + } + ctx.globalAlpha = 1 + } + ;(canvas as unknown as { _proj?: typeof projected })._proj = projected + } + raf = requestAnimationFrame(draw) + return () => cancelAnimationFrame(raf) + }, []) + + // Bridges lens needs betweenness; computed once per graph, on demand. + useEffect(() => { + if (lens !== 4 || world.current.betweenness || graph.nodes.length === 0) return + const t = setTimeout(() => { + const nCount = graph.nodes.length + const adj: number[][] = graph.nodes.map(() => []) + graph.edges.forEach(([a, b]) => { + adj[a].push(b) + adj[b].push(a) + }) + const between = new Float64Array(nCount + 1) + for (let s = 0; s < nCount; s++) { + const stack: number[] = [] + const pred: number[][] = graph.nodes.map(() => []) + const sigma = new Float64Array(nCount) + const dist = new Int32Array(nCount).fill(-1) + const delta = new Float64Array(nCount) + sigma[s] = 1 + dist[s] = 0 + const queue = [s] + while (queue.length > 0) { + const v = queue.shift()! + stack.push(v) + for (const m of adj[v]) { + if (dist[m] < 0) { + dist[m] = dist[v] + 1 + queue.push(m) + } + if (dist[m] === dist[v] + 1) { + sigma[m] += sigma[v] + pred[m].push(v) + } + } + } + while (stack.length > 0) { + const m = stack.pop()! + for (const v of pred[m]) delta[v] += (sigma[v] / sigma[m]) * (1 + delta[m]) + if (m !== s) between[m] += delta[m] + } + } + between[nCount] = Math.max(1, ...Array.from(between.slice(0, nCount))) + world.current.betweenness = between + }, 10) + return () => clearTimeout(t) + }, [lens, graph]) + + function hitTest(sx: number, sy: number): number { + const canvas = canvasRef.current as unknown as { _proj?: Array } | null + const proj = canvas?._proj + if (!proj) return -1 + let best = -1 + let bd = Infinity + stateRef.current.graph.nodes.forEach((n, i) => { + const p = proj[i] + if (!p || world.current.display[i].alpha < 0.1) return + const r = Math.max(1.4, (3.4 + Math.sqrt(n.degree) * 2.1) * p.s) + 6 + const d = (p.sx - sx) ** 2 + (p.sy - sy) ** 2 + if (d < r * r && p.depth < bd) { + bd = p.depth + best = i + } + }) + return best + } + + function touch(): void { + world.current.interacted = performance.now() + } + function enterHint(): void { + const canvas = canvasRef.current as unknown as { _proj?: Array } | null + const proj = canvas?._proj + const c = canvasRef.current + if (!proj || !c) return + const vw = c.clientWidth + const vh = c.clientHeight + const visible = stateRef.current.graph.nodes + .map((_, i) => i) + .filter((i) => { + const p = proj[i] + return p && world.current.display[i].alpha > 0.2 && p.sx > 10 && p.sy > 40 && p.sx < vw - 10 && p.sy < vh - 40 + }) + .sort((a, b) => { + const pa = proj[a]! + const pb = proj[b]! + return ( + (pa.sx - vw / 2) ** 2 + (pa.sy - vh / 2) ** 2 - ((pb.sx - vw / 2) ** 2 + (pb.sy - vh / 2) ** 2) + ) + }) + .slice(0, 81) + const labels = new Map() + visible.forEach((i, idx) => labels.set(i, HINTKEYS[Math.floor(idx / 9)] + HINTKEYS[idx % 9])) + world.current.hint = { labels, typed: '' } + } + function centerOnNode(i: number): void { + const n = stateRef.current.graph.nodes[i] + const cam = world.current.cam + const [x, y, z] = target(n) + cam.cxT = x + cam.cyT = y + cam.czT = z + world.current.focusPath = n.path + touch() + } + function jumpRegion(dir: number): void { + const g = stateRef.current.graph + if (g.regions.length === 0) return + const focusNode = g.nodes.find((n) => n.path === world.current.focusPath) + const cur = focusNode ? focusNode.region : 0 + const next = (cur + dir + g.regions.length) % g.regions.length + const reg = g.regions[next] + const cam = world.current.cam + const c = canvasRef.current + const minSide = Math.min(c?.clientWidth || 1200, c?.clientHeight || 800) + if (stateRef.current.mode3d) { + cam.cxT = reg.cx + cam.cyT = reg.cy + cam.czT = reg.cz + } else { + cam.cxT = reg.cx2 + cam.cyT = reg.cy2 + cam.czT = 0 + } + cam.distT = Math.max(F * 0.5, (reg.r * F) / (minSide * 0.33)) + const hub = g.nodes.find((n) => n.region === next) + if (hub) world.current.focusPath = hub.path + setStatus(reg.label) + touch() + } + function openFocused(): void { + const path = world.current.focusPath + if (path) void selectNote(path) + } + + // Keyboard: capture phase so it beats VimNav; single letters are Vim-only + // per the house rule (arrows, Enter and Escape stay universal). + useEffect(() => { + if (!isActive) return + const handler = (e: KeyboardEvent): void => { + if (isAppOverlayOpen()) return + if (document.querySelector('[data-vim-hint-overlay]')) return + const active = document.activeElement as HTMLElement | null + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) return + const fp = useStore.getState().focusedPanel + if (fp != null && fp !== 'atlas') return + if (e.metaKey || e.ctrlKey || e.altKey) return + const st = stateRef.current + const w = world.current + const cam = w.cam + const consume = (): void => { + e.preventDefault() + e.stopImmediatePropagation() + } + if (w.hint) { + consume() + if (e.key === 'Escape') { + w.hint = null + return + } + if (/^[a-z]$/.test(e.key)) { + w.hint.typed += e.key + const matches = [...w.hint.labels.entries()].filter(([, c]) => c.startsWith(w.hint!.typed)) + if (matches.length === 1 && matches[0][1] === w.hint.typed) { + const i = matches[0][0] + w.hint = null + centerOnNode(i) + } else if (matches.length === 0) w.hint = null + } + return + } + const vim = st.vimMode + const pan = (60 * cam.dist) / F + const universal = (): boolean => { + switch (e.key) { + case 'ArrowLeft': + if (st.mode3d) cam.yawT -= 0.14 + else cam.cxT -= pan + return true + case 'ArrowRight': + if (st.mode3d) cam.yawT += 0.14 + else cam.cxT += pan + return true + case 'ArrowUp': + if (st.mode3d) cam.pitchT = Math.min(1.25, cam.pitchT + 0.1) + else cam.cyT -= pan + return true + case 'ArrowDown': + if (st.mode3d) cam.pitchT = Math.max(-1.25, cam.pitchT - 0.1) + else cam.cyT += pan + return true + case 'Enter': + openFocused() + return true + case 'Escape': + if (filterOpen || st.filterQ) { + setFilterOpen(false) + setFilterQ('') + } else if (st.lens !== 1) setLens(1) + else w.focusPath = null + return true + default: + return false + } + } + if (universal()) { + touch() + consume() + return + } + if (!vim) return + switch (e.key) { + case 'h': + if (st.mode3d) cam.yawT -= 0.14 + else cam.cxT -= pan + break + case 'l': + if (st.mode3d) cam.yawT += 0.14 + else cam.cxT += pan + break + case 'k': + if (st.mode3d) cam.pitchT = Math.min(1.25, cam.pitchT + 0.1) + else cam.cyT -= pan + break + case 'j': + if (st.mode3d) cam.pitchT = Math.max(-1.25, cam.pitchT - 0.1) + else cam.cyT += pan + break + case '+': + case '=': + if (st.mode3d) { + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + const travel = cam.distT * 0.18 + cam.cxT += sinY * cosP * travel + cam.cyT += sinP * travel + cam.czT += cosY * cosP * travel + } else cam.distT = Math.max(F * 0.3, cam.distT / 1.22) + break + case '-': + cam.distT = cam.distT * 1.22 + break + case 'f': + enterHint() + break + case '/': + setFilterOpen(true) + setTimeout(() => filterRef.current?.focus(), 30) + break + case 'v': + setMode3d((m) => !m) + break + case 'c': + setEdgeMode((m) => (m + 1) % 3) + break + case '[': + jumpRegion(-1) + break + case ']': + jumpRegion(1) + break + case '0': + fitAll() + break + case '1': + case '2': + case '3': + case '4': + setLens(Number(e.key) as Lens) + break + default: + return + } + touch() + consume() + } + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isActive, filterOpen]) + + // Mouse: drag orbits in 3D and pans in 2D; wheel flies; click focuses/opens. + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + let drag: { x: number; y: number } | null = null + let moved = false + const down = (e: MouseEvent): void => { + drag = { x: e.clientX, y: e.clientY } + moved = false + touch() + } + const move = (e: MouseEvent): void => { + const rect = canvas.getBoundingClientRect() + const sx = e.clientX - rect.left + const sy = e.clientY - rect.top + const cam = world.current.cam + if (drag) { + const dx = e.clientX - drag.x + const dy = e.clientY - drag.y + drag = { x: e.clientX, y: e.clientY } + if (Math.abs(dx) + Math.abs(dy) > 2) moved = true + if (stateRef.current.mode3d && !e.shiftKey) { + cam.yawT += dx * 0.005 + cam.yaw = cam.yawT + cam.pitchT = Math.max(-1.25, Math.min(1.25, cam.pitchT + dy * 0.004)) + cam.pitch = cam.pitchT + } else { + const s = F / cam.dist + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + cam.cxT -= (cosY * dx + sinY * sinP * dy) / s + cam.cyT -= (cosP * dy) / s + cam.czT -= (-sinY * dx + cosY * sinP * dy) / s + cam.cx = cam.cxT + cam.cy = cam.cyT + cam.cz = cam.czT + } + touch() + } else { + const i = hitTest(sx, sy) + world.current.hoverPath = i >= 0 ? stateRef.current.graph.nodes[i].path : null + canvas.style.cursor = i >= 0 ? 'pointer' : 'grab' + } + } + const up = (e: MouseEvent): void => { + if (drag && !moved) { + const rect = canvas.getBoundingClientRect() + const i = hitTest(e.clientX - rect.left, e.clientY - rect.top) + if (i >= 0) { + const path = stateRef.current.graph.nodes[i].path + if (world.current.focusPath === path) void selectNote(path) + else world.current.focusPath = path + } else world.current.focusPath = null + } + drag = null + } + const wheel = (e: WheelEvent): void => { + e.preventDefault() + touch() + const cam = world.current.cam + const f = Math.exp(e.deltaY * 0.0014) + if (stateRef.current.mode3d && f < 1) { + // Fly forward through the sky toward the cursor instead of shrinking + // the orbit: the camera target rides the view ray, so you pass between + // clusters rather than watching them scale. + const rect = canvas.getBoundingClientRect() + const vx0 = e.clientX - rect.left - rect.width / 2 + const vy0 = e.clientY - rect.top - rect.height / 2 + const len = Math.hypot(vx0, vy0, F) + const vx = vx0 / len + const vy = vy0 / len + const vz = F / len + const cosY = Math.cos(cam.yaw) + const sinY = Math.sin(cam.yaw) + const cosP = Math.cos(cam.pitch) + const sinP = Math.sin(cam.pitch) + const y1 = vy * cosP + vz * sinP + const z1 = -vy * sinP + vz * cosP + const dir = [vx * cosY + z1 * sinY, y1, -vx * sinY + z1 * cosY] + const travel = cam.distT * (1 - f) + cam.cxT += dir[0] * travel + cam.cyT += dir[1] * travel + cam.czT += dir[2] * travel + } else { + cam.distT = Math.max(F * 0.3, cam.distT * f) + } + } + canvas.addEventListener('mousedown', down) + window.addEventListener('mousemove', move) + window.addEventListener('mouseup', up) + canvas.addEventListener('wheel', wheel, { passive: false }) + return () => { + canvas.removeEventListener('mousedown', down) + window.removeEventListener('mousemove', move) + window.removeEventListener('mouseup', up) + canvas.removeEventListener('wheel', wheel) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const chip = + 'cursor-pointer rounded-full border border-paper-300 bg-paper-100 px-2.5 py-1 font-mono text-[11px] text-ink-600 hover:text-ink-800' + return ( +
setFocusedPanel('atlas')} + onFocusCapture={() => setFocusedPanel('atlas')} + tabIndex={0} + > + +
+
Atlas
+
+ {graph.nodes.length} notes · {graph.regions.filter((r) => r.count > 0).length} regions ·{' '} + {graph.edges.length} links{status ? ` · ${status}` : ''} +
+
+ {filterOpen && ( +
+ / + setFilterQ(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Escape') { + setFilterQ('') + setFilterOpen(false) + } + if (e.key === 'Enter') filterRef.current?.blur() + }} + placeholder="filter notes and tags" + className="w-44 bg-transparent text-sm text-ink-900 outline-none placeholder:text-ink-400" + /> +
+ )} +
+ + + + + + {vimMode && ( + + f jump · hjkl move · + - zoom · v 2d/3d · [ ] regions · 1..4 lens · Enter open + + )} +
+
+ ) +} diff --git a/packages/app-core/src/components/BufferPalette.tsx b/packages/app-core/src/components/BufferPalette.tsx index 1e394ff9..d9cd4067 100644 --- a/packages/app-core/src/components/BufferPalette.tsx +++ b/packages/app-core/src/components/BufferPalette.tsx @@ -23,6 +23,7 @@ import { isHelpTabPath } from '@shared/help' import { isTagsTabPath } from '@shared/tags' import { isTasksTabPath } from '@shared/tasks' import { isWorkflowsTabPath } from '@shared/workflows-view' +import { isAtlasTabPath } from '@shared/atlas-view' import { isArchiveTabPath } from '@shared/archive' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' @@ -94,6 +95,19 @@ function buildEntries(deps: BuildDeps): BufferEntry[] { }) return } + if (isAtlasTabPath(path)) { + entries.push({ + path, + title: 'Atlas', + subtitle: 'The vault as a map', + keywords: 'atlas map graph vault notes links virtual', + badge, + current: isCurrent, + dirty: false, + virtual: true + }) + return + } if (isWorkflowsTabPath(path)) { entries.push({ path, diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index 96781ddd..eb7ea27d 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -162,7 +162,9 @@ import { QuickNotesView } from './QuickNotesView' import type { MathRenderer } from '@shared/app-config' import { isTasksTabPath } from '@shared/tasks' import { isWorkflowsTabPath } from '@shared/workflows-view' +import { isAtlasTabPath } from '@shared/atlas-view' import { LazyWorkflowsView } from './LazyWorkflowsView' +import { LazyAtlasView } from './LazyAtlasView' import { isDatabaseTabPath, databaseTitleFromTab, databaseTabPath, isDatabaseCsvPath } from '@shared/databases' import { isTagsTabPath } from '@shared/tags' import { isHelpTabPath } from '@shared/help' @@ -2767,6 +2769,13 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { isWorkflows: true } } + if (isAtlasTabPath(path)) { + return { + ...base, + title: 'Atlas', + isWorkflows: true + } + } if (isTasksTabPath(path)) { return { ...base, @@ -2909,6 +2918,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { if ( isQuickNotesTabPath(path) || isWorkflowsTabPath(path) || + isAtlasTabPath(path) || isTagsTabPath(path) || isHelpTabPath(path) || isArchiveTabPath(path) || @@ -3750,6 +3760,8 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { )} {isWorkflowsTabPath(activeTab) ? ( + ) : isAtlasTabPath(activeTab) ? ( + ) : isTasksTabPath(activeTab) ? ( ) : isQuickNotesTabPath(activeTab) ? ( diff --git a/packages/app-core/src/components/LazyAtlasView.tsx b/packages/app-core/src/components/LazyAtlasView.tsx new file mode 100644 index 00000000..71c10a44 --- /dev/null +++ b/packages/app-core/src/components/LazyAtlasView.tsx @@ -0,0 +1,14 @@ +// Same lazy pattern as LazyWorkflowsView: the canvas view stays out of the +// boot path, and deliberately gets NO named manualChunks rule (see the +// packaging scars in electron.vite.config.ts). +import { lazy, Suspense } from 'react' + +const AtlasViewImpl = lazy(() => import('./AtlasView').then((mod) => ({ default: mod.AtlasView }))) + +export function LazyAtlasView(): JSX.Element { + return ( + + + + ) +} diff --git a/packages/app-core/src/components/OutlinePalette.tsx b/packages/app-core/src/components/OutlinePalette.tsx index f0f6e68b..6f14be25 100644 --- a/packages/app-core/src/components/OutlinePalette.tsx +++ b/packages/app-core/src/components/OutlinePalette.tsx @@ -17,6 +17,7 @@ import { isArchiveTabPath } from '@shared/archive' import { isTagsTabPath } from '@shared/tags' import { isTasksTabPath } from '@shared/tasks' import { isWorkflowsTabPath } from '@shared/workflows-view' +import { isAtlasTabPath } from '@shared/atlas-view' import { isTrashTabPath } from '@shared/trash' import { isQuickNotesTabPath } from '@shared/quick-notes' import { focusEditorNormalMode } from '../lib/editor-focus' @@ -28,6 +29,7 @@ function isVirtualPath(path: string | null): boolean { isQuickNotesTabPath(path) || isTasksTabPath(path) || isWorkflowsTabPath(path) || + isAtlasTabPath(path) || isTagsTabPath(path) || isHelpTabPath(path) || isArchiveTabPath(path) || diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 931185db..4c8b9205 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -511,6 +511,8 @@ export function SettingsModal(): JSX.Element { const setTabsEnabled = useStore((s) => s.setTabsEnabled); const workflowsEnabled = useStore((s) => s.workflowsEnabled); const setWorkflowsEnabled = useStore((s) => s.setWorkflowsEnabled); + const atlasEnabled = useStore((s) => s.atlasEnabled); + const setAtlasEnabled = useStore((s) => s.setAtlasEnabled); const hiddenWorkflowPresets = useStore((s) => s.hiddenWorkflowPresets); const setHiddenWorkflowPresets = useStore((s) => s.setHiddenWorkflowPresets); const wrapTabs = useStore((s) => s.wrapTabs); @@ -2105,6 +2107,13 @@ export function SettingsModal(): JSX.Element { "language", ], }, + { + id: "atlas-enabled", + title: "Atlas", + description: + "The Atlas map: the whole vault as notes and links, in 2D or 3D.", + keywords: ["atlas", "map", "graph", "visualize", "links", "network", "sky", "3d"], + }, { id: "workflows-enabled", title: "Workflows", @@ -2586,6 +2595,29 @@ export function SettingsModal(): JSX.Element {
), }, + { + id: "atlas", + title: "Atlas", + description: + "The Atlas map of the vault, and whether it appears in the app at all.", + searchIds: ["atlas-enabled"], + content: ( +
+
+ +
+
+ ), + }, { id: "workflows", title: "Workflows", diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index f3a543f7..a0418604 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { isTasksViewActive, isTrashViewActive, isWorkflowsViewActive, + isAtlasViewActive, useStore, } from "../store"; import { Button } from "./ui/Button"; @@ -56,6 +57,7 @@ import { TargetIcon, TrashIcon, WorkflowIcon, + AtlasIcon, } from "./icons"; import { ContextMenu, type ContextMenuItem } from "./ContextMenu"; import { ResizeHandle } from "./ResizeHandle"; @@ -461,6 +463,9 @@ export function Sidebar(): JSX.Element { const openWorkflowsView = useStore((s) => s.openWorkflowsView); const workflowsViewActive = useStore(isWorkflowsViewActive); const workflowsEnabled = useStore((s) => s.workflowsEnabled); + const openAtlasView = useStore((s) => s.openAtlasView); + const atlasViewActive = useStore(isAtlasViewActive); + const atlasEnabled = useStore((s) => s.atlasEnabled); const openQuickNotesView = useStore((s) => s.openQuickNotesView); const quickNotesViewActive = useStore(isQuickNotesViewActive); const openHelpView = useStore((s) => s.openHelpView); @@ -3350,6 +3355,20 @@ export function Sidebar(): JSX.Element { /> )} + {/* Same skip-before-props trick as Workflows above. */} + {atlasEnabled && ( + void openAtlasView()} + label="Atlas" + icon={} + sidebarType="workflows" + sidebarIdx={idxCounter.current.value++} + vimHighlight={vimCursor === idxCounter.current.value - 1} + sidebarFocused={isSidebarFocused} + /> + )} + ( ) +/** Three linked nodes: a constellation, the shape of the Atlas map. */ +export const AtlasIcon = (p: IconProps): JSX.Element => ( + + + + + + +) + export const TableIcon = (p: IconProps): JSX.Element => ( diff --git a/packages/app-core/src/lib/atlas.ts b/packages/app-core/src/lib/atlas.ts new file mode 100644 index 00000000..fb399237 --- /dev/null +++ b/packages/app-core/src/lib/atlas.ts @@ -0,0 +1,367 @@ +// Atlas: the vault drawn as a map. Pure graph + layout logic, kept out of the +// component so it is testable and could move to a worker later. +// +// Two hard rules from the design (docs/ideas/atlas.md): +// - The layout is DETERMINISTIC and FROZEN. Same notes + links = same map. +// - Notes already on the map never move when new notes arrive; newcomers are +// placed at the centroid of their linked neighbors (or their region) so the +// geography stays a place the user knows. +import type { NoteMeta } from '@shared/ipc' +import { resolveWikilinkTarget } from './wikilinks' + +export interface AtlasNode { + path: string + title: string + region: number + tags: readonly string[] + createdAt: number + updatedAt: number + degree: number + x: number + y: number + z: number + x2: number + y2: number +} + +export interface AtlasRegion { + key: string + label: string + count: number + cx: number + cy: number + cz: number + cx2: number + cy2: number + r: number +} + +export interface AtlasGraph { + nodes: AtlasNode[] + edges: Array<[number, number]> + regions: AtlasRegion[] +} + +export type AtlasPositions = Record< + string, + { x: number; y: number; z: number; x2: number; y2: number } +> + +const GOLD = 2.3999632 +const SEED = 20260818 + +function mulberry32(a: number): () => number { + return function () { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** The region a note belongs to: its top-level folder (clustering can come later). */ +export function atlasRegionKey(path: string): string { + const slash = path.indexOf('/') + return slash === -1 ? '' : path.slice(0, slash) +} + +export function buildAtlasGraph(notes: readonly NoteMeta[]): AtlasGraph { + const usable = notes.filter((n) => n.folder !== 'trash') + const regionKeys = new Map() + const regionCounts: number[] = [] + const regionLabels: string[] = [] + for (const n of usable) { + const key = atlasRegionKey(n.path) + if (!regionKeys.has(key)) { + regionKeys.set(key, regionLabels.length) + regionLabels.push(key === '' ? 'Notes' : key) + regionCounts.push(0) + } + regionCounts[regionKeys.get(key)!]++ + } + const index = new Map() + const nodes: AtlasNode[] = usable.map((n, i) => { + index.set(n.path, i) + return { + path: n.path, + title: n.title, + region: regionKeys.get(atlasRegionKey(n.path))!, + tags: n.tags, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + degree: 0, + x: 0, + y: 0, + z: 0, + x2: 0, + y2: 0 + } + }) + const edgeSet = new Set() + const edges: Array<[number, number]> = [] + usable.forEach((n, i) => { + for (const target of n.wikilinks) { + const resolved = resolveWikilinkTarget(usable, target) + if (!resolved) continue + const j = index.get(resolved.path) + if (j === undefined || j === i) continue + const key = i < j ? i + ':' + j : j + ':' + i + if (edgeSet.has(key)) continue + edgeSet.add(key) + edges.push(i < j ? [i, j] : [j, i]) + nodes[i].degree++ + nodes[j].degree++ + } + }) + const regions: AtlasRegion[] = regionLabels.map((label, ri) => ({ + key: label, + label, + count: regionCounts[ri], + cx: 0, + cy: 0, + cz: 0, + cx2: 0, + cy2: 0, + r: 30 * Math.sqrt(regionCounts[ri]) + 36 + })) + return { nodes, edges, regions } +} + +/** Neighbor pairs via a spatial hash so relaxation stays near-linear on big vaults. */ +function relax( + nodes: AtlasNode[], + edges: Array<[number, number]>, + dims: 2 | 3, + iterations: number, + get: (n: AtlasNode) => [number, number, number], + set: (n: AtlasNode, x: number, y: number, z: number) => void, + regionCenter: (n: AtlasNode) => [number, number, number] +): void { + const CUT = 210 + for (let it = 0; it < iterations; it++) { + const fx = new Float64Array(nodes.length) + const fy = new Float64Array(nodes.length) + const fz = new Float64Array(nodes.length) + const grid = new Map() + nodes.forEach((n, i) => { + const [x, y, z] = get(n) + const key = + Math.floor(x / CUT) + ',' + Math.floor(y / CUT) + ',' + (dims === 3 ? Math.floor(z / CUT) : 0) + const cell = grid.get(key) + if (cell) cell.push(i) + else grid.set(key, [i]) + }) + nodes.forEach((n, i) => { + const [x, y, z] = get(n) + const gx = Math.floor(x / CUT) + const gy = Math.floor(y / CUT) + const gz = dims === 3 ? Math.floor(z / CUT) : 0 + for (let ax = gx - 1; ax <= gx + 1; ax++) + for (let ay = gy - 1; ay <= gy + 1; ay++) + for (let az = dims === 3 ? gz - 1 : 0; az <= (dims === 3 ? gz + 1 : 0); az++) { + const cell = grid.get(ax + ',' + ay + ',' + az) + if (!cell) continue + for (const j of cell) { + if (j <= i) continue + const [bx, by, bz] = get(nodes[j]) + const dx = bx - x + const dy = by - y + const dz = dims === 3 ? bz - z : 0 + const d2 = dx * dx + dy * dy + dz * dz + 40 + if (d2 > CUT * CUT) continue + const f = 2400 / d2 + const d = Math.sqrt(d2) + fx[i] -= (f * dx * 13) / d + fy[i] -= (f * dy * 13) / d + fz[i] -= (f * dz * 13) / d + fx[j] += (f * dx * 13) / d + fy[j] += (f * dy * 13) / d + fz[j] += (f * dz * 13) / d + } + } + }) + for (const [a, b] of edges) { + const [ax, ay, az] = get(nodes[a]) + const [bx, by, bz] = get(nodes[b]) + const dx = bx - ax + const dy = by - ay + const dz = dims === 3 ? bz - az : 0 + const d = Math.hypot(dx, dy, dz) || 1 + const same = nodes[a].region === nodes[b].region + const rest = same ? 90 : 330 + const k = same ? 0.016 : 0.004 + const f = k * (d - rest) + fx[a] += (f * dx) / d + fy[a] += (f * dy) / d + fz[a] += (f * dz) / d + fx[b] -= (f * dx) / d + fy[b] -= (f * dy) / d + fz[b] -= (f * dz) / d + } + nodes.forEach((n, i) => { + const [x, y, z] = get(n) + const [cx, cy, cz] = regionCenter(n) + fx[i] += (cx - x) * 0.012 + fy[i] += (cy - y) * 0.012 + fz[i] += (cz - z) * 0.012 + const clamp = (v: number): number => Math.max(-13, Math.min(13, v)) + set(n, x + clamp(fx[i]), y + clamp(fy[i]), dims === 3 ? z + clamp(fz[i]) : 0) + }) + } +} + +/** + * Lay out the graph in both dimensions. Nodes whose path appears in `previous` + * keep those positions verbatim; only newcomers are computed. When most of the + * vault is new (or nothing is cached) the full deterministic layout runs. + */ +export function layoutAtlas(graph: AtlasGraph, previous?: AtlasPositions | null): void { + const { nodes, edges, regions } = graph + const rng = mulberry32(SEED) + const R = regions.length + regions.forEach((reg, ri) => { + const yy = R === 1 ? 0 : 1 - (2 * (ri + 0.5)) / R + const rr = Math.sqrt(Math.max(0, 1 - yy * yy)) + const th = ri * GOLD * 2.1 + reg.cx = Math.cos(th) * rr * 500 + reg.cy = yy * 350 + reg.cz = Math.sin(th) * rr * 500 + const ang = ri * GOLD + const rad = ri === 0 ? 0 : 300 * Math.sqrt(ri + 0.55) + reg.cx2 = Math.cos(ang) * rad + reg.cy2 = Math.sin(ang) * rad * 0.86 + }) + const known = new Set() + if (previous) { + for (const n of nodes) { + const p = previous[n.path] + if (!p) continue + n.x = p.x + n.y = p.y + n.z = p.z + n.x2 = p.x2 + n.y2 = p.y2 + known.add(n.path) + } + } + const fresh = nodes.length - known.size + const fullLayout = known.size === 0 || fresh / Math.max(1, nodes.length) > 0.4 + const perRegionIndex = new Map() + const adjacency = new Map() + edges.forEach(([a, b]) => { + ;(adjacency.get(a) ?? adjacency.set(a, []).get(a)!).push(b) + ;(adjacency.get(b) ?? adjacency.set(b, []).get(b)!).push(a) + }) + nodes.forEach((n, i) => { + const j = perRegionIndex.get(n.region) ?? 0 + perRegionIndex.set(n.region, j + 1) + if (!fullLayout && known.has(n.path)) return + const reg = regions[n.region] + const jitter = (): number => (rng() - 0.5) * 22 + const placedNeighbors = fullLayout + ? [] + : (adjacency.get(i) ?? []).filter((m) => known.has(nodes[m].path)) + if (!fullLayout && placedNeighbors.length > 0) { + // A newcomer lands beside what it links to; nothing else moves. + const sum = placedNeighbors.reduce( + (s, m) => { + const nb = nodes[m] + return [s[0] + nb.x, s[1] + nb.y, s[2] + nb.z, s[3] + nb.x2, s[4] + nb.y2] + }, + [0, 0, 0, 0, 0] + ) + const c = placedNeighbors.length + n.x = sum[0] / c + jitter() + n.y = sum[1] / c + jitter() + n.z = sum[2] / c + jitter() + n.x2 = sum[3] / c + jitter() + n.y2 = sum[4] / c + jitter() + return + } + const count = Math.max(1, reg.count) + const yy = 1 - (2 * (j + 0.5)) / count + const rr = Math.sqrt(Math.max(0, 1 - yy * yy)) + const th = j * GOLD + n.region * 1.7 + const rad = reg.r * 0.85 + n.x = reg.cx + Math.cos(th) * rr * rad + jitter() + n.y = reg.cy + yy * rad * 0.85 + jitter() + n.z = reg.cz + Math.sin(th) * rr * rad + jitter() + const rr2 = reg.r * 0.9 * Math.sqrt((j + 0.6) / count) + n.x2 = reg.cx2 + Math.cos(th) * rr2 + jitter() + n.y2 = reg.cy2 + Math.sin(th) * rr2 + jitter() + }) + if (fullLayout) { + const iterations = nodes.length > 1500 ? 50 : 120 + relax( + nodes, + edges, + 3, + iterations, + (n) => [n.x, n.y, n.z], + (n, x, y, z) => { + n.x = x + n.y = y + n.z = z + }, + (n) => [regions[n.region].cx, regions[n.region].cy, regions[n.region].cz] + ) + relax( + nodes, + edges, + 2, + iterations, + (n) => [n.x2, n.y2, 0], + (n, x, y) => { + n.x2 = x + n.y2 = y + }, + (n) => [regions[n.region].cx2, regions[n.region].cy2, 0] + ) + } + // Label anchors follow the notes, wherever they ended up. + regions.forEach((reg, ri) => { + const rn = nodes.filter((n) => n.region === ri) + if (rn.length === 0) return + reg.cx = rn.reduce((s, n) => s + n.x, 0) / rn.length + reg.cy = rn.reduce((s, n) => s + n.y, 0) / rn.length + reg.cz = rn.reduce((s, n) => s + n.z, 0) / rn.length + reg.cx2 = rn.reduce((s, n) => s + n.x2, 0) / rn.length + reg.cy2 = rn.reduce((s, n) => s + n.y2, 0) / rn.length + }) +} + +/** + * Merge extra note-to-note edges (markdown-style links, scanned lazily by the + * view) into an already-laid-out graph. Additive only: positions stay frozen, + * duplicates are dropped, degrees grow so node sizes stay honest. + */ +export function applyExtraLinkEdges( + graph: AtlasGraph, + pairs: ReadonlyArray<[string, string]> +): number { + const index = new Map() + graph.nodes.forEach((n, i) => index.set(n.path, i)) + const seen = new Set(graph.edges.map(([a, b]) => a + ':' + b)) + let added = 0 + for (const [pa, pb] of pairs) { + const a = index.get(pa) + const b = index.get(pb) + if (a === undefined || b === undefined || a === b) continue + const lo = Math.min(a, b) + const hi = Math.max(a, b) + if (seen.has(lo + ':' + hi)) continue + seen.add(lo + ':' + hi) + graph.edges.push([lo, hi]) + graph.nodes[a].degree++ + graph.nodes[b].degree++ + added++ + } + return added +} + +export function collectAtlasPositions(graph: AtlasGraph): AtlasPositions { + const out: AtlasPositions = {} + for (const n of graph.nodes) out[n.path] = { x: n.x, y: n.y, z: n.z, x2: n.x2, y2: n.y2 } + return out +} diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 753581b1..78329547 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -1117,6 +1117,16 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma when: () => getState().workflowsEnabled, run: () => getState().openWorkflowsView() }, + { + id: 'view.atlas', + title: 'Open Atlas', + category: 'View', + shortcut: leaderShortcut('vim.leaderAtlas'), + keywords: 'atlas map graph vault visualize notes links regions sky 3d', + // Hidden entirely when the feature is switched off in Settings + when: () => getState().atlasEnabled, + run: () => getState().openAtlasView() + }, { id: 'view.tags', title: 'Open Tags', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 3867bc7e..ba383458 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -520,6 +520,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Space p', action: 'Note outline', detail: 'Jump to any heading in the active note via a searchable overlay.' }, { keys: 'Space v', action: 'Switch vault', detail: 'Open the command palette directly to the local vault switcher.' }, { keys: 'Space a', action: 'Open workflows', detail: 'Open the Workflows view, where saved pipelines over your notes are built and run. Workflows are off by default; turn them on under Settings → Workflows first.' }, + { keys: 'Space g', action: 'Open atlas', detail: 'Open the Atlas view: the whole vault drawn as a map of notes and links.' }, { keys: 'Space q', action: 'Quick capture window', detail: 'Open the floating, always-on-top capture window, same as the global hotkey.' }, { keys: 'Space i', action: 'Insert template into note', detail: 'Pick a template and insert it at the cursor of the active note, instead of creating a new note from it.' }, { keys: 'Space c', action: 'Toggle calendar', detail: 'Show or hide the calendar panel for the active pane.' }, @@ -635,6 +636,23 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Esc', action: 'Clear the filter', detail: 'Clears an active filter. These views are tabs, so Esc no longer closes them — close with :q or the ✕ in the tab header.' } ] }, + { + id: 'atlas-view', + title: 'Atlas view', + description: + 'On by default; switch it off under Settings → Atlas. Single-letter keys are Vim-mode only; arrows, Enter, and Esc always work, and every action is also on the toolbar chips along the bottom.', + items: [ + { keys: 'f', action: 'Hint jump', detail: 'Two-letter labels appear over visible notes; type one to fly there. The same hint mode as everywhere else in the app.' }, + { keys: 'h j k l', action: 'Move the camera', detail: 'Pan the map in 2D; orbit and tilt the sky in 3D. Arrow keys do the same in either Vim mode.' }, + { keys: '+ / -', action: 'Zoom', detail: 'Fly closer or further. The mouse wheel and pinch do the same.' }, + { keys: 'v', action: 'Map or sky', detail: 'Toggle between the flat 2D map and the 3D sky. Both use the same frozen layout, so nothing ever shuffles.' }, + { keys: '1 2 3 4', action: 'Lenses', detail: 'Structure, Heat (recent editing glows), Orphans (notes with no links), and Bridges (notes holding regions together).' }, + { keys: '[ / ]', action: 'Previous / next region', detail: 'Fly between regions. Regions are your top-level folders.' }, + { keys: 'c', action: 'Links quiet, all, off', detail: 'How much of the link web is drawn. Quiet keeps lines faint until a note is hovered or focused.' }, + { keys: 'Enter', action: 'Open the focused note', detail: 'Click once to focus a note, twice (or Enter) to open it in the editor.' }, + { keys: '/', action: 'Filter', detail: 'Dim every note not matching a title or tag search, live.' } + ] + }, { id: 'workflows-view', title: 'Workflows view', @@ -1057,6 +1075,12 @@ export const HELP_SETTINGS: HelpSettingsSection[] = [ { label: 'Kanban statuses', detail: 'Define the ordered columns of the custom-status Kanban board here; `kanban_statuses` under `[view]` in `config.toml` is the file-level equivalent.' } ] }, + { + title: 'Atlas', + items: [ + { label: 'Atlas', detail: 'On by default. The vault drawn as a map: every note a point sized by how linked it is, every wikilink a line, regions from your top-level folders, in 2D or 3D. Positions are computed once and cached so the map stays a place you know. Turning it off hides the sidebar row, the command, and the Space g binding. Theme authors can restyle it with the --z-atlas-bg and --z-atlas-region-1 through --z-atlas-region-8 variables.' } + ] + }, { title: 'Workflows', items: [ diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 10acbcaa..20660657 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -50,6 +50,7 @@ export type KeymapId = | "vim.leaderPrefix" | "vim.leaderOpenBuffers" | "vim.leaderWorkflows" + | "vim.leaderAtlas" | "vim.leaderSearchNotes" | "vim.leaderSearchGroup" | "vim.leaderSearchVaultText" @@ -445,6 +446,17 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ vimOnly: true, maxTokens: 1, }, + { + id: "vim.leaderAtlas", + kind: "sequence", + scope: "leader", + group: "vim", + title: "Leader: open atlas", + description: "Open the Atlas map of the vault.", + defaultBinding: "g", + vimOnly: true, + maxTokens: 1, + }, { id: "vim.leaderWorkflows", kind: "sequence", diff --git a/packages/app-core/src/lib/pane-nav.ts b/packages/app-core/src/lib/pane-nav.ts index 9b8b04e6..754a45b5 100644 --- a/packages/app-core/src/lib/pane-nav.ts +++ b/packages/app-core/src/lib/pane-nav.ts @@ -7,7 +7,7 @@ * mental model matches what they see on screen — sibling panes in a * deeply nested split still look like simple neighbors. */ -import { isTasksViewActive, useStore } from '../store' +import { isAtlasViewActive, isTasksViewActive, useStore } from '../store' import { getVisiblePanelsNow, resolveNextPanel, type Panel } from './vim-nav' import { ROW_PANEL_DEFS, @@ -105,7 +105,8 @@ function getVisiblePanelList(state: ReturnType): Panel sidebarOpen: state.sidebarOpen, noteListOpen: state.noteListOpen, unifiedSidebar: state.unifiedSidebar, - tasksViewOpen: isTasksViewActive(state) + tasksViewOpen: isTasksViewActive(state), + atlasViewOpen: isAtlasViewActive(state) }) } diff --git a/packages/app-core/src/lib/vim-nav.ts b/packages/app-core/src/lib/vim-nav.ts index e3098341..3e6c768d 100644 --- a/packages/app-core/src/lib/vim-nav.ts +++ b/packages/app-core/src/lib/vim-nav.ts @@ -53,6 +53,7 @@ export type Panel = | 'hoverpreview' | 'tasks' | 'tags' + | 'atlas' export interface PanelVisibility { sidebarOpen: boolean @@ -63,6 +64,7 @@ export interface PanelVisibility { outlineOpen: boolean calendarOpen: boolean tasksViewOpen: boolean + atlasViewOpen?: boolean } /** Every panel on screen, ordered left to right — the focus order both pane @@ -72,7 +74,7 @@ export function getVisiblePanels(visibility: PanelVisibility): Panel[] { const panels: Panel[] = [] if (visibility.sidebarOpen) panels.push('sidebar') if (visibility.noteListOpen && !visibility.unifiedSidebar) panels.push('notelist') - panels.push(visibility.tasksViewOpen ? 'tasks' : 'editor') + panels.push(visibility.tasksViewOpen ? 'tasks' : visibility.atlasViewOpen ? 'atlas' : 'editor') if (visibility.connectionsOpen) panels.push('connections') if (visibility.commentsOpen) panels.push('comments') if (visibility.outlineOpen) panels.push('outline') @@ -102,6 +104,7 @@ export function getVisiblePanelsNow(state: { noteListOpen: boolean unifiedSidebar: boolean tasksViewOpen: boolean + atlasViewOpen?: boolean }): Panel[] { const onScreen = (selector: string): boolean => typeof document !== 'undefined' && document.querySelector(selector) !== null @@ -110,6 +113,7 @@ export function getVisiblePanelsNow(state: { noteListOpen: state.noteListOpen, unifiedSidebar: state.unifiedSidebar, tasksViewOpen: state.tasksViewOpen, + atlasViewOpen: state.atlasViewOpen, connectionsOpen: onScreen(PANEL_MARKERS.connections), commentsOpen: onScreen(PANEL_MARKERS.comments), outlineOpen: onScreen(PANEL_MARKERS.outline), diff --git a/packages/app-core/src/lib/workspace-tabs.ts b/packages/app-core/src/lib/workspace-tabs.ts index ac472345..10d4736e 100644 --- a/packages/app-core/src/lib/workspace-tabs.ts +++ b/packages/app-core/src/lib/workspace-tabs.ts @@ -5,6 +5,7 @@ import { isTagsTabPath } from '@shared/tags' import { isTasksTabPath } from '@shared/tasks' import { isTrashTabPath } from '@shared/trash' import { isWorkflowsTabPath } from '@shared/workflows-view' +import { isAtlasTabPath } from '@shared/atlas-view' import { isDatabaseTabPath } from '@shared/databases' import { isAssetsViewTabPath } from '@shared/assets-view' import { isAssetTabPath } from './asset-tabs' @@ -16,6 +17,7 @@ export function isWorkspaceVirtualTabPath(path: string): boolean { isQuickNotesTabPath(path) || isTasksTabPath(path) || isWorkflowsTabPath(path) || + isAtlasTabPath(path) || isTagsTabPath(path) || isHelpTabPath(path) || isArchiveTabPath(path) || diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index ba32842b..1094a1f8 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -62,6 +62,7 @@ import { recordTitle, composePageBody } from './lib/database-cells' import { applyManualMove, manualOrderCompare, parentDirOf } from './lib/manual-order' import { TAGS_TAB_PATH, isTagsTabPath } from '@shared/tags' import { WORKFLOWS_TAB_PATH, isWorkflowsTabPath } from '@shared/workflows-view' +import { ATLAS_TAB_PATH, isAtlasTabPath } from '@shared/atlas-view' import { HELP_TAB_PATH, isHelpTabPath } from '@shared/help' import { ARCHIVE_TAB_PATH, isArchiveTabPath } from '@shared/archive' import { TRASH_TAB_PATH, isTrashTabPath } from '@shared/trash' @@ -643,6 +644,7 @@ interface Prefs { * closes any tab already showing it. OFF by default, deliberately: it can * rewrite notes in bulk, so it is a one-time opt-in under Settings. */ workflowsEnabled: boolean + atlasEnabled: boolean /** Built-in workflow recipes hidden from the gallery, by preset id. Unknown * ids are kept rather than pruned, so hiding a preset survives the preset * itself being renamed away and back across versions. */ @@ -1032,6 +1034,7 @@ export const DEFAULT_PREFS: Prefs = { // graph editor asks more of a new user than any other view. The feature is // opted into once in Settings -> Workflows, not stumbled into. workflowsEnabled: false, + atlasEnabled: true, hiddenWorkflowPresets: [], collapsedTagNodes: [], autoCalendarPanel: true, @@ -1324,6 +1327,8 @@ function normalizePrefs(p: Partial): Prefs { typeof p.workflowsEnabled === 'boolean' ? p.workflowsEnabled : DEFAULT_PREFS.workflowsEnabled, + atlasEnabled: + typeof p.atlasEnabled === 'boolean' ? p.atlasEnabled : DEFAULT_PREFS.atlasEnabled, hiddenWorkflowPresets: normalizeHiddenWorkflowPresets(p.hiddenWorkflowPresets), collapsedTagNodes: Array.isArray(p.collapsedTagNodes) ? p.collapsedTagNodes.filter((k): k is string => typeof k === 'string') @@ -2163,6 +2168,7 @@ function collectPrefs(s: { tagsCollapsed: boolean nestedTags: boolean workflowsEnabled: boolean + atlasEnabled: boolean hiddenWorkflowPresets: string[] collapsedTagNodes: string[] autoCalendarPanel: boolean @@ -2254,6 +2260,7 @@ function collectPrefs(s: { tagsCollapsed: s.tagsCollapsed, nestedTags: s.nestedTags, workflowsEnabled: s.workflowsEnabled, + atlasEnabled: s.atlasEnabled, hiddenWorkflowPresets: s.hiddenWorkflowPresets, collapsedTagNodes: s.collapsedTagNodes, autoCalendarPanel: s.autoCalendarPanel, @@ -2533,6 +2540,16 @@ export function isWorkflowsViewActive(state: { return leaf?.activeTab === WORKFLOWS_TAB_PATH } +/** True when the active pane is showing the Atlas map. Mirrors + * `isTasksViewActive`; the sidebar row uses it for its selected state. */ +export function isAtlasViewActive(state: { + paneLayout: PaneLayout + activePaneId: string +}): boolean { + const leaf = findLeaf(state.paneLayout, state.activePaneId) + return leaf?.activeTab === ATLAS_TAB_PATH +} + function hasTasksViewOpen(state: { paneLayout: PaneLayout }): boolean { return allLeaves(state.paneLayout).some((leaf) => leaf.tabs.includes(TASKS_TAB_PATH)) } @@ -2818,6 +2835,7 @@ interface Store { * row, the `view.workflows` command, and the leader binding, so the canvas * has no way in at all. */ workflowsEnabled: boolean + atlasEnabled: boolean /** Built-in recipes hidden from the New-workflow gallery, by preset id. * Persisted (portable). Hiding is per taste, not per vault. */ hiddenWorkflowPresets: string[] @@ -2970,6 +2988,8 @@ interface Store { openTagView: (tag?: string) => Promise /** Open the Workflows canvas as a tab in the active pane. */ openWorkflowsView: () => Promise + /** Open the Atlas map as a tab in the active pane. */ + openAtlasView: () => Promise /** Close the Tags tab in every pane and clear the selection. */ closeTagView: () => void /** Open the built-in Help tab in the active pane. */ @@ -3188,6 +3208,7 @@ interface Store { /** Turn the whole Workflows feature on or off. Switching it off also closes * any pane still showing the canvas. */ setWorkflowsEnabled: (on: boolean) => void + setAtlasEnabled: (on: boolean) => void hideWorkflowPreset: (id: string) => void restoreWorkflowPreset: (id: string) => void /** Wholesale replacement, for Settings' Hide all / Restore all. The preset @@ -4274,6 +4295,9 @@ export const useStore = create((set, get) => { if (!get().workflowsEnabled) { layout = rewritePathsInTree(layout, (path) => (isWorkflowsTabPath(path) ? null : path)) } + if (!get().atlasEnabled) { + layout = rewritePathsInTree(layout, (path) => (isAtlasTabPath(path) ? null : path)) + } const unreadable = new Set() const contents: Record = {} const dirty: Record = {} @@ -4524,6 +4548,7 @@ export const useStore = create((set, get) => { tagsCollapsed: loadPrefs().tagsCollapsed, nestedTags: loadPrefs().nestedTags, workflowsEnabled: loadPrefs().workflowsEnabled, + atlasEnabled: loadPrefs().atlasEnabled, hiddenWorkflowPresets: loadPrefs().hiddenWorkflowPresets, collapsedTagNodes: loadPrefs().collapsedTagNodes, autoCalendarPanel: loadPrefs().autoCalendarPanel, @@ -7093,11 +7118,29 @@ export const useStore = create((set, get) => { set({ hideBuiltinTemplates: hidden }) savePrefs(collectPrefs(get())) }, + + openAtlasView: async () => { + const state = get() + // Single funnel for every entry point (sidebar row, command, leader key), + // so the feature switch holds even if a caller forgets to check it. + if (!state.atlasEnabled) return + await get().openNoteInPane(state.activePaneId, ATLAS_TAB_PATH) + // Hand the keyboard over on EVERY open, not just the first: clicking the + // sidebar row leaves focus (and focusedPanel) on the sidebar, and the + // row click is also how people re-enter an already-open Atlas tab. + ;(document.activeElement as HTMLElement | null)?.blur?.() + set({ focusedPanel: 'atlas' }) + }, setWorkflowsEnabled: (on) => { set({ workflowsEnabled: on }) savePrefs(collectPrefs(get())) if (!on) closeWorkflowsTabsEverywhere() }, + setAtlasEnabled: (on) => { + set({ atlasEnabled: on }) + savePrefs(collectPrefs(get())) + if (!on) closeAtlasTabsEverywhere() + }, hideWorkflowPreset: (id) => { set((s) => ({ hiddenWorkflowPresets: normalizeHiddenWorkflowPresets([...s.hiddenWorkflowPresets, id]) @@ -8211,6 +8254,7 @@ export const useStore = create((set, get) => { // canvas that can write to the vault may never come back past a switch that // turned it off. if (isWorkflowsTabPath(path) && !s.workflowsEnabled) return + if (isAtlasTabPath(path) && !s.atlasEnabled) return // Tasks / Tags / Help / Trash tabs are virtual — add them without touching disk. if (isWorkspaceVirtualTabPath(path)) { set((cur) => { @@ -9595,6 +9639,20 @@ function closeWorkflowsTabsEverywhere(): void { })) } +/** Drop the virtual Atlas tab from every pane, mirroring + * `closeWorkflowsTabsEverywhere`, whenever the feature is switched off. */ +function closeAtlasTabsEverywhere(): void { + const state = useStore.getState() + for (const leaf of allLeaves(state.paneLayout)) { + if (leaf.tabs.includes(ATLAS_TAB_PATH)) { + void state.closeTabInPane(leaf.id, ATLAS_TAB_PATH) + } + } + useStore.setState((s) => ({ + closedTabStack: s.closedTabStack.filter((entry) => !isAtlasTabPath(entry.path)) + })) +} + // --- Portable config file sync (desktop) ------------------------------------ /** Apply an externally-changed portable config (synced dotfile / hand-edit) @@ -9619,6 +9677,7 @@ function applyPortableConfig(next: AppConfigPortable): void { // setState bypasses the setters on purpose (no write-back to the file), so // the tab cleanup that setWorkflowsEnabled does has to be repeated here. if (!merged.workflowsEnabled) closeWorkflowsTabsEverywhere() + if (!merged.atlasEnabled) closeAtlasTabsEverywhere() } let configSyncInitialized = false diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 2acc8f7d..4e38d3d1 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -119,6 +119,7 @@ export const PORTABLE_PREF_KEYS = [ // features 'workflowsEnabled', 'hiddenWorkflowPresets', + 'atlasEnabled', // view 'systemFolderLabels', 'noteSortOrder', @@ -230,6 +231,7 @@ export const PORTABLE_DEFAULTS: Record = { monoFont: null, workflowsEnabled: false, hiddenWorkflowPresets: [], + atlasEnabled: true, systemFolderLabels: {}, noteSortOrder: 'none', assetSortOrder: 'name-asc', diff --git a/packages/shared-domain/src/atlas-view.ts b/packages/shared-domain/src/atlas-view.ts new file mode 100644 index 00000000..d946f6b4 --- /dev/null +++ b/packages/shared-domain/src/atlas-view.ts @@ -0,0 +1,9 @@ +// The pseudo-path that opens the Atlas map as a tab, matching the convention +// used by Tasks, Tags, Workflows, Archive, Trash and Help. Lives in +// shared-domain so the renderer, the command palette and the pane router all +// agree on one string. +export const ATLAS_TAB_PATH = 'zen://atlas' + +export function isAtlasTabPath(path: string | null | undefined): boolean { + return path === ATLAS_TAB_PATH +} diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index ca164a2c..7f9739ab 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -82,6 +82,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "vim.leaderPrefix", group: "vim", defaultBinding: "Space", title: "Leader key" }, { id: "vim.leaderOpenBuffers", group: "vim", defaultBinding: "o", title: "Leader: open buffers" }, { id: "vim.leaderWorkflows", group: "vim", defaultBinding: "a", title: "Leader: open workflows" }, + { id: "vim.leaderAtlas", group: "vim", defaultBinding: "g", title: "Leader: open atlas" }, { id: "vim.leaderSearchNotes", group: "vim", defaultBinding: "f", title: "Leader: search notes" }, { id: "vim.leaderSearchGroup", group: "vim", defaultBinding: "s", title: "Leader: search…" }, { id: "vim.leaderSearchVaultText", group: "vim", defaultBinding: "t", title: "Leader search: vault text" }, From ff525fed9238d50817ae420085fd88338b980020 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 13:30:14 -0500 Subject: [PATCH 06/15] Feat(notes): Cmd+N creates a note in the current folder (#614) The command palette has had New Note in Current Folder since #403, but it was never a bindable action, which is exactly where #614 landed: the keybinding settings had nothing to attach a key to. It is now a first-class shortcut, global.newNoteHere, default Cmd+N (previously unbound), rebindable under Settings, and shown next to the palette entry. One store action backs both the palette command and the shortcut so the two can never drift. Semantics are unchanged from #403: the active note's folder wins, the browsed folder is the fallback when no note is open, and Trash never receives a note. How to test locally: open any note inside a folder, press Cmd+N, and the new note appears in that folder with the title focused. With no note open, browse a folder and Cmd+N creates there. --- packages/app-core/src/App.tsx | 6 ++++++ packages/app-core/src/lib/commands.ts | 20 ++----------------- packages/app-core/src/lib/help.ts | 1 + packages/app-core/src/lib/keymaps.ts | 11 ++++++++++ packages/app-core/src/store.ts | 19 ++++++++++++++++++ packages/shared-domain/src/keymaps-catalog.ts | 1 + 6 files changed, 40 insertions(+), 18 deletions(-) diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index a1580154..e1dbbe79 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -705,6 +705,12 @@ function App(): JSX.Element { void state.createAndOpen('quick', '', { title, focusTitle: true }) return } + if (matchesShortcut(e, overrides, 'global.newNoteHere')) { + // ⌘N — new note in the current folder (#614) + e.preventDefault() + void state.createNoteInCurrentFolder() + return + } if (matchesShortcut(e, overrides, 'global.toggleWordWrap')) { // ⌥Z — toggle word wrap (matches VSCode/Sublime convention) e.preventDefault() diff --git a/packages/app-core/src/lib/commands.ts b/packages/app-core/src/lib/commands.ts index 78329547..3a38719f 100644 --- a/packages/app-core/src/lib/commands.ts +++ b/packages/app-core/src/lib/commands.ts @@ -260,6 +260,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma id: 'note.new.here', title: 'New Note in Current Folder', category: 'Note', + shortcut: shortcut('global.newNoteHere'), keywords: 'create add write', when: () => { const s = getState() @@ -267,24 +268,7 @@ export function buildCommands(options?: { includeUnavailable?: boolean }): Comma if (s.activeNote && s.activeNote.folder !== 'trash') return true return s.view.kind === 'folder' && s.view.folder !== 'trash' }, - run: () => { - const s = getState() - if (isTrashViewActive(s)) return - // "Current folder" is the folder of the active note (the one you're - // editing), not the sidebar's browse view. Those drift apart when notes - // from different folders are open, since switching tabs doesn't move the - // view, so reading the view created the note in the wrong directory. - // (#403) Fall back to the browsed folder only when no note is open. - const active = s.activeNote - if (active && active.folder !== 'trash') { - return s.createAndOpen(active.folder, noteFolderSubpath(active, s.vaultSettings), { - focusTitle: true - }) - } - const v = s.view - if (v.kind !== 'folder' || v.folder === 'trash') return - return s.createAndOpen(v.folder, v.subpath, { focusTitle: true }) - } + run: () => getState().createNoteInCurrentFolder() }, { id: 'note.save', diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index ba383458..4cc4887d 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -466,6 +466,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Mod+F', action: 'Search notes (non-Vim mode)', detail: 'Open the note search palette directly when Vim mode is off.' }, { keys: 'Mod+F (in the editor)', action: 'Find and replace in the note', detail: 'In Edit and Split, open the editor’s find-and-replace bar: Tab moves between the Find and Replace fields, with match-case, whole-word, and regex toggles. Esc closes it.' }, { keys: 'Shift+Mod+P', action: 'Open commands', detail: 'Open the command palette.' }, + { keys: 'Mod+N', action: 'New note in current folder', detail: 'Create a note in the active note\u2019s folder (or the browsed folder when no note is open) and focus its title. Rebindable under Settings \u2192 Keymaps.' }, { keys: 'Shift+Mod+N', action: 'New Quick Note', detail: 'Create a quick capture note in the main window and focus its title.' }, { keys: 'Shift+Mod+Space', action: 'Open quick capture window', detail: 'Open the floating, always-on-top capture window. Bound system-wide (CommandOrControl+Shift+Space by default) so it works over any app; change it under Settings → Editor.' }, { keys: 'Mod+,', action: 'Open Settings', detail: 'Open settings for appearance, editor behavior, fonts, vault controls, and app details.' }, diff --git a/packages/app-core/src/lib/keymaps.ts b/packages/app-core/src/lib/keymaps.ts index 20660657..b5cbbeb1 100644 --- a/packages/app-core/src/lib/keymaps.ts +++ b/packages/app-core/src/lib/keymaps.ts @@ -13,6 +13,7 @@ export type KeymapId = | "global.searchNotesNonVim" | "global.commandPalette" | "global.newQuickNote" + | "global.newNoteHere" | "global.openSettings" | "global.openFile" | "global.toggleSidebar" @@ -177,6 +178,16 @@ const KEYMAP_DEFINITIONS: KeymapDefinition[] = [ description: "Create a quick capture note and focus its title.", defaultBinding: "Shift+Mod+N", }, + { + id: "global.newNoteHere", + kind: "shortcut", + scope: "app", + group: "global", + title: "New note in current folder", + description: + "Create a note in the active note's folder (or the browsed folder when no note is open) and focus its title.", + defaultBinding: "Mod+N", + }, { id: "global.openSettings", kind: "shortcut", diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 1094a1f8..587e640b 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3150,6 +3150,7 @@ interface Store { * — unlike the right-click menus — carry no implied location. */ createNoteInChosenFolder: (opts?: { initialPath?: string }) => Promise + createNoteInCurrentFolder: () => Promise /** * Web counterpart of the desktop drag-to-open feature: for each * drag-and-dropped markdown File, read its contents, create a note from @@ -6612,6 +6613,24 @@ export const useStore = create((set, get) => { } }, + createNoteInCurrentFolder: async () => { + const s = get() + if (isTrashViewActive(s)) return + // "Current folder" is the folder of the active note (the one you're + // editing), not the sidebar's browse view; those drift apart when notes + // from different folders are open. (#403) Fall back to the browsed folder + // only when no note is open. (#614) + const active = s.activeNote + if (active && active.folder !== 'trash') { + await get().createAndOpen(active.folder, noteFolderSubpath(active, s.vaultSettings), { + focusTitle: true + }) + return + } + const v = s.view + if (v.kind !== 'folder' || v.folder === 'trash') return + await get().createAndOpen(v.folder, v.subpath, { focusTitle: true }) + }, createNoteInChosenFolder: async (opts) => { const state = get() const entered = await promptApp( diff --git a/packages/shared-domain/src/keymaps-catalog.ts b/packages/shared-domain/src/keymaps-catalog.ts index 7f9739ab..fbfb49b6 100644 --- a/packages/shared-domain/src/keymaps-catalog.ts +++ b/packages/shared-domain/src/keymaps-catalog.ts @@ -45,6 +45,7 @@ export const KEYMAP_CATALOG: KeymapCatalogEntry[] = [ { id: "global.searchNotesNonVim", group: "global", defaultBinding: "Mod+F", title: "Search notes in non-Vim mode" }, { id: "global.commandPalette", group: "global", defaultBinding: "Shift+Mod+P", title: "Open command palette" }, { id: "global.newQuickNote", group: "global", defaultBinding: "Shift+Mod+N", title: "New quick note" }, + { id: "global.newNoteHere", group: "global", defaultBinding: "Mod+N", title: "New note in current folder" }, { id: "global.openSettings", group: "global", defaultBinding: "Mod+,", title: "Open settings" }, { id: "global.openFile", group: "global", defaultBinding: "Mod+O", title: "Open file" }, { id: "global.toggleSidebar", group: "global", defaultBinding: "Mod+1", title: "Toggle sidebar" }, From fe1ee88426769545761e194188e85424225ab875 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 13:36:43 -0500 Subject: [PATCH 07/15] Fix(editor): markdown links with spaced targets render like links (#617) [text](My Note.md) navigated fine but rendered half-collapsed: the label lost its brackets while the raw (My Note.md) trailed beside it. CommonMark refuses an unescaped space in a link destination, so the parser ends the Link node at the closing bracket and the target hangs off the paragraph as plain text; the live preview hid the link syntax it could see and left the rest. Meanwhile the click and gd path reads targets with its own space-tolerant scan, which is why the link worked while looking broken. Rendering now matches navigation: a complete Link immediately followed by a balanced (target) the parser rejected is treated as one link. The tail hides with the brackets, and the cursor anywhere in the full span reveals the whole thing as source, exactly like an ordinary markdown link. The unterminated-target guard from #471 is untouched: while the closing paren has not been typed yet, everything stays visible. How to test locally: create My Note.md, then in another note write [text](My Note.md) beside [text](Note.md). Both render as just the label; cursor into either reveals the source; gd or click opens the right note. --- packages/app-core/src/lib/cm-live-preview.ts | 44 +++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/lib/cm-live-preview.ts b/packages/app-core/src/lib/cm-live-preview.ts index e15032af..6e74dba8 100644 --- a/packages/app-core/src/lib/cm-live-preview.ts +++ b/packages/app-core/src/lib/cm-live-preview.ts @@ -157,6 +157,34 @@ function enclosingLinkRange(ref: SyntaxNodeRefLike): { from: number; to: number * CommonMark's balanced-paren destinations, so a URL like * `https://en.wikipedia.org/wiki/Foo_(bar` still counts as unterminated. */ +/** + * The end offset of a balanced `(target)` sitting immediately after a `Link` + * node, or null when there is none. This is the parser-rejected-destination + * case (#617): CommonMark refuses an unescaped space in `[text](My Note.md)`, + * so the `Link` node ends at `]` and the target trails as plain text. The + * click/gd path (`markdownLinkAt`) accepts those targets, so rendering must + * treat the whole span as one link too. + */ +function terminatedLinkTailEnd(state: EditorView['state'], linkTo: number): number | null { + if (state.doc.sliceString(linkTo, linkTo + 1) !== '(') return null + const line = state.doc.lineAt(linkTo) + const rest = state.doc.sliceString(linkTo, line.to) + let depth = 0 + for (let i = 0; i < rest.length; i++) { + const ch = rest[i] + if (ch === '\\') { + i += 1 + continue + } + if (ch === '(') depth += 1 + else if (ch === ')') { + depth -= 1 + if (depth === 0) return i > 1 ? linkTo + i + 1 : null + } + } + return null +} + function hasUnterminatedLinkTarget(state: EditorView['state'], linkTo: number): boolean { if (state.doc.sliceString(linkTo, linkTo + 1) !== '(') return false const line = state.doc.lineAt(linkTo) @@ -1364,11 +1392,25 @@ function computeDecorations(view: EditorView): DecorationSet { if (replacedLines.has(line)) return if (isLinkSyntax) { const linkRange = enclosingLinkRange(node) - if (linkRange && selectionTouchesRange(state, linkRange.from, linkRange.to)) return + // A spaced destination (`[text](My Note.md)`) is rejected by the + // parser, so the `(target)` trails outside the Link node as plain + // text. Treat the full `[label](target)` as the link: reveal it as + // one unit and hide the trailing target with the brackets. (#617) + const tailEnd = linkRange ? terminatedLinkTailEnd(state, linkRange.to) : null + if (linkRange && selectionTouchesRange(state, linkRange.from, tailEnd ?? linkRange.to)) + return // `[label](` with no closing `)` yet isn't a link, so keep its // brackets visible: the label reads as source while the target is // typed or pasted, and collapses once the syntax is complete. (#471) if (linkRange && hasUnterminatedLinkTarget(state, linkRange.to)) return + if ( + linkRange && + tailEnd !== null && + node.to === linkRange.to && + state.doc.sliceString(node.to - 1, node.to) === ']' + ) { + pending.push({ from: linkRange.to, to: tailEnd, deco: hide }) + } } else if (activeLines.has(line)) { // Reveal every marker on the active line, headings included: the // cursor anywhere in a heading shows its `##` prefix, matching the From be0437fd5bcfa7af28b6a587766647d1c535e99d Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 13:58:45 -0500 Subject: [PATCH 08/15] Fix(editor): Live Preview off now means plain markdown (#616) Two behaviors ignored the Live Preview toggle. Frontmatter always rendered as the compact properties card, so someone who wants raw markdown had to break the fence with a deleted dash just to see their own text. And a plain click inside [label](url) followed the link even though nothing was rendered, so clicking into the URL to edit it opened the browser instead. Both were rendering features applied outside the rendering gate. The properties card (frontmatterStyle) now loads with the Live Preview bundle; the frontmatter tag completion stays always-on, since it assists editing rather than presentation. The plain-click follow from #201 existed to make RENDERED links clickable, so it now checks the setting too; with Live Preview off a click places the cursor, and Cmd/Ctrl+click still follows, matching editor convention. With Live Preview on, nothing changes: card, collapsed links, and click-to-follow all behave exactly as before. How to test locally: turn Live Preview off in Settings, open a note with frontmatter and a markdown link. The frontmatter reads as raw --- lines, the link shows its full [label](url) source, and clicking inside the url edits it. Turn Live Preview back on and both render again. --- packages/app-core/src/components/EditorPane.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app-core/src/components/EditorPane.tsx b/packages/app-core/src/components/EditorPane.tsx index eb7ea27d..6dd69cbd 100644 --- a/packages/app-core/src/components/EditorPane.tsx +++ b/packages/app-core/src/components/EditorPane.tsx @@ -419,7 +419,6 @@ function markdownEditingExtensions(showHeadingLevelLabels = false): Extension[] customCodeFenceHighlightExtension, vimAwareMarkdownKeymap, markdownListIndentPlugin, - frontmatterStyle, frontmatterTagExtension, orderedListRenumber, forwardOnCheckboxArrow, @@ -454,6 +453,9 @@ function wysiwygExtensions( ): Extension[] { return [ livePreviewPlugin, + // Frontmatter renders as compact properties only while Live Preview is + // on; with it off the block reads as plain --- markdown. (#616) + frontmatterStyle, codeBlockFlairPlugin, // Table widgets are gated on a setting — off keeps tables as plain editable // markdown for full keyboard/Vim editing (#232). @@ -1843,6 +1845,7 @@ export function EditorPane({ pane }: { pane: PaneLeaf }): JSX.Element { const link = markdownLinkAt(doc, pos) if ( link && + useStore.getState().livePreview && pointerOverRange(view, link.from, link.to, event.clientX, event.clientY) ) { const sel = view.state.selection.main From b2313de820384cdb801e6185ce93dd571f619aa5 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 14:30:35 -0500 Subject: [PATCH 09/15] Fix(desktop): a disabled quick-capture hotkey stays unregistered (#615) Disabling the quick capture hotkey persists an empty string, the settings screen faithfully shows Disabled after a restart, and yet startup went on to register the default shortcut anyway: the registration site read cfg.quickCaptureHotkey || DEFAULT, and an empty string is falsey. On Wayland with Electron's GlobalShortcutsPortal that re-registration invokes the desktop portal, so every launch popped GNOME's Add Keyboard Shortcuts dialog over the app. On other platforms it silently bound a shortcut the user had turned off. loadConfig always returns a normalized string here, and empty is the user's explicit choice, so startup now passes it through unchanged; registerQuickCaptureHotkey('') is already a clean no-op. Fresh installs still get the default from DEFAULT_CONFIG, and only corrupt non-string values fall back during normalization. Diagnosis, including the exact offending expression, came from the reporter. How to test locally: Settings, disable the quick capture hotkey, quit, relaunch: the shortcut stays unbound (and on GNOME Wayland no shortcuts dialog appears at launch). --- apps/desktop/src/main/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8982ff15..cb47dcdc 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5051,7 +5051,12 @@ app.whenReady().then(async () => { try { const cfg = await loadConfig(); - const desired = cfg.quickCaptureHotkey || DEFAULT_QUICK_CAPTURE_HOTKEY; + // loadConfig always yields a normalized string here, and empty string is + // the user's explicit "disabled" choice — registerQuickCaptureHotkey("") + // is a clean no-op. Falling back to the default on falsey re-registered + // the shortcut on every launch, which on Wayland invoked the + // global-shortcuts portal and popped GNOME's shortcut dialog. (#615) + const desired = cfg.quickCaptureHotkey; const result = registerQuickCaptureHotkey(desired); if (!result.ok) console.warn(result.error ?? `Failed to bind ${desired}`); } catch (err) { From ca77f50b9c05f4a0e4b6b3dc94c54681da613615 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 16:00:53 -0500 Subject: [PATCH 10/15] Feat(workflows): remote vaults in the desktop app get full workflows (#618) The missing half of #608. That change gave the Go server a journalled workflow API with capability negotiation and taught the web client to use it; the desktop app in a remote workspace stayed hard-locked read-only, with a comment promising delegation later. This is the delegation. RemoteServerClient gains the seven workflow calls, mirroring the web bridge exactly, including the split that matters: apply prepares the run on the client side (reading files through the server) and posts the prepared payload for the server's transactional apply, so conflict checks, rollback, and interrupted-run recovery all happen where the files live. The six workflow IPC handlers now route remote workspaces through that client. The renderer gate asks the connected server for its capabilities instead of trusting the static desktop ones, so servers without the API keep the read-only view and the update-your-server error, exactly the standard #608 set. Verified end to end: a desktop build connected to a live Go server through the real connect dialog shows the writable Workflows view, lists a workflow stored on the server, opens it on the canvas, and dry-runs it. Typecheck, all JS suites (two new gating cases), go vet, and the server's httpserver tests pass. How to test locally: run the server with an auth token, connect the desktop app to it, open Workflows: create, edit, run, and undo all work, and the run journal lives on the server. --- apps/desktop/src/main/index.ts | 40 ++++++++--- apps/desktop/src/main/remote/server-client.ts | 70 +++++++++++++++++++ .../app-core/src/components/WorkflowsView.tsx | 31 ++++++-- .../src/lib/workflow-workspace.test.ts | 8 ++- .../app-core/src/lib/workflow-workspace.ts | 8 ++- 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cb47dcdc..2e34fcc3 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -2990,17 +2990,31 @@ function registerIpc(): void { // Workflows are authored as files in the vault, so remote workspaces (which // have no local `.zennotes/workflows`) simply have none. + // Remote workspaces delegate every workflow call to the server's journalled + // workflow API from #608 when it is advertised; older servers stay + // read-only, matching the web client. (#618) + const requireRemoteWorkflows = async () => { + const client = requireRemoteWorkspaceClient(); + if (!(await client.supportsWorkflows())) { + throw new Error( + "This ZenNotes server does not support workflows yet. Update the server and reconnect.", + ); + } + return client; + }; + handle(IPC.VAULT_LIST_WORKFLOWS, async () => { - if (isRemoteWorkspaceActive()) return []; + if (isRemoteWorkspaceActive()) { + const client = requireRemoteWorkspaceClient(); + return (await client.supportsWorkflows()) ? await client.listWorkflows() : []; + } const v = requireVault(); return await listWorkflowFiles(v.root); }); - // Authoring needs the local filesystem, so remote workspaces reject rather - // than resolve: a silent success would leave the editor believing it saved. handle(IPC.VAULT_WRITE_WORKFLOW, async (_e, input: WriteWorkflowInput) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).writeWorkflow(input); } const v = requireVault(); return await writeWorkflowFile(v.root, input); @@ -3008,7 +3022,7 @@ function registerIpc(): void { handle(IPC.VAULT_DELETE_WORKFLOW, async (_e, sourcePath: string) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).deleteWorkflow(sourcePath); } const v = requireVault(); return await deleteWorkflowFile(v.root, sourcePath); @@ -3080,7 +3094,7 @@ function registerIpc(): void { // the dry run and asked for it here. handle(IPC.VAULT_APPLY_WORKFLOW, async (_e, input: ApplyWorkflowInput) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).applyWorkflow(input); } const v = requireVault(); return await applyWorkflowOps(v.root, input); @@ -3091,7 +3105,7 @@ function registerIpc(): void { // is unknown or already undone. handle(IPC.VAULT_UNDO_WORKFLOW_RUN, async (_e, runId: string) => { if (isRemoteWorkspaceActive()) { - throw new Error("Workflows are unavailable on remote vaults"); + return await (await requireRemoteWorkflows()).undoWorkflowRun(runId); } const v = requireVault(); return await undoWorkflowRun(v.root, runId); @@ -3100,13 +3114,21 @@ function registerIpc(): void { // Run history is read from files in the vault, so a remote workspace simply // has none, matching how it reports workflows themselves. handle(IPC.VAULT_LIST_WORKFLOW_RUNS, async () => { - if (isRemoteWorkspaceActive()) return []; + if (isRemoteWorkspaceActive()) { + const client = requireRemoteWorkspaceClient(); + return (await client.supportsWorkflows()) ? await client.listWorkflowRuns() : []; + } const v = requireVault(); return await listWorkflowRuns(v.root); }); handle(IPC.VAULT_DELETE_WORKFLOW_RUNS, async (_e, workflowId: string) => { - if (isRemoteWorkspaceActive()) return 0; + if (isRemoteWorkspaceActive()) { + if (typeof workflowId !== "string" || !workflowId) { + throw new Error("deleteWorkflowRuns needs a workflow id"); + } + return await (await requireRemoteWorkflows()).deleteWorkflowRuns(workflowId); + } if (typeof workflowId !== "string" || !workflowId) { throw new Error("deleteWorkflowRuns needs a workflow id"); } diff --git a/apps/desktop/src/main/remote/server-client.ts b/apps/desktop/src/main/remote/server-client.ts index e1766ebe..9fd43d12 100644 --- a/apps/desktop/src/main/remote/server-client.ts +++ b/apps/desktop/src/main/remote/server-client.ts @@ -39,6 +39,16 @@ export { connectionErrorMessage } /** The server never answered: DNS/refused/timeout. The workspace may be * fine; the network is not. Callers must never read this as "absent". */ +import type { + ApplyWorkflowInput, + WorkflowFile, + WorkflowRunReceipt, + WorkflowRunSummary, + WorkflowUndoResult, + WriteWorkflowInput +} from '@zennotes/bridge-contract/workflows' +import { prepareWorkflowRun } from '@shared/workflows/prepare-run' + export class RemoteConnectionError extends Error {} /** The server answered with a non-2xx status: it is alive and made a @@ -131,6 +141,66 @@ export class RemoteServerClient { return this.jsonRequest(`/api/search/text?${params.toString()}`) } + /** True when the connected server advertises the journalled workflow API + * from #608. Older servers stay read-only, exactly like the web client. */ + async supportsWorkflows(): Promise { + const caps = await this.getCapabilities() + return (caps as { supportsWorkflows?: boolean } | null)?.supportsWorkflows === true + } + + async listWorkflows(): Promise { + return this.jsonRequest('/api/workflows') + } + + async writeWorkflow(input: WriteWorkflowInput): Promise { + return this.jsonRequest('/api/workflows/write', { + method: 'POST', + body: input as unknown as Record + }) + } + + async deleteWorkflow(sourcePath: string): Promise { + await this.jsonRequest('/api/workflows/delete', { method: 'POST', body: { sourcePath } }) + } + + /** Prepare on this side (reads through the server), apply transactionally on + * the server — the same split the web bridge ships for #608. */ + async applyWorkflow(input: ApplyWorkflowInput): Promise { + const settings = await this.getVaultSettings() + const prepared = await prepareWorkflowRun(input, { + read: async (path: string) => { + try { + return (await this.readNote(path)).body + } catch { + return null + } + }, + systemFolderDirs: settings.systemFolderPaths ?? {} + }) + return this.jsonRequest('/api/workflows/apply', { + method: 'POST', + body: prepared as unknown as Record + }) + } + + async undoWorkflowRun(runId: string): Promise { + return this.jsonRequest('/api/workflows/undo', { + method: 'POST', + body: { runId } + }) + } + + async listWorkflowRuns(): Promise { + return this.jsonRequest('/api/workflows/runs') + } + + async deleteWorkflowRuns(workflowId: string): Promise { + return this.jsonRequest('/api/workflows/runs/delete', { + method: 'POST', + body: { workflowId } + }) + } + async readNote(relPath: string): Promise { return this.jsonRequest(`/api/notes/read?path=${encodeURIComponent(relPath)}`) } diff --git a/packages/app-core/src/components/WorkflowsView.tsx b/packages/app-core/src/components/WorkflowsView.tsx index 396d7999..671f7617 100644 --- a/packages/app-core/src/components/WorkflowsView.tsx +++ b/packages/app-core/src/components/WorkflowsView.tsx @@ -1364,12 +1364,35 @@ export function WorkflowsView(): JSX.Element { // the drag that was made, and must record it against the file it was made on. const pendingLayoutWrite = useRef(null) - // Desktop owns local workflow files directly. The Docker web client owns - // them through a server that explicitly advertises journalled workflow - // support. Older servers and Electron remote workspaces stay read-only. + // Desktop owns local workflow files directly. Remote workspaces (web or + // Electron) own them through a server that explicitly advertises journalled + // workflow support; older servers stay read-only. For a desktop remote + // workspace the static preload capabilities describe the app, not the + // server, so ask the server itself. (#618) const appInfo = window.zen.getAppInfo() const capabilities = window.zen.getCapabilities() - const writableWorkspace = canManageWorkflows(appInfo.runtime, workspaceMode, capabilities) + const [remoteWorkflowsSupported, setRemoteWorkflowsSupported] = useState(null) + useEffect(() => { + if (workspaceMode !== 'remote' || appInfo.runtime !== 'desktop') return + let cancelled = false + window.zen + .getServerCapabilities() + .then((caps) => { + if (!cancelled) setRemoteWorkflowsSupported(caps?.supportsWorkflows === true) + }) + .catch(() => { + if (!cancelled) setRemoteWorkflowsSupported(false) + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceMode]) + const effectiveCapabilities = + appInfo.runtime === 'desktop' && workspaceMode === 'remote' + ? { ...capabilities, supportsWorkflows: remoteWorkflowsSupported === true } + : capabilities + const writableWorkspace = canManageWorkflows(appInfo.runtime, workspaceMode, effectiveCapabilities) const nativeLocalVault = appInfo.runtime === 'desktop' && workspaceMode !== 'remote' const canWrite = writableWorkspace && typeof window.zen.writeWorkflow === 'function' const canDelete = writableWorkspace && typeof window.zen.deleteWorkflow === 'function' diff --git a/packages/app-core/src/lib/workflow-workspace.test.ts b/packages/app-core/src/lib/workflow-workspace.test.ts index d20394a5..a11dd499 100644 --- a/packages/app-core/src/lib/workflow-workspace.test.ts +++ b/packages/app-core/src/lib/workflow-workspace.test.ts @@ -6,9 +6,13 @@ describe('canManageWorkflows', () => { expect(canManageWorkflows('web', 'local', { supportsWorkflows: true })).toBe(true) }) - it('keeps older web servers and remote desktop workspaces read-only', () => { + it('keeps older servers read-only in web and remote desktop workspaces', () => { expect(canManageWorkflows('web', 'local', {})).toBe(false) - expect(canManageWorkflows('desktop', 'remote', { supportsWorkflows: true })).toBe(false) + expect(canManageWorkflows('desktop', 'remote', {})).toBe(false) + }) + + it('enables remote desktop workspaces when the server supports workflows', () => { + expect(canManageWorkflows('desktop', 'remote', { supportsWorkflows: true })).toBe(true) }) it('retains local desktop workflow support', () => { diff --git a/packages/app-core/src/lib/workflow-workspace.ts b/packages/app-core/src/lib/workflow-workspace.ts index 248ce7b8..84ecceff 100644 --- a/packages/app-core/src/lib/workflow-workspace.ts +++ b/packages/app-core/src/lib/workflow-workspace.ts @@ -1,11 +1,13 @@ /** Whether this renderer is paired with a host that can keep workflow files - * and run journals in the vault. Remote desktop workspaces remain read-only - * until their Electron bridge delegates these calls to the remote server. */ + * and run journals in the vault. Remote workspaces (desktop or web) follow + * the connected server's advertised capability: the Electron bridge delegates + * workflow calls to the server's journalled API (#618), and older servers + * without it stay read-only. */ export function canManageWorkflows( runtime: 'desktop' | 'web', workspaceMode: 'local' | 'remote', capabilities: { supportsWorkflows?: boolean } ): boolean { - if (workspaceMode === 'remote') return false + if (workspaceMode === 'remote') return capabilities.supportsWorkflows === true return runtime === 'desktop' || capabilities.supportsWorkflows === true } From e1390eb827072de5044bf5fb7d07e14ade4a1136 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 16:51:15 -0500 Subject: [PATCH 11/15] Feat(vim): counts for [b and ]b tab navigation (#622) 3]b now walks three tabs forward and 2[b two back, Neovim-style, with the no-count behaviour untouched. The asymmetry being fixed: [b already honoured counts because it shares gT's relative action, but ]b shared gt's, whose {count} is vim's ABSOLUTE tab number, so 3]b jumped to tab 3 instead of moving three forward. ]b now has its own relative action; {count}gt keeps its absolute jump, and both wrap around the open-tab ring the way the existing modulo navigation always has. The global fallback layer (bracket sequences pressed outside a focused editor, #321) had no counts at all, so it gains a vim-style digit prefix: digits are recorded without being consumed, spent by the next [b or ]b, expire on their own, and a leading 0 never starts a count, matching vim. How to test locally: open a few tabs, focus a note, press 3]b and 2[b and watch the tab strip; then click into the sidebar and do the same from outside the editor. Without a count both keys still move exactly one tab. --- packages/app-core/src/components/Editor.tsx | 17 ++++++++++- packages/app-core/src/components/VimNav.tsx | 32 +++++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/app-core/src/components/Editor.tsx b/packages/app-core/src/components/Editor.tsx index c66f7029..652d0268 100644 --- a/packages/app-core/src/components/Editor.tsx +++ b/packages/app-core/src/components/Editor.tsx @@ -200,7 +200,7 @@ function syncVimKeymaps(overrides: KeymapOverrides): void { { id: "vim.bufferNext", contexts: ["normal", "visual"], - action: "nextBuffer", + action: "nextBufferRelative", bindings: [ toVimSequence(getKeymapBinding(overrides, "vim.bufferNext")), ].filter((binding): binding is string => !!binding), @@ -732,6 +732,21 @@ function registerVimCommands(): void { navigateActiveBuffer(useStore.getState(), 1); }, ); + // ]b is RELATIVE with a count ({count}]b walks forward count tabs), unlike + // gt whose {count} is vim's absolute tab number. [b shares previousBuffer + // with gT and was already relative. (#622) + Vim.defineAction( + "nextBufferRelative", + ( + _cm: unknown, + actionArgs?: { repeat?: number; repeatIsExplicit?: boolean }, + ) => { + const repeat = actionArgs?.repeatIsExplicit + ? (actionArgs.repeat ?? 1) + : 1; + navigateActiveBuffer(useStore.getState(), repeat); + }, + ); registerVimNoteCommands(); registerCommandPaletteEx(); diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 3891a2a3..608a1721 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -82,6 +82,7 @@ export function VimNav(): JSX.Element | null { // #321: `g`-prefix pending for gt/gT. Tracked separately (not via advanceSequence) // because `g` is shared with gg/gd, so it must NOT be consumed on the `g` press. const gTabPending = useRef(false) + const bufferCount = useRef<{ n: number; at: number } | null>(null) const leaderPending = useRef<'leader' | 'leader-l' | 'leader-s' | null>(null) const ctrlWTimer = useRef>() const jumpTopTimer = useRef>() @@ -155,7 +156,7 @@ export function VimNav(): JSX.Element | null { }) }) }, []) - const navigateBuffer = useCallback((delta: 1 | -1): void => { + const navigateBuffer = useCallback((delta: number): void => { const focusIfCurrentNoteTab = (paneId: string, path: string): void => { const latest = useStore.getState() const leaf = findLeaf(latest.paneLayout, paneId) @@ -573,13 +574,38 @@ export function VimNav(): JSX.Element | null { e.preventDefault() e.stopImmediatePropagation() } + // A vim-style count prefix for the sequences below ({count}[b walks + // back count tabs). Digits are recorded without being consumed, so + // anything else digits mean elsewhere still works; a completed + // sequence spends the count, and it expires quickly on its own. A + // leading 0 never starts a count, matching vim. (#622) + if ( + /^[0-9]$/.test(e.key) && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + (bufferCount.current !== null || e.key !== '0') + ) { + const now = Date.now() + const prev = + bufferCount.current && now - bufferCount.current.at < 1500 + ? bufferCount.current.n + : 0 + bufferCount.current = { n: Math.min(99, prev * 10 + Number(e.key)), at: now } + } + const takeBufferCount = (): number => { + const entry = bufferCount.current + bufferCount.current = null + if (!entry || Date.now() - entry.at > 1500) return 1 + return Math.max(1, entry.n) + } if ( advanceSequence( e, getKeymapBinding(overrides, 'vim.bufferPrevious'), previousBufferPending, previousBufferTimer, - () => navigateBuffer(-1), + () => navigateBuffer(-takeBufferCount()), consumeBufferKey ) ) { @@ -591,7 +617,7 @@ export function VimNav(): JSX.Element | null { getKeymapBinding(overrides, 'vim.bufferNext'), nextBufferPending, nextBufferTimer, - () => navigateBuffer(1), + () => navigateBuffer(takeBufferCount()), consumeBufferKey ) ) { From 81b3b38b90e545de88b117e73809418e52249f0b Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 17:06:04 -0500 Subject: [PATCH 12/15] Fix(sidebar): the Assets row's m shortcut opens a real menu (#621) Every vim-navigable sidebar row advertises m for its context menu, and Assets showed the hint too, but it was the one row with no menu wired: Archive and Trash pass their folder menus, Assets passed nothing, so the synthesized contextmenu event landed on a row with no handler and nothing happened. Rather than hide the hint, Assets gets a menu worth opening: Open Assets, and sort by name, type, size, last modified, or times used. Picking the active field flips its direction the way a list header does, the active field carries an up or down hint, and the choices drive the existing assetSortOrder portable preference, so they stay in sync with the Assets view and config.toml. Right-click on the row gets the same menu. How to test locally: focus the sidebar, move the cursor onto Assets, press m: the menu opens; pick Sort by size twice and the Assets view flips between smallest-first and largest-first. --- packages/app-core/src/components/Sidebar.tsx | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/app-core/src/components/Sidebar.tsx b/packages/app-core/src/components/Sidebar.tsx index a0418604..0c35c415 100644 --- a/packages/app-core/src/components/Sidebar.tsx +++ b/packages/app-core/src/components/Sidebar.tsx @@ -974,6 +974,9 @@ export function Sidebar(): JSX.Element { y: number; tag: string; } | null>(null); + // Assets row menu (#621): the row advertised the `m` hint like every other + // sidebar row but had no context menu wired at all. + const [assetsRowMenu, setAssetsRowMenu] = useState<{ x: number; y: number } | null>(null); const [folderMenu, setFolderMenu] = useState<{ x: number; y: number; @@ -2835,6 +2838,11 @@ export function Sidebar(): JSX.Element { [prepareContextSelection], ); + const openAssetsRowMenu = useCallback((e: React.MouseEvent): void => { + e.preventDefault(); + setAssetsRowMenu({ x: e.clientX, y: e.clientY }); + }, []); + const openAssetMenu = useCallback( (e: React.MouseEvent, asset: AssetMeta): void => { e.preventDefault(); @@ -3741,6 +3749,7 @@ export function Sidebar(): JSX.Element { count={assetCount} active={assetsViewActive} onClick={() => void openAssetsView()} + onContextMenu={openAssetsRowMenu} sidebarIdx={idxCounter.current.value++} vimHighlight={vimCursor === idxCounter.current.value - 1} sidebarFocused={isSidebarFocused} @@ -3828,6 +3837,43 @@ export function Sidebar(): JSX.Element { onClose={() => setFolderMenu(null)} /> )} + {assetsRowMenu && ( + { + const state = useStore.getState(); + const current = state.assetSortOrder; + const sortItem = ( + label: string, + field: "name" | "used" | "type" | "size" | "modified", + ): ContextMenuItem => { + const activeField = current.startsWith(field + "-"); + const asc = current === field + "-asc"; + return { + label: `Sort by ${label}`, + // Re-picking the active field flips its direction, like a + // list header; a fresh field starts ascending. + hint: activeField ? (asc ? "↑" : "↓") : undefined, + onSelect: () => + state.setAssetSortOrder( + activeField && asc ? `${field}-desc` : `${field}-asc`, + ), + }; + }; + return [ + { label: "Open Assets", onSelect: () => void openAssetsView() }, + { kind: "separator" as const }, + sortItem("name", "name"), + sortItem("type", "type"), + sortItem("size", "size"), + sortItem("last modified", "modified"), + sortItem("times used", "used"), + ]; + })()} + onClose={() => setAssetsRowMenu(null)} + /> + )} {rootMenu && ( Date: Tue, 18 Aug 2026 17:13:36 -0500 Subject: [PATCH 13/15] Feat(editor): a math size slider for both renderers (#623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Math had exactly one size: whatever the typesetter derived from the text around it. Settings gains Math size, a 50 to 200 percent slider under the Math renderer picker, scaling inline $…$ and block $$…$$ math in the editor and the reading view alike. One factor covers both engines: a single zoom rule on the KaTeX container and the Typst SVG output, driven by a --z-math-scale variable the app sets from the preference. zoom rather than transform, so the surrounding line layout grows with the formula instead of letting it overlap neighbours. The preference is portable: math_font_scale under [editor] in config.toml, clamped on the way in, searchable in Settings, documented in the manual. How to test locally: open a note with $E = mc^2$ and a $$…$$ block, drag Settings > Editor > Math size to 150 percent, and watch both grow in the editor and preview; config.toml picks up math_font_scale = 150. --- apps/desktop/src/main/app-config.ts | 5 +++++ packages/app-core/src/App.tsx | 4 +++- .../app-core/src/components/SettingsModal.tsx | 20 +++++++++++++++++++ packages/app-core/src/lib/help.ts | 1 + packages/app-core/src/store.ts | 15 ++++++++++++++ packages/app-core/src/styles/index.css | 9 +++++++++ packages/shared-domain/src/app-config.ts | 2 ++ 7 files changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/app-config.ts b/apps/desktop/src/main/app-config.ts index 04414a3e..7bc36ee0 100644 --- a/apps/desktop/src/main/app-config.ts +++ b/apps/desktop/src/main/app-config.ts @@ -144,6 +144,11 @@ const SCALAR_FIELDS: Partial> = { tomlKey: 'font_size', comment: 'editor + preview font size (px)' }, + mathFontScale: { + section: 'editor', + tomlKey: 'math_font_scale', + comment: 'math size as a percentage (50-200); scales $…$ and $$…$$ in editor and preview' + }, editorLineHeight: { section: 'editor', tomlKey: 'line_height', comment: 'line-height multiplier' }, editorTabSize: { section: 'editor', diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index e1dbbe79..2f41ba84 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -364,6 +364,7 @@ function App(): JSX.Element { const enabledOverrides = useStore((s) => s.enabledOverrides) const themeTweaks = useStore((s) => s.themeTweaks) const editorFontSize = useStore((s) => s.editorFontSize) + const mathFontScale = useStore((s) => s.mathFontScale) const editorLineHeight = useStore((s) => s.editorLineHeight) const previewMaxWidth = useStore((s) => s.previewMaxWidth) const editorMaxWidth = useStore((s) => s.editorMaxWidth) @@ -605,6 +606,7 @@ function App(): JSX.Element { useEffect(() => { const html = document.documentElement html.style.setProperty('--z-editor-font-size', `${editorFontSize}px`) + html.style.setProperty('--z-math-scale', String(mathFontScale / 100)) html.style.setProperty('--z-editor-line-height', String(editorLineHeight)) html.style.setProperty('--z-preview-max-width', `${previewMaxWidth}px`) html.style.setProperty('--z-editor-max-width', `${editorMaxWidth}px`) @@ -632,7 +634,7 @@ function App(): JSX.Element { monoFont, '"SF Mono", "SFMono-Regular", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace' ) - }, [editorFontSize, editorLineHeight, previewMaxWidth, editorMaxWidth, contentAlign, completedTaskStyle, mathRenderer, lineNumberPosition, interfaceFont, textFont, monoFont]) + }, [editorFontSize, mathFontScale, editorLineHeight, previewMaxWidth, editorMaxWidth, contentAlign, completedTaskStyle, mathRenderer, lineNumberPosition, interfaceFont, textFont, monoFont]) // Keep the markdown/preview pipeline pointed at the active math engine, even // on surfaces that render markdown without the Preview component mounted diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 4c8b9205..d414a2bb 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -472,6 +472,8 @@ export function SettingsModal(): JSX.Element { const showArchivedTasks = useStore((s) => s.showArchivedTasks); const setShowArchivedTasks = useStore((s) => s.setShowArchivedTasks); const mathRenderer = useStore((s) => s.mathRenderer); + const mathFontScale = useStore((s) => s.mathFontScale); + const setMathFontScale = useStore((s) => s.setMathFontScale); const typstTagPreambles = useStore((s) => s.typstTagPreambles); const setTypstTagPreambles = useStore((s) => s.setTypstTagPreambles); const setMathRenderer = useStore((s) => s.setMathRenderer); @@ -1861,6 +1863,13 @@ export function SettingsModal(): JSX.Element { "source", ], }, + { + id: "math-font-scale", + title: "Math size", + description: + "Scale inline and block math relative to the surrounding text.", + keywords: ["math", "size", "scale", "font", "katex", "typst", "latex", "formula", "equation"], + }, { id: "math-renderer", title: "Math renderer", @@ -2364,6 +2373,17 @@ export function SettingsModal(): JSX.Element { ]} onChange={(next) => setMathRenderer(next)} /> + {mathRenderer === "typst" && ( `, and with this on a rename carries that heading along — rename `Untitled` to `Groceries` and line one becomes `# Groceries`, from the breadcrumb, the sidebar, or the note list alike. Only an existing top-level `#` heading is rewritten and one is never invented, so a note that opens with prose, a list, or a `##` heading is untouched; deleting the `#` line opts that note out permanently. The heading is found after any frontmatter, and the rest of the note is left byte for byte as it was.' }, { label: 'Heading level labels', detail: 'Show H1 through H6 badges before headings. Heading fold arrows stay available whether labels are on or off.' }, diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index 587e640b..3e38fa69 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -551,6 +551,7 @@ interface Prefs { themeFamily: ThemeFamily themeMode: ThemeMode editorFontSize: number // px — affects editor + preview + mathFontScale: number // percent — inline + block math, both renderers (#623) editorLineHeight: number // unitless multiplier editorTabSize: number // columns used to render and indent a tab editorScrollOff: number // vim scrolloff — lines kept above/below the cursor (0 = off) @@ -987,6 +988,7 @@ export const DEFAULT_PREFS: Prefs = { enabledOverrides: {}, themeTweaks: {}, editorFontSize: 16, + mathFontScale: 100, editorLineHeight: 1.7, editorTabSize: 4, editorScrollOff: 0, @@ -1175,6 +1177,10 @@ function normalizePrefs(p: Partial): Prefs { typeof p.editorFontSize === 'number' ? p.editorFontSize : DEFAULT_PREFS.editorFontSize, + mathFontScale: + typeof p.mathFontScale === 'number' && Number.isFinite(p.mathFontScale) + ? Math.min(200, Math.max(50, Math.round(p.mathFontScale))) + : DEFAULT_PREFS.mathFontScale, editorLineHeight: typeof p.editorLineHeight === 'number' ? p.editorLineHeight @@ -2127,6 +2133,7 @@ function collectPrefs(s: { themeFamily: ThemeFamily themeMode: ThemeMode editorFontSize: number + mathFontScale: number editorLineHeight: number editorTabSize: number editorScrollOff: number @@ -2219,6 +2226,7 @@ function collectPrefs(s: { themeFamily: s.themeFamily, themeMode: s.themeMode, editorFontSize: s.editorFontSize, + mathFontScale: s.mathFontScale, editorLineHeight: s.editorLineHeight, editorTabSize: s.editorTabSize, editorScrollOff: s.editorScrollOff, @@ -2746,6 +2754,7 @@ interface Store { themeFamily: ThemeFamily themeMode: ThemeMode editorFontSize: number + mathFontScale: number editorLineHeight: number editorTabSize: number editorScrollOff: number @@ -3225,6 +3234,7 @@ interface Store { ) => void setTheme: (next: { id: string; family: ThemeFamily; mode: ThemeMode }) => void setEditorFontSize: (px: number) => void + setMathFontScale: (percent: number) => void setEditorLineHeight: (mult: number) => void setEditorTabSize: (size: number) => void setEditorScrollOff: (lines: number) => void @@ -4507,6 +4517,7 @@ export const useStore = create((set, get) => { themeFamily: loadPrefs().themeFamily, themeMode: loadPrefs().themeMode, editorFontSize: loadPrefs().editorFontSize, + mathFontScale: loadPrefs().mathFontScale, editorLineHeight: loadPrefs().editorLineHeight, editorTabSize: loadPrefs().editorTabSize, editorScrollOff: loadPrefs().editorScrollOff, @@ -7233,6 +7244,10 @@ export const useStore = create((set, get) => { set({ editorFontSize: px }) savePrefs(collectPrefs(get())) }, + setMathFontScale: (percent) => { + set({ mathFontScale: Math.min(200, Math.max(50, Math.round(percent))) }) + savePrefs(collectPrefs(get())) + }, setEditorLineHeight: (mult) => { set({ editorLineHeight: mult }) savePrefs(collectPrefs(get())) diff --git a/packages/app-core/src/styles/index.css b/packages/app-core/src/styles/index.css index 0f0e1b66..35f01981 100644 --- a/packages/app-core/src/styles/index.css +++ b/packages/app-core/src/styles/index.css @@ -4,6 +4,15 @@ looks the same in the editor and the preview on every theme. (#390-adjacent) */ @import "katex/dist/katex.min.css"; +/* Math size (#623): one user-set factor scales inline and block math in both + * the editor and the reading view, for KaTeX and Typst alike. zoom keeps the + * surrounding layout honest where transform: scale would overlap lines. */ +:is(.prose-zen, .cm-editor) .katex, +:is(.prose-zen, .cm-editor) .zen-typst-math, +:is(.prose-zen, .cm-editor) :is(.cm-math-inline, .cm-math-block) > svg { + zoom: var(--z-math-scale, 1); +} + @tailwind base; @tailwind components; @tailwind utilities; diff --git a/packages/shared-domain/src/app-config.ts b/packages/shared-domain/src/app-config.ts index 4e38d3d1..ba1a0aa7 100644 --- a/packages/shared-domain/src/app-config.ts +++ b/packages/shared-domain/src/app-config.ts @@ -89,6 +89,7 @@ export const PORTABLE_PREF_KEYS = [ 'tabsEnabled', 'wrapTabs', 'editorFontSize', + 'mathFontScale', 'editorLineHeight', 'editorTabSize', 'editorScrollOff', @@ -204,6 +205,7 @@ export const PORTABLE_DEFAULTS: Record = { tabsEnabled: true, wrapTabs: false, editorFontSize: 16, + mathFontScale: 100, editorLineHeight: 1.7, editorTabSize: 4, editorScrollOff: 0, From d5691c03a0b67feceb637e675999226ca690f6c2 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 18:17:39 -0500 Subject: [PATCH 14/15] Docs(help): catch the manual up to remote workflows and buffer counts Two features shipped this cycle without their in-app manual entries. The workflows chapter still claimed desktop remote workspaces are read-only, which #618 made untrue: they author and run workflows now, provided the server is on 2.29 or newer, and only against older servers does the old read-only behavior remain. And the [b / ]b rows never mentioned that #622 taught both motions to take a count, so 3]b jumps three buffers forward around the ring. The website docs said all of this already; this brings the second of the two mirrored surfaces back in line. Text only, no behavior change. --- packages/app-core/src/lib/help.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app-core/src/lib/help.ts b/packages/app-core/src/lib/help.ts index 0d6ad2e6..8df9fdd6 100644 --- a/packages/app-core/src/lib/help.ts +++ b/packages/app-core/src/lib/help.ts @@ -447,7 +447,7 @@ export const HELP_CORE_CONCEPTS: HelpCard[] = [ { title: 'Workflows plan first and write second', body: - 'A workflow is a plain `.md` file under `.zennotes/workflows/`: frontmatter plus one pipeline per line, like `good = books | where rating >= 4`. Wires carry sets of notes, so every wire on the canvas shows the live count flowing through it, and the canvas and the text are lossless projections of the same file (layout is computed, so no coordinates ever land in your vault). The engine can only propose changes: running shows the full dry-run diff before anything is applied, applying journals every file\'s pre-run bytes so Undo restores them exactly, and a run that fails midway rolls the whole thing back on its own. There are no code steps, no shell, and no network, which is why a workflow you did not write is safe to read and run. In this release workflows run when you run them: an event or schedule `trigger:` in the frontmatter parses but does not fire yet. Local desktop vaults and current self-hosted web servers can author and run workflows; desktop remote workspaces remain read-only. The feature is off by default; enable it under Settings → Workflows.' + 'A workflow is a plain `.md` file under `.zennotes/workflows/`: frontmatter plus one pipeline per line, like `good = books | where rating >= 4`. Wires carry sets of notes, so every wire on the canvas shows the live count flowing through it, and the canvas and the text are lossless projections of the same file (layout is computed, so no coordinates ever land in your vault). The engine can only propose changes: running shows the full dry-run diff before anything is applied, applying journals every file\'s pre-run bytes so Undo restores them exactly, and a run that fails midway rolls the whole thing back on its own. There are no code steps, no shell, and no network, which is why a workflow you did not write is safe to read and run. In this release workflows run when you run them: an event or schedule `trigger:` in the frontmatter parses but does not fire yet. Local desktop vaults, self-hosted web servers, and desktop remote workspaces can all author and run workflows; a remote workspace needs a server on 2.29 or newer, and against an older server workflows stay read-only. The feature is off by default; enable it under Settings → Workflows.' }, { title: 'The workflow grammar in one card', @@ -512,7 +512,7 @@ export const HELP_SHORTCUT_SECTIONS: HelpShortcutSection[] = [ { keys: 'Ctrl-w h / j / k / l', action: 'Move focus', detail: 'Move between sidebar, note list, the active pane’s tab strip, editor, connections, or adjacent editor panes. From tabs, use h / l to switch tabs and j to return to the editor.' }, { keys: 'Ctrl-w v', action: 'Split right', detail: 'Clone the current tab into a pane to the right.' }, { keys: 'Ctrl-w s', action: 'Split down', detail: 'Clone the current tab into a pane below.' }, - { keys: '[b / ]b', action: 'Previous / next buffer', detail: 'Move across open buffers, falling back to recent notes when only one buffer is open.' }, + { keys: '[b / ]b', action: 'Previous / next buffer', detail: 'Move across open buffers, falling back to recent notes when only one buffer is open. Both take a count: `3]b` jumps three buffers forward, wrapping around the ring.' }, { keys: 'Space o', action: 'Open buffers', detail: 'Show a searchable list of every open buffer across every pane.' }, { keys: 'Space f', action: 'Search notes', detail: 'Open the vault-wide note search palette.' }, { keys: 'Space s t', action: 'Search vault text', detail: 'Fuzzy-search matching text lines across notes in Inbox, Quick Notes, and Archive.' }, @@ -784,7 +784,7 @@ export const HELP_VIM_COMMANDS: HelpExCommand[] = [ { command: ':bn / :bp', summary: 'Cycle tabs', - detail: 'Move to the next or previous tab, or the next most-recent note when only one tab is open. The default normal-mode keymaps are `]b` and `[b`, and both can be remapped in Settings.' + detail: 'Move to the next or previous tab, or the next most-recent note when only one tab is open. The default normal-mode keymaps are `]b` and `[b`, both remappable in Settings, and both accept a count: `3]b` jumps three tabs forward, wrapping around the ring.' }, { command: ':buffers / :ls', From d2f6e25ec301d560d727d7073fd21085c48f0a25 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 18 Aug 2026 18:17:43 -0500 Subject: [PATCH 15/15] chore(release): 2.30.0 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d112b639..c12b4266 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.29.0", + "version": "2.30.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/server/package.json b/apps/server/package.json index 0189674a..5bfb15e7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.29.0", + "version": "2.30.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index c0595dbc..a316d8a6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index e11e4686..4a8e0baf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.29.0", + "version": "2.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.29.0", + "version": "2.30.0", "workspaces": [ "apps/*", "packages/*" @@ -20,7 +20,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.29.0", + "version": "2.30.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -99,11 +99,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.29.0" + "version": "2.30.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.29.0", + "version": "2.30.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17123,7 +17123,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.29.0", + "version": "2.30.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -17187,11 +17187,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.29.0" + "version": "2.30.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.29.0", + "version": "2.30.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -17199,7 +17199,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.29.0" + "version": "2.30.0" } } } diff --git a/package.json b/package.json index e96bc4c1..1a50bf28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.29.0", + "version": "2.30.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index 0fc387eb..724d85c2 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 1a40b5d2..250d2821 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 2c10d4ae..03c8b6d1 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 71922bf0..7a5a85ef 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "exports": { ".": "./src/index.ts"