Skip to content

Release 2.79.0: database views and a folder Gallery (charles b.), new-note typing and theme pick on phones - #1017

Merged
adibhanna merged 31 commits into
mainfrom
v2.79.0
Oct 29, 2026
Merged

adibhanna merged 31 commits into
mainfrom
v2.79.0

Conversation

@adibhanna

Copy link
Copy Markdown
Contributor

Two features asked for by charles b. on release morning, and two phone-walk fixes, joining 2.79.0 before the tag.

Database table views (merge efbf7e3, 8 commits)

  • Columns: a Columns button ("Columns 5 of 44") with find, Show all and Hide all. Each view keeps its own columns, and the last visible one stays. There's also Hide column in the column menu, and c in the grid with Vim on.
  • More views: the + makes a New table view, a New board view, or Duplicate this view. Duplicate view is in the tab menu too.
  • Picked rows:
    • Select rows, then Show only these in this view. The view saves them as DbView.pickedRowIds.
    • A "Showing N picked rows" strip offers Edit picks and Show all rows.
    • New rows join the picks, and the Board honors picks too.
  • Commands: five palette commands, all bindable.
  • Vim off: the grid's letters now act only with Vim on. With Vim off, the arrows, Enter, Space, Escape and Shift+F10 work.

Folder Gallery (merge ee5d1dc, 4 commits)

  • What it shows: a folder's notes as cards by cover: an image, Unsplash photo or gradient, or the title's letter. Sort (updated, created, title), an Include subfolders switch, and a title filter.
  • Opening it: Open as Gallery in the folder menu, Open Folder Gallery in the palette, :gallery [folder], or Space G.
  • Keys: with Vim on, h/j/k/l, gg/G, Enter or o, and /. With Vim off, the arrows, Enter and Escape.
  • Covers: read only for visible cards (60 ms settle, 4 at a time) and cached by path and updatedAt; NoteMeta is unchanged. A 1000-note folder took 11 reads to fill the screen.
  • Prefs: gallery_sort and gallery_include_subfolders under [view] in config.toml.

Fixes from the phone walks

  • Theme pick (64abae7): picking a theme from the palette's Themes list no longer raises a phone's keyboard. Desktop is unchanged.
  • New-note typing (34dcfe6): typing in a new note goes under its # Title, not in front of it. This is pre-existing (core 2.78.0 too). It covers the name field's Enter and Escape and the palette's New Note, through focusEditorNormalMode.

What's New lists both features (a7b5540).

Verification so far

  • Merged tree: app-core 4116 passed, shared-domain 2208 passed, typechecks clean.
  • Branch CDP runs: database views 56/56 (a 44-column database, Vim on and off). Gallery 68/68 on desktop, 8/8 on a znserver vault, 11/11 in the web app, plus 390 px phone width.

How to test locally

  • Database views: open a database and press Columns, then Hide all, and tick 4 columns. Make a New table view and switch back: each view keeps its columns. Select 3 rows, then Show only these in this view: the strip reads "Showing 3 picked rows".
  • Gallery: right-click a folder with notes that have covers, then Open as Gallery. Move with h/j/k/l (or the arrows with Vim off) and press Enter to open.
  • New note: make a new note, type its name, press Enter, and type Body. The note reads # <name>, an empty line, then Body.

On the phones, picking a theme from the command palette's Themes list
raised the keyboard over the note being read (seen on Android 1.1.50's
walk, and the iPhone shares the code). The palette's command path already
treats a theme as a `stay` landing (lib/command-landing.ts): the focus goes
back where it was before the palette opened, so a keyboard that was down
stays down. The Themes list committed its pick on a separate path that
called focusEditorNormalMode itself and skipped that rule.

It now hands back through focusEditorAfterPaletteClose, like backing out
of the palette or running a toggle. Desktop is unchanged: that hand-off
focuses the editor there, as the theme pick always did.
…ked rows

charles b. imports CSVs of up to 44 columns into databases and wants saved
views that show only 4 or 5 of them, and only the rows he chose, by filter
or by hand. The schema already had the per-view half of the columns story
(DbView.hiddenFieldIds, which the Table honors), but nothing could set it,
and there was no way to save a hand-picked set of rows at all.

This is the data layer for both, with no UI yet.

DbView gains an optional pickedRowIds. When present, the view shows only
the rows it names, and its filters and sorts then apply to those; absent
means every row. An empty list is kept as an empty list (the UI will say
"0 picked rows") rather than read as "everything", so taking out the last
pick never floods the view with every record. Ids of rows deleted since are
ignored where the rows are computed instead of pruned when a row goes, so a
row delete never costs a schema write; the next edit of the picks drops
them. Older builds ignore the field and round-trip it untouched, since the
sidecar parser keeps view objects whole.

The rows a view shows now come from one shared function, viewRows
(shared-domain): picks, then filters, then sorts. The Table and the Board
both call it, so a view means the same records on either surface. The Board
used to filter without sorting; it now runs the sorts too, which changes
nothing today because no board view carries sorts (no UI sets them).

The pure helpers the UI will dispatch live in database-cells:
setColumnHidden, showAllColumns and hideAllColumns write one view's
hiddenFieldIds and refuse to hide its last visible column (hide all keeps
the title column, which carries the page icon); duplicateView makes a deep
copy of every setting, picks included, right after the source, named
"<name> copy"; setViewPicks, removeFromViewPicks and clearViewPicks edit the
picks; addRowInView adds a row that joins the active view's picks so it
cannot vanish the moment it is made, and says when the schema changed so
the caller writes it. New views get unique names (Table 2, Board 2), now
that a database will commonly carry several table views. The column-order
normalization moveColumn and moveColumnToField each spelled out is one
helper, normalizedColumnOrder, with the same behavior.

zn and the MCP server do not apply views at all (zn base rows lists every
row, and the MCP server has no database tools), so they are left as they
are; picks change only what the app's views show.
…shows

With a 44-column CSV imported, the grid drew all 44 and there was no way to
see fewer: each view could already store hidden columns, but no control set
them. A table view now has a Columns button in the database header, beside
Filter. It reads "Columns" while every column shows and "Columns 5 of 44"
once some are hidden, and opens a popover with a find field, Show all and
Hide all, and a checklist of the view's columns in its column order.

Every toggle writes the active view's hiddenFieldIds, so each saved view
keeps its own columns: a "Pipeline" view can show 5 while the default Table
keeps all 44. The last visible column cannot be hidden (its row says why on
hover), and Hide all keeps the title column, which carries the page icon,
as the start of picking the few you want. The row id stays out of the list,
as it stays out of the grid. Switching views closes the popover, since the
next view has columns of its own. A board view has no Columns button: its
cards choose their fields separately.

The column header menu gains Hide column (this view only) and Choose
columns..., and in the grid c opens the popover with Vim mode on. c was
free among the grid's keys; like O it is a new single-key shortcut, so it is
off with Vim mode off, and it does nothing while a menu or dialog is open.

In the popover, with Vim on, the list holds the keyboard: j / k move,
Space or x toggles, / jumps to the find field, Home / End reach the ends.
With Vim off the find field takes the typing and no letter is a shortcut:
the arrows move between the field and the columns, Space or Enter toggles
the focused column. Escape closes it in both, and focus goes back to the
grid unless a click put it somewhere else. The panel is one of the
SELF_KEYED_SURFACES, so the global Vim layer (and its Vim-off panel keys)
leave its keys alone instead of walking the sidebar behind it.
The + beside a database's view tabs could only add a board view, so the
saved views charles b. asked for (several tables over one 44-column CSV,
each showing its own few columns) could not be made at all. The + now
opens a menu: New table view, New board view, and Duplicate this view.

New table view adds a table showing every column, makes it the active view
and opens the Columns popover straight away, so a wide database becomes a
4- or 5-column view in one go. New views get names that tell them apart
(Table 2, Board 2).

Duplicate copies everything the view is: its filters and whether they
match all or any, its sorts, column order, hidden columns and widths, a
board's grouping, column order and card fields, and its picked rows. The
copy is deep, lands right after the source as "<name> copy", and becomes
the active view, ready to be changed without touching the original. The
view tab's right-click menu gains Duplicate view, between Rename view and
Delete view.
charles b. wanted to choose a view's rows "either by field filter or
selecting the rows". Filters existed; picking rows by hand did not. Now,
with rows selected in the Table (the checkboxes, or Space / x in the grid),
the selection bar gains "Show only these in this view". It saves the
selection as the view's picks (DbView.pickedRowIds), and the view shows
only those rows from then on, its filters and sorts applied on top.

A view with picks always says so: a strip under the header reads "Showing
N picked rows", with Edit picks and Show all rows. Edit picks shows every
row (picks and filters set aside, sorts kept) with the picked ones
selected, and the selection bar offers Update picks or Cancel instead of
its usual actions; Escape in the grid cancels too. Show all rows drops the
picks. The row menu gains "Remove from this view's picks" (for the whole
selection when the row is part of one), and "Show only selected rows"
while rows are selected, so x then m reaches it from the keyboard. Taking
out the last pick leaves the view at "Showing 0 picked rows" rather than
quietly showing every record again.

A row added while a picks view is on screen joins its picks, or it would
vanish the moment it was made: the grid's a, the New row control and the
header's + Row all add it there, and while editing picks the new row is
selected so Update picks keeps it. Picks that name deleted rows count for
nothing and drop out at the next edit.

The Board honors picks the same way, since both surfaces get their rows
from viewRows. It shows the strip with Show all rows but no Edit picks: a
board has no row selection, so a board's picks come from duplicating a
board that has them, or from schema.json.

Switching to another view tab now leaves edit mode and clears the
selection, since the other view has picks and columns of its own; before,
a selection made in one view stayed checked in the next.
Every new database control gets a command palette entry, so it can take a
key of its own like any other command (#971): Choose Database Columns, New
Table View, Duplicate View, Show Only Selected Rows and Show All Rows, in
the Database category next to Create Pages for All Records.

They act on the database in the active pane, opened as a database tab or
as a .csv file, and its active view, and each lists only where it can do
something: Choose Database Columns and Show Only Selected Rows on a table
view, Show All Rows on a view with picks, the other two on any database.
New Table View opens the Columns popover for the new view, as the + menu
does. Show Only Selected Rows with nothing selected says how to select
rows instead of doing nothing.

What they act on lives in component state rather than the store (whether
the Columns popover is open is the header's; the row selection is the
Table's), so a command names its database on a window event,
zen:database-view-command, and only the DatabaseView in the active pane
answers it; the same database open in a second pane stays as it is.

COMMAND_CATALOG is regenerated from buildCommands, so the five ids are
bindable in Settings, config.toml and :bind.
The in-app manual says what the database views now do, in the words the UI
uses. "Any CSV is a database" covers the Columns button and its "Columns 5
of 44" label, Hide column, the + menu (New table view, New board view,
duplicate), Duplicate view on a view tab, Show only these in this view and
the "Showing N picked rows" strip with Edit picks and Show all rows, rows
added in a picks view, the Board, and the five palette commands.

The Database grid shortcuts gain c (Vim mode, with what the popover's keys
are in both modes), and the m, Space / x, a and Esc rows say what they now
do around picks and the column menu.
Run with nothing selected, the command says how to select rows. Its first
wording named x, a key hint that, like every other in the app, belongs to
Vim mode, so with Vim off it now reads "the row checkboxes, or Space in the
grid" instead.
…of it

Make a note and start typing: the text went in front of the heading, as
`Body# My note`. On desktop and on both phones, whichever way the note was
made: naming it in the name field and pressing Enter or Escape, or a New
Note command from the palette that hands the editor the keyboard itself
(seen on Android). iPhone build 52 (core 2.78.0) does the same, so it is
not new in 2.79.0.

A new note's body is `# <title>` and an empty line, and its editor starts
with the caret at 0. The name field's Enter hands the focus to the editor
(#214's "Enter to drop into the body") and a writing command does the same,
but nothing moved the caret; a rename rewrites the heading as a minimal
change, which leaves a caret at 0 where it was.

focusEditorNormalMode, the hand-off every one of those paths goes through,
now drops the caret of a note that holds only its heading, with the caret
still at the very start, to the end, on the empty line under the heading.
A note with more in it, a caret already placed, a selection, or a heading
with no line under it is left alone.

Found by the iPhone 1.34.0 walk (cause traced there) and the Android 1.1.50
walk (the palette path); reproduced on the desktop build over CDP: Vim on,
name, Enter, `i`, `Body` saved `Body# Caret check`.
The database grid answered h/j/k/l, H/L, 0 ^ $, gg/G, i, x, dd, a, o and m
whether Vim mode was on or not, so with it off a stray letter typed at a
focused grid moved the cursor, reordered a column, added a row, opened a
page, or (dd) deleted the record under the cursor. This release already
made the sidebar, the note list, Connections, the Outline and Comments
follow the rule every list in the app keeps: single-key motions and verbs
are Vim mode's; the arrows, Enter and Escape stay live. The grid was the
one surface left.

Which keys answer in which mode now lives in one function,
databaseGridKeyAction (app-core), and the grid's handler acts on what it
returns rather than on e.key. With Vim mode off the grid answers:

  arrows        move the cell cursor; up past the first row reaches the
                column header, as k does
  Enter         edit the cell (toggle a checkbox cell; rename the field
                on the header)
  Space         select the row (toggle a checkbox cell)
  Escape        leave Edit picks, clear the selection, leave the grid
  Shift+F10 /   open the row menu, or the column menu on the header, as
  Context Menu  in the sidebar and the note list

and nothing else: every letter, digit and Vim symbol is ignored, as is
any chord, which stays the app's. With Vim mode on everything works as
before, and Shift+F10 and the Context Menu key now open the menus there
too. Space already selected rows alongside x, so it stays. Everything the
letters did is still reachable without them: Open page, Delete row and the
picks items in the row menu, Move left / Move right, Hide column and
Choose columns in the column menu, New row and + Row, the Columns button,
and the command palette.

Delete and Backspace deliberately do not delete selected rows. Deleting a
row asks first only when the row has a linked page; a plain row goes at
once, through the selection bar's Delete, the row menu's Delete row or
dd, and a key that sits next to text editing should not get that power
without a confirm the grid does not have. Row deletion stays on those
explicit controls.

The column menu named H and L next to Move left and Move right in either
mode; those hints now show only with Vim mode on, like c beside Choose
columns and O in the database menu. The Database grid table in the manual
now leads with the keys that work in both modes, marks every letter row
"Vim mode." with where its action lives otherwise, and says so in its
description, as the other tables do; the card text names x and o only as
Vim mode keys.

Verified with unit tests for the gating (every grid letter answers with
Vim on and none with it off, the universal keys in both modes, chords and
plain F10 left alone, Delete and Backspace untaken), app-core typecheck and
its full suite, the three Vim smoke suites, and the CDP driver on the
built app: with Vim off the arrows moved the cursor two rows and two
columns, j k h l H L G gg 0 $ x i o a m c O and dd changed nothing (cursor,
rows, columns, selection, menus, tabs), Space selected, Enter edited,
Shift+F10 opened the row menu, the Context Menu key on the header opened a
column menu without H or L; with Vim on every letter still did its job.
…cked rows, and the + makes table views and copies
… the picture each card draws

charles b. asked, once covers shipped in 2.78.0, for "a gallery view of all
notes in a folder by cover thumbnail". This is the part of it that needs no
screen: pure functions the view is a thin shell over, so each rule is
tested on its own.

lib/folder-gallery.ts decides:
- the tab: a gallery is `zen://gallery/<folder>/<encoded subpath>`, one tab
  per folder, and it follows its folder through a rename or a move (and
  closes when the folder is deleted) through the workspace's own path
  rewrite, root-mode and remapped system folders included;
- which notes: the folder's own notes, or every note below it with
  subfolders on. Drawings and a database's record pages stay out;
- the order: Updated (last edited first, the default), Created (newest
  first) or Title (natural, case-insensitive), ties by title and path so
  the grid never shuffles; the title filter keeps a note whose title holds
  every word typed;
- the grid: columns that fill the width at 200 px or more a card, never
  fewer than two (a phone shows two small cards a row) and never more than
  eight, every card the same height, which is what lets the view mount only
  the rows on screen; the cursor moves (left and right in reading order,
  up and down keeping the column, down past a short last row landing on
  the last card) and the scroll that keeps the cursor's card in view;
- the picture: a vault image is the file the host serves, a built-in
  gradient falls back to its own SVG when the vault cannot hand the file
  over yet, an Unsplash photo is asked for at 800 px instead of the
  cover's 2400 (the rest of the URL, its ixid included, kept byte for
  byte, since Unsplash wants its photos hotlinked), any other https cover
  as it is. No cover, or one this device cannot find, is the placeholder
  with the title's first letter; a card is not where a cover gets fixed;
- `:gallery <folder>`: a path in the vault, a folder in the notes area as
  :newtask takes it, or a system folder by key or label.

lib/gallery-covers.ts reads covers on demand. NoteMeta carries no cover and
does not gain one: that would change the desktop scan, the Go server and
the phones' vault code. A card asks when it comes on screen; the note is
read once, an open buffer first, and the answer is kept against the
note's path and updatedAt, so an edited note is read again and an
unchanged one never is. Cards asking at once share one read, a note that
cannot be read has no cover until it changes, a workspace switch empties
the cache and drops a read that lands after it. runLimited bounds how many
reads run at once and stops taking new ones when a scroll moves on.

Tests: tab paths and their rewrite on rename and delete (inbox, root mode,
remapped archive), membership with and without subfolders, the three
sorts, the filter, initials, dates, the folder argument, the grid's
geometry and row window, cursor moves, scroll-into-view, the thumbnail
choice for every cover kind, and the cover cache (read once, re-read on
change, shared reads, failed reads, scope switch, eviction).
…older's menu

Right-click a folder in the sidebar and choose Open as Gallery: the folder
opens as a tab, named after it, with its notes as cards. Each card leads
with the note's cover (an image in the vault, an Unsplash photo or a
gradient, cropped to fill at the cover's banner_y), then the title and the
date; a note without a cover gets a quiet card with its title's first
letter. The header carries the folder's name, "Gallery" and the note
count, a Sort menu (Updated, the default; Created; Title) and an Include
subfolders switch. A database folder has no Gallery: its record pages
have the database's own grid.

The view mounts only the rows on screen (plus two either side) and reads a
note for its cover only once its card is on screen, after a short pause so
a row flying past during a scroll costs nothing, four reads at a time. A
1000-note folder in the built app: first cards in about 20 ms, 11 note
reads to fill the screen, 47 after walking to the last card. Cards are
memoized on plain values, so a cover landing redraws its own card.

Keys, the Unresolved Links view's way: the view holds the keyboard while
the editor panel would, and VimNav stands down for it except for the
leader. With Vim on, h/j/k/l move across the grid, gg/G jump to the ends,
Enter or o opens and / moves into the filter; with Vim off only the
arrows, Enter and Escape act, and the filter field takes typing. Escape
clears the filter. A chord bound to one of the nav actions works in either
mode (#972). Every handler stands aside while a modal, a palette or a menu
is open. The cursor card has an accent ring; opening a note moves into
the editor the way other openers do, and Ctrl+O comes back to the Gallery
(a gallery tab is a jump-history stop like Tasks and Tags), on the card it
left from: each tab remembers its cursor, scroll and filter.

Sort and subfolders are portable preferences, gallery_sort and
gallery_include_subfolders under [view] in config.toml, beside
asset_sort_order and timeline_range: they are how a person likes to
browse, the same on every machine, and change only when chosen. They
apply to every Gallery rather than per folder, which would turn a view
setting into per-vault state.

At phone width the grid keeps two columns and the header wraps. An
Unsplash cover's credit shows on the card on hover, through
NoteCoverCredit's new compact frame, and always on a screen that cannot
hover, as on the note's own cover.

Verified with the next commit's openers in the built app over CDP, with
isolated userData and config dir (68 checks: the folder menu, sort,
subfolders, filter, Vim on and off, the modal bail, open and Ctrl+O, phone
width, the 1000-note folder), on a
self-hosted server vault (the desktop app in remote mode against znserver)
and in the web app (Vite client over znserver, headless Chrome). No
console errors.

Tests: the gallery prefs' defaults, round trip and normalizing, the
opener (one tab per folder, focus to the view), a Gallery tab following a
renamed folder and closing with a deleted one in both root modes, and the
config.toml round trip.
Every new feature ships with a Vim binding, and the folder menu is a mouse
route, so the Gallery gets three keyboard openers:

- Open Folder Gallery in the command palette (view.folder-gallery). It is
  in the commands registry, so it can take a key of its own (#971).
- Space G with Vim on. `g` is the Atlas, so the Gallery takes the capital:
  after the leader `G` is a key of its own, as `<leader>q` and `<leader>Q`
  are. The free lowercase keys (j, k, x, y, z) say nothing about a
  gallery, and `x` is where the manual's examples put people's own
  command keys. A user command sequence can no longer start on `G`, since
  built-in leader keys win. The leader hints list it as Folder gallery.
- `:gallery` (`:gal`) on the ex line, with an optional folder:
  `:gallery Projects/Website`, `:gallery inbox/Projects` or
  `:gallery archive`. A folder nothing in the vault answers says so in a
  toast instead of opening an empty tab.

Which folder, when none is named (lib/folder-gallery-open.ts): the row
under the sidebar cursor while the sidebar holds the keyboard (a folder
row is that folder, a note row its note's folder, the Archive row the
archive), the way the palette's Move Note to Trash follows the sidebar
(#972); otherwise the open note's folder; from a Gallery its own folder;
and last the top of the notes area.

Verified in the built app over CDP: :gallery from a note opens its folder,
the palette row shows Space G and opens the open note's folder (a
subfolder, Recipes/Desserts), Space G from a note opens its folder, and
with the sidebar focused and its cursor walked onto the Recipes folder
row, Space G opens Recipes.

Tests: the folder each context means (sidebar folder, note and Archive
rows, the open note, an open Gallery, the fallback) and the argument path,
including the toast for a folder that is not there.
The Help view now has:

- a how-to card, "See a folder as a gallery of its covers", after the two
  cover cards: what a card shows, the four ways to open it and which
  folder each one picks, sort and subfolders with their config.toml keys,
  what is left out, the keys with Vim on and off, Ctrl+O back to the card,
  and that it reads only the covers on screen, works on a self-hosted
  server and in the web app, and keeps two columns on a phone;
- Space G in the leader table, next to Space u;
- `:gallery [folder]` in the ex commands, before `:unresolved`;
- a Gallery view section with its keys. HelpView resolves those keys from
  the nav.* bindings and Space G from vim.leaderFolderGallery, so the
  manual follows a rebinding;
- the Gallery among the list views a chord can reach (#972).

Open Folder Gallery reaches the manual's command list on its own, from
the palette. The website text, the same facts, is in
docs/releases/v2.79.0/website-docs-folder-gallery.md for the website pass.
…iews that keep their own columns

charles b. asked for both on release morning and they joined 2.79.0: a
folder opens as a Gallery of its notes by cover, and a database table view
keeps its own columns and picked rows. The What's New card names them in
the words the app shows (Open as Gallery, Columns, Show only these in this
view) and the keys that open them.
… one, as one edit (#1016)

williamcheuk03 asked for a right-click way to make text a callout and to
switch an existing callout between types. Before any menu, this lands the
edits themselves as pure functions over the note text, so the menu, the
palette and a leader key can share one rule for what they touch.

What a caret or a selection targets:

- Inside a callout, the innermost callout around the whole selection.
- Otherwise a selection covers its whole lines (one that ends at the very
  start of a line leaves that line out), and a caret takes its paragraph:
  the non-blank lines around it, stopping at a heading, a code fence and
  the edge of a quote. A caret anywhere in a list takes the whole list,
  and a paragraph inside a list item stays in that item, indentation and
  all.
- A block is never cut in half. A selection that reaches into part of a
  fence or a quote grows to hold all of it, so a callout it touches ends
  up nested, whole, inside the new one.
- Nothing in a code fence or in the frontmatter: the lines there are code
  and YAML, and `> ` in front of them would change what they say.

The three edits:

- Turn into writes `> [!type]` and prefixes each line `> ` (a blank line
  `>`). A plain blockquote already has its markers and only gets the
  title line above it. Markdown reads text right under a quote as more of
  that quote, so a callout made from part of a paragraph, or next to a
  quote, gets a blank line where it would run into its neighbor; without
  it the title under a quote is no callout at all.
- Change rewrites only the keyword, so the fold marker and the title stay.
  A callout already of that type, alias included (`[!error]` is danger),
  is left as written.
- Remove drops the title line, keeping a custom title as a plain first
  line, and strips one level of `> `, the callout's own, so a quote or
  callout nested inside keeps its markers.

Lines split at `\n` with any `\r` kept, so a CRLF note stays CRLF. The
editor side dispatches each edit with its own user event, not
`input.type`, so typing just before it never folds into the same Undo.
… type (#1016)

The editor's right-click menu gains a callout group after Paste, behind a
separator:

- On text, a selection or the paragraph under the pointer: "Turn into
  callout…".
- Inside a callout, written out or drawn as a card in Live Preview:
  "Change callout type…" and "Remove callout".
- In a code block or the frontmatter, "Turn into callout…" shows disabled
  with the reason beside it, the way Cut and Copy show disabled without a
  selection, so the menu says why rather than hiding the row. A blank
  line gets no row: there is nothing there to turn into anything, and it
  is where a paste usually goes.

The "…" items open a second menu at the same spot, titled "Callout type":
all twelve types in CALLOUT_TYPES order with their icons, the current
type checked and the cursor starting on it. The issue asked for Error by
name; `error` is danger's alias, so that row reads "Danger (Error)" and
typing "error" finds it. The filter also matches the other aliases, so
"caution" finds Warning and "tldr" Abstract. ContextMenu learns three
small things for it, no submenus: a title, a checked row (a check mark
and menuitemradio), and keywords the filter matches.

What the items act on follows the pointer, not the caret. A callout in
Live Preview is line decorations over editable text, so its right-click
already reached this menu, but the menu acted on the selection, which
can sit anywhere: a click on a rendered callout has to mean that callout.
A right-click inside the selection still means the selection. The pane
notes the selection when the right button goes down, because on a Mac
the press selects the word under the pointer before the menu opens. A
menu opened from the keyboard has no pointer and acts on the selection.

The reading view stays read-only. Its only write is ticking a task;
double-click a callout there and it opens in the editor, where the menu
is.
…and Space l a (#1016)

Every feature ships with a key, so the callout menu gets three ways in
that need no mouse:

- "Set Callout Type…" (editor.callout-type) opens the type menu at the
  caret. Outside a callout a pick turns the paragraph or the selected
  lines into one; inside, it changes that callout's type, the current one
  checked. In a code block, the frontmatter or on a blank line it says
  why in a toast instead.
- "Remove Callout" (editor.remove-callout) takes the callout at the caret
  back to plain text, or says the cursor is not in one.
- `Space l a` in Vim mode runs Set Callout Type…. `c` is the cover, so it
  takes `a`, for admonition and for the alerts GitHub calls them. It sits
  in the note-actions group with its which-key row ("Callout type"), and
  the right-click menu shows it beside Turn into and Change type, in Vim
  mode only: with Vim off the chord does nothing, so no row names it.

Both commands are listed whenever a note is open, like Convert Table to
Database, and both can take a key of their own (#971); the catalogs in
shared-domain carry them, and the leader group knows `a` is taken.
Remove Callout has no default key: it is one row away in the menu, from
the right-click or from Shift+F10 on a selection.

Running Set Callout Type… from the palette first left the menu open
without the keyboard. The palette, closing, hands the focus back to the
editor once the command has run, unless the command opened something it
knows of; a ContextMenu was not on that list, so the letters typed to
filter the menu went into the note as Vim commands. A command that leaves
a menu open now keeps the keyboard in it.
The shared ContextMenu moved its cursor on j and k whatever the Vim
setting, while its other letters filter the list. The rule since the
sidebar work is that with Vim off only the arrows, Enter and Escape act
on a list; single letters are text. The callout type menu (#1016) made
the gap plain: with Vim off, typing "j" should look for a type, and it
jumped a row instead.

Now j and k move only with Vim on. With it off they join the filter like
every other letter. Ctrl+N and Ctrl+P keep moving either way, since a
chord is not text. The arrows, Enter, Escape and Backspace are unchanged.
The Help view now has:

- a how-to card after "Callouts highlight the important bits", "Turn
  text into a callout from the menu": the three right-click items and
  the "Callout type" menu by the names they carry, Danger (Error) and the
  alias filter, what a selection, a paragraph, a list and a plain quote
  become, the blank line a callout gets beside its neighbors, what Change
  keeps and what Remove drops, Set Callout Type… and Remove Callout in
  the palette, Space l a, `m` or Shift+F10 on a selection in Vim mode,
  one Undo step, why code and properties are refused, and the keys with
  Vim off;
- a pointer to it at the end of the callouts card;
- Space l a in the leader table. HelpView resolves it from
  vim.leaderNoteActions and vim.leaderCalloutType, so the row follows a
  rebinding.

Set Callout Type… and Remove Callout reach the manual's command list on
their own, from the palette. The website text, the same facts, is in
docs/releases/v2.79.0/website-docs-callout-menu.md for the website pass.
…anges its type or removes it (#1016)

A correction to 1e1d7d1's message: Remove Callout is reachable from
Shift+F10 on a selection in Vim mode only; with Vim mode off on macOS,
Shift+F10 opens no editor menu (as before this branch), and the palette's
Remove Callout and the right-click menu are the routes.
…or 80 ms

The gate run on 14b2159 failed "lists two blocks under a note, then a row
for the rest" once: the rows were not drawn yet. The test waited a fixed
80 ms for the tagged blocks' reads, which land through a notify timer, and
with every package's suite running at once (turbo test:run) that chain took
longer. On its own the file passed 5 of 5 and the app-core suite 4184 of 4184.

After typing a #tag query the tests now wait until the first block row is
drawn (3 s at most), then settle as before. The test that turns tagged
blocks off, and the wait after Enter, keep the plain settle: no block row
ever appears there. No product code changed.
The code block language picker (#1014) lists every language the editor
can highlight, some 130 of them. The shared ContextMenu was built for a
dozen rows: it filtered on typed letters without a field to show them, it
never scrolled, and a letter matched anywhere in a name, so `c` then Enter
picked Brainfuck. A phone has no keyboard to type with until a field is
tapped.

A menu given `findPlaceholder` now has:

- a find field above the rows, holding the query. With Vim off it takes
  the keyboard when the menu opens and every letter is text. With Vim on
  the list keeps it, as in every other menu: j and k move, and `/` goes to
  the field, where j and k are letters too. The arrows, Ctrl+N / Ctrl+P,
  Enter and Escape act from either place, and an input method's Enter
  commits its word instead of picking a row.
- rows that scroll under the field (a 52vh cap) and keep the cursor's row
  in sight, at a fixed width so filtering does not resize the menu.
- ranked matches: a label or keyword that is the query, then one that
  starts with it, then a word that starts with it, then anything holding
  it. Equal matches keep their order.
- on a touch screen, the list in charge when it opens, so the keyboard
  does not rise over the rows it would pick from (the PromptModal rule).

`anchorTop` lets a menu opened just below something open above it when
there is no room below, instead of sliding up over what opened it.

Menus without `findPlaceholder` behave as before.
…e it lacks (#1014)

williamcheuk03 asked for a language drop-down on code blocks that lists the
supported languages and allows a manual override, alongside automatic
detection. The label at the top right of each fenced block in Edit mode
read the fence's language and, pressed, copied the block. Changing the
language meant finding the fence and typing a tag the editor might not
know, and a block with no tag stayed grey in the editor while the reading
view coloured it with highlight.js' best guess, however poor.

The label is now a language button, "typescript ▾", with copy moved to an
icon button beside it (aria-label "Copy code", a check while copied). The
button opens the "Code block language" picker, the shared menu with a find
field: every language the editor highlights by name (the eager grammars,
language-data and the user's TextMate languages), the current one checked,
tags searchable (`golang` finds Go). A pick rewrites only the opening
fence's language word: the backticks or tildes, their count, a quote's
markers and anything after the word stay, the caret stays on its
character, and it is one Undo step of its own. "Text (plain)" takes the
language away (or writes `text` when attributes follow, which deleting the
word would promote to the language). Each row writes a tag the editor
reads back as that language: `cpp`, `csharp`, `bash` for Shell (highlight.js
reads `shell` as a terminal session); a language no fence tag can reach,
such as VB.NET, is left out rather than offered as a pick that would not
highlight.

A block that names no language is guessed at, off the typing path: 400 ms
after edits or scrolling settle, in idle time, a few blocks per slice,
cached by the block's text. highlight.js scores the block in 22 languages
that both lowlight's common set and the editor know, and a guess must be
confident:

- at least 2 lines and 24 characters;
- relevance 8 or more on the first 2,000 characters (a block over 50,000
  characters is not guessed at);
- a hallmark of the language in the text, since relevance only adds up and
  a long paragraph clears any bar as CSS ("a", "code" and "line" are tag
  names a selector may use): `fn` or `::` for Rust, an `end` line for Ruby,
  a `prop: value;` rule for CSS, a statement opening a line for SQL;
- no language of another family within 2 points (a class that is valid
  Java and valid C# stays text).

A shebang, `<?php` and a JSON document are taken at their word. The bar was
tuned on 122 real snippets in code-language-detect.samples.ts: every
medium snippet in 22 languages is named right, none of 22 prose, log and
data texts is guessed at even repeated twelve times, nor are 28 snippets
in other languages; a short snippet is either named right or left as text.

A guessed block is parsed with the guessed language's own CodeMirror
parser, over the same ranges the markdown parser hands a labeled block's
language, and drawn with `highlightTree` through the editor's own highlight
style, so it looks exactly like the block labeled. Its label reads "python ·
detected", muted, in a dashed outline. The guess is never written to the
file; the picker's first row, "Python (detected)", writes it. A guess
stays with its block through edits until the next guess replaces it, and
a block longer than 6,000 characters keeps its colours mapped while it is
typed in and is redrawn once typing pauses, so typing there costs what it
does in a labeled block.

`text` and its spellings now read as plain text in the editor too: left to
language-data's fuzzy matcher, `text` contains `tex` and was highlighted
as LaTeX.

The guessing is the `detectCodeLanguages` preference (on); Settings and
the reading view follow in the next commit.
…sses as the editor does (#1014)

Settings → Editor → Writing gets "Detect code block languages", on by
default. Off, Edit mode guesses nothing (the detection extension is not
loaded) and an unlabeled block is plain text.

The reading view guessed on its own: rehype-highlight ran with `detect`,
which takes highlight.js' best score over all of lowlight's languages
however low, so `x = 1` came out as INI and a paragraph as CSS, and a block
the editor now calls "python · detected" could read as another language
there. The reading view now names an unlabeled block with the editor's
guesser, the same subset and bar, and rehype-highlight colours it by that
class with its own `detect` off. Both surfaces agree on every block, and
the setting switches both: off, the reading view leaves an unlabeled block
plain as well. A tiny block that `detect` used to colour stays plain now.
An indented code block, which the editor cannot label, is still guessed
at in the reading view, with the same bar.

The setting is portable: `detect_code_languages` under `[editor]` in
config.toml, beside `render_tables`. It is a writing preference with
nothing machine-specific in it, like its neighbours, so it travels with
the rest of the editor's settings. markdown-settings carries it to the
renderer, with its revision in the render cache key, so a switch redraws
the reading view at once.
…1014)

Every feature ships with a key, so the picker has two ways in that need no
mouse:

- "Set Code Block Language…" (editor.code-language) opens the picker for
  the code block the cursor is in, under the block's label when it is on
  screen, at the cursor in Split, which has no label. Outside a code block
  a toast says "The cursor is not in a code block." It is listed whenever a
  note is open, like Set Callout Type…, and can take a key of its own
  (#971); the catalogs in shared-domain carry it.
- `Space l l` in Vim mode runs it, `l` for language: the note-actions
  group's own key, pressed twice. f, y, s, d, c, u and a were taken. It has
  its which-key row ("Code block language"), the leader group knows `l` is
  taken, and the Help view's shortcut table resolves it from
  vim.leaderNoteActions and vim.leaderCodeLanguage, so a rebinding shows.
  With Vim off the chord does nothing.

The palette hands the keyboard to the picker it opened, as it does for the
callout menu: the picker is a ContextMenu.

The request goes through code-language-request.ts, a module with nothing
behind it. Imported from the picker's module, the command list pulled
language-data into the app's entry chunk.
…icker (#1014)

The Help view now has:

- a how-to card before "Add syntax highlighting for another language",
  "Set a code block’s language": the language button and the copy button
  beside it, the "Code block language" picker and its find field (tags
  count, an exact name first), what a pick rewrites and what it keeps, one
  Undo step, "Text (plain)", the user's own languages in the list, the
  guess for a block with no language ("python · detected", never written,
  "Python (detected)" writes it), tiny and ambiguous blocks staying text,
  the reading view agreeing, Set Code Block Language… and `Space l l`, the
  keys with Vim on and off, and the setting;
- `Space l l` in the leader table, resolved from vim.leaderNoteActions and
  vim.leaderCodeLanguage;
- "Detect code block languages" in the Editor behavior settings, with its
  config.toml key.

Set Code Block Language… reaches the manual's command list on its own,
from the palette. The website text, the same facts, is in
docs/releases/v2.79.0/website-docs-code-language.md for the website pass.
…from a menu

#1016 and #1014 (both asked for by williamcheuk03) joined 2.79.0 on release
day: right-click to turn lines into a callout or change one's type, and a
code block's label that picks its language and shows a guess for a block
without one. One What's New card names both, in the words the app shows.
@adibhanna
adibhanna merged commit 47d2682 into main Oct 29, 2026
10 checks passed
@adibhanna
adibhanna deleted the v2.79.0 branch October 29, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant