diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c18825a6f..de08e7bc7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.73.0", + "version": "2.74.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", @@ -48,7 +48,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/highlight": "^1.2.1", - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/sdk": "^1.32.0", "@myriaddreamin/typst-ts-renderer": "^0.7.0", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index aed439ea5..65a7ca399 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -295,6 +295,7 @@ import { showTray, } from "./tray"; import { shouldQuitWithOnlyQuickCaptureLeft } from "./last-window"; +import { quickCaptureBoundsOnDisplay } from "./quick-capture-placement"; import { checkForAppUpdates, describeInstall, @@ -5537,10 +5538,32 @@ async function showQuickCaptureWindow(): Promise { ) { inheritWindowWorkspaceSession(sourceWindow, win); } + // A hidden panel opens on the display under the pointer (#976). One already + // on screen stays put: the user can see it, and a pinned panel sits where + // it was placed. + if (!win.isVisible()) moveQuickCaptureToPointerDisplay(win); win.show(); win.focus(); } +/** Where the panel last sat on each display it left, by display id. */ +const quickCaptureBoundsByDisplay = new Map(); + +function moveQuickCaptureToPointerDisplay(win: BrowserWindow): void { + const bounds = win.getBounds(); + const from = screen.getDisplayMatching(bounds); + const to = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const next = quickCaptureBoundsOnDisplay({ + bounds, + from, + to, + remembered: quickCaptureBoundsByDisplay.get(to.id), + }); + if (!next) return; + quickCaptureBoundsByDisplay.set(from.id, bounds); + win.setBounds(next); +} + async function toggleQuickCaptureWindow(): Promise { const win = quickCaptureWindow; if (win && !win.isDestroyed() && win.isVisible() && win.isFocused()) { diff --git a/apps/desktop/src/main/quick-capture-placement.test.ts b/apps/desktop/src/main/quick-capture-placement.test.ts new file mode 100644 index 000000000..b9be26dfb --- /dev/null +++ b/apps/desktop/src/main/quick-capture-placement.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { quickCaptureBoundsOnDisplay, type DisplayArea } from './quick-capture-placement' + +// Work areas of a two-display Mac like the one in #976: a main display with +// the Dock below, and a smaller built-in display to its left, lower down. +const main: DisplayArea = { id: 1, workArea: { x: 0, y: 30, width: 2560, height: 1356 } } +const side: DisplayArea = { id: 2, workArea: { x: -1728, y: 206, width: 1728, height: 1085 } } + +describe('the Quick capture panel opens on the display under the pointer (#976)', () => { + it('stays exactly where it is on its own display', () => { + const bounds = { x: 140, y: 90, width: 620, height: 480 } + expect(quickCaptureBoundsOnDisplay({ bounds, from: main, to: main })).toBeNull() + }) + + it('opens centered on the other display when it was centered', () => { + const bounds = { x: 970, y: 468, width: 620, height: 480 } + expect(quickCaptureBoundsOnDisplay({ bounds, from: main, to: side })).toEqual({ + x: -1728 + (1728 - 620) / 2, + y: Math.round(206 + (1085 - 480) / 2), + width: 620, + height: 480 + }) + }) + + it('keeps a corner the user dragged it to', () => { + // Top right of the main display, flush with its edges. + const bounds = { x: 2560 - 620, y: 30, width: 620, height: 480 } + expect(quickCaptureBoundsOnDisplay({ bounds, from: main, to: side })).toEqual({ + x: -620, + y: 206, + width: 620, + height: 480 + }) + }) + + it('shrinks to fit a smaller work area and stays inside it', () => { + const small: DisplayArea = { id: 3, workArea: { x: 2560, y: 0, width: 500, height: 400 } } + const bounds = { x: 200, y: 200, width: 620, height: 480 } + expect(quickCaptureBoundsOnDisplay({ bounds, from: main, to: small })).toEqual({ + x: 2560, + y: 0, + width: 500, + height: 400 + }) + }) + + it('returns exactly to where it last sat on a display', () => { + const bounds = { x: 1700, y: 90, width: 700, height: 520 } + const there = quickCaptureBoundsOnDisplay({ bounds, from: main, to: side })! + expect( + quickCaptureBoundsOnDisplay({ bounds: there, from: side, to: main, remembered: bounds }) + ).toEqual(bounds) + }) + + it('maps afresh when the remembered spot no longer fits the display', () => { + // The display got smaller since, so the old spot hangs off its edge. + const remembered = { x: 2200, y: 1200, width: 620, height: 480 } + const bounds = { x: -1174, y: 509, width: 620, height: 480 } + const next = quickCaptureBoundsOnDisplay({ bounds, from: side, to: main, remembered })! + expect(next).not.toEqual(remembered) + expect(next.x + next.width).toBeLessThanOrEqual(2560) + expect(next.y + next.height).toBeLessThanOrEqual(30 + 1356) + }) + + it('lands fully on the new display when it hung off the edge of the old one', () => { + const bounds = { x: 2400, y: 1300, width: 620, height: 480 } + const next = quickCaptureBoundsOnDisplay({ bounds, from: main, to: side })! + expect(next.x + next.width).toBeLessThanOrEqual(0) + expect(next.y + next.height).toBeLessThanOrEqual(206 + 1085) + expect(next.x).toBeGreaterThanOrEqual(-1728) + expect(next.y).toBeGreaterThanOrEqual(206) + }) +}) diff --git a/apps/desktop/src/main/quick-capture-placement.ts b/apps/desktop/src/main/quick-capture-placement.ts new file mode 100644 index 000000000..30783deab --- /dev/null +++ b/apps/desktop/src/main/quick-capture-placement.ts @@ -0,0 +1,65 @@ +/** A rectangle in Electron's screen coordinates (DIPs, all displays share one space). */ +export interface ScreenRect { + x: number + y: number + width: number + height: number +} + +/** The part of a display Electron reports that windows may use. */ +export interface DisplayArea { + id: number + workArea: ScreenRect +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), Math.max(min, max)) + +const contains = (area: ScreenRect, rect: ScreenRect): boolean => + rect.x >= area.x && + rect.y >= area.y && + rect.x + rect.width <= area.x + area.width && + rect.y + rect.height <= area.y + area.height + +/** + * Where the Quick capture panel opens when the hotkey shows it again. The + * panel is created once and hidden between captures, so it kept the bounds of + * its first show and came back on that display even with the pointer and the + * focused window on another one (#976); as a macOS panel floating above other + * windows, tiling window managers leave it alone, so nothing outside the app + * could move it either. + * + * On the display it is already on, it stays exactly where the user left it + * (null). Back on a display it has been on, it returns to where it last sat + * there, as long as that still fits the display's work area. On a display it + * has not been on, it takes the same place there: its center keeps its + * relative position in the work area, so a centered panel opens centered and + * one dragged to a corner opens in that corner, shrunk to fit a smaller work + * area and kept inside it. That mapping rounds, so without the remembered + * spot each trip there and back could move the panel by a pixel. + */ +export function quickCaptureBoundsOnDisplay(input: { + bounds: ScreenRect + /** The display the panel is on now. */ + from: DisplayArea + /** The display under the pointer. */ + to: DisplayArea + /** Where the panel last sat on `to`, if it has been there. */ + remembered?: ScreenRect +}): ScreenRect | null { + const { bounds, from, to, remembered } = input + if (from.id === to.id) return null + const source = from.workArea + const area = to.workArea + if (remembered && contains(area, remembered)) return { ...remembered } + const width = Math.min(bounds.width, area.width) + const height = Math.min(bounds.height, area.height) + const fx = source.width > 0 ? clamp((bounds.x + bounds.width / 2 - source.x) / source.width, 0, 1) : 0.5 + const fy = source.height > 0 ? clamp((bounds.y + bounds.height / 2 - source.y) / source.height, 0, 1) : 0.5 + return { + x: clamp(Math.round(area.x + fx * area.width - width / 2), area.x, area.x + area.width - width), + y: clamp(Math.round(area.y + fy * area.height - height / 2), area.y, area.y + area.height - height), + width, + height + } +} diff --git a/apps/desktop/terminal-release.json b/apps/desktop/terminal-release.json index 4f6e7ced2..e26e16417 100644 --- a/apps/desktop/terminal-release.json +++ b/apps/desktop/terminal-release.json @@ -3,24 +3,24 @@ "release": { "repository": "ZenNotes/tui", "protocol": 1, - "version": "0.9.1", - "commit": "c6e988604834fa65314e6f8617f2e83b4461334e", + "version": "0.10.0", + "commit": "ced7926715549e85349e545f780a9e4bdca1585f", "artifacts": { "darwin-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_darwin_arm64.tar.gz", - "sha256": "6ee4b0d9ed60417453bae5aed10c1cb92cd2d1504be79afb5e6b8fb31caed99a" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_darwin_arm64.tar.gz", + "sha256": "0ab6f5596079cadb1329161dd9a6716f253355465a4ec8e527838fbb8ec97ee6" }, "darwin-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_darwin_amd64.tar.gz", - "sha256": "58c990a91900df44684f942d11907fec79c2712ca1a513278f957328779478c6" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_darwin_amd64.tar.gz", + "sha256": "dcbe9cfe7b116b1667e282dc81fcf13e8340b41d7f9575e0919481bd75908376" }, "linux-arm64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_linux_arm64.tar.gz", - "sha256": "b8a3dd4a3f160c2d5d9184a0a4ca941822d410623ef3c609aa841638dc866a54" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_linux_arm64.tar.gz", + "sha256": "b7bd5f8a1b22988240d612879b3194a07212ba8d7a51112df822ba935680d3b0" }, "linux-x64": { - "url": "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_linux_amd64.tar.gz", - "sha256": "8064c1282f63ce520ba252c09be4d867c8b8b4417d88c547cd5ea4e74a2a84da" + "url": "https://github.com/ZenNotes/tui/releases/download/v0.10.0/zn_0.10.0_linux_amd64.tar.gz", + "sha256": "d7aeca19b82c29b994cc0c2bcf6d3eff2767f3dea0842170462d21b6eda394f1" } } } diff --git a/apps/quicklook/package.json b/apps/quicklook/package.json index 3c2497873..10ece4e61 100644 --- a/apps/quicklook/package.json +++ b/apps/quicklook/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/quicklook", "private": true, - "version": "2.73.0", + "version": "2.74.0", "type": "module", "description": "The macOS Quick Look preview for Markdown files: the page it renders and the Swift extension that serves it", "homepage": "https://zennotes.org", diff --git a/apps/share-viewer/package.json b/apps/share-viewer/package.json index 86402a34a..fc9e5482d 100644 --- a/apps/share-viewer/package.json +++ b/apps/share-viewer/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/share-viewer", "private": true, - "version": "2.73.0", + "version": "2.74.0", "type": "module", "description": "Read-only renderer for publicly shared ZenNotes, embedded by the zennotes.org website", "homepage": "https://zennotes.org", diff --git a/apps/web/package.json b/apps/web/package.json index 4c4c0baa3..6682ca5fc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.73.0", + "version": "2.74.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 dba92754f..78d55f9f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.73.0", + "version": "2.74.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.73.0", + "version": "2.74.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.73.0", + "version": "2.74.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -36,7 +36,7 @@ "@codemirror/state": "^6.5.0", "@codemirror/view": "^6.35.3", "@lezer/highlight": "^1.2.1", - "@modelcontextprotocol/sdk": "^1.31.0", + "@modelcontextprotocol/sdk": "^1.32.0", "@myriaddreamin/typst-ts-renderer": "^0.7.0", "@myriaddreamin/typst-ts-web-compiler": "^0.7.0", "@myriaddreamin/typst.ts": "^0.7.0", @@ -282,9 +282,9 @@ } }, "apps/desktop/node_modules/@modelcontextprotocol/sdk": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", - "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.0.tgz", + "integrity": "sha512-8BviX/hK4Gd2eL1KdTwm0gfl4d3wdoErW0Jd00h6nomUAtk6IJk5vwQJc6kfWtJTzN5OEV/lgIiJKI8fiDGAlA==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -915,7 +915,7 @@ }, "apps/quicklook": { "name": "@zennotes/quicklook", - "version": "2.73.0", + "version": "2.74.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -989,7 +989,7 @@ }, "apps/share-viewer": { "name": "@zennotes/share-viewer", - "version": "2.73.0", + "version": "2.74.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -1061,7 +1061,7 @@ }, "apps/web": { "name": "@zennotes/web", - "version": "2.73.0", + "version": "2.74.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16409,7 +16409,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.73.0", + "version": "2.74.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16498,14 +16498,14 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.73.0", + "version": "2.74.0", "devDependencies": { "typescript": "^5.7.2" } }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.73.0", + "version": "2.74.0", "dependencies": { "@scure/bip39": "2.4.0", "@zennotes/bridge-contract": "*", @@ -16519,7 +16519,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.73.0" + "version": "2.74.0" } } } diff --git a/package.json b/package.json index 3fc609023..eca26806a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.73.0", + "version": "2.74.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 841eb17a1..f74694e0a 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.73.0", + "version": "2.74.0", "type": "module", "exports": { "./main": "./src/main.tsx", diff --git a/packages/app-core/src/App.tsx b/packages/app-core/src/App.tsx index 9854f18ff..899a8d489 100644 --- a/packages/app-core/src/App.tsx +++ b/packages/app-core/src/App.tsx @@ -38,7 +38,7 @@ import { matchesShortcut, TAB_SELECT_KEYMAP_IDS } from './lib/keymaps' -import { selectActiveBuffer } from './lib/buffer-navigation' +import { selectActiveBuffer, stepActiveTab } from './lib/buffer-navigation' import { resolveCloseShortcut } from './lib/close-shortcut' import { focusPaneOrEdgePanel } from './lib/pane-nav' import { @@ -841,6 +841,21 @@ function App(): JSX.Element { return } } + // Next / previous tab with Vim on or off (#973), in the same order gt/gT + // walk. Inserted mid-chain like the digits above, so an explicit rebind + // of the same combination to a later action still wins. + if (!overlayOpen) { + for (const [id, delta] of [ + ['tabs.next', 1], + ['tabs.previous', -1] + ] as const) { + if (!matchesShortcut(e, overrides, id)) continue + if (!overrides[id] && eventMatchesUserOverride(e, overrides, id)) continue + e.preventDefault() + stepActiveTab(state, delta) + return + } + } // The template pickers as plain shortcuts, so they no longer need Vim's // leader (#847). Same overlay rule as tab selection: a picker opened // under Settings or another palette would strand the user behind it. diff --git a/packages/app-core/src/components/CalendarPanel.tsx b/packages/app-core/src/components/CalendarPanel.tsx index eb39be2dc..4817952e4 100644 --- a/packages/app-core/src/components/CalendarPanel.tsx +++ b/packages/app-core/src/components/CalendarPanel.tsx @@ -41,6 +41,7 @@ import { confirmApp } from '../lib/confirm-requests' import { usePanelResize } from '../lib/use-panel-resize' import { PanelResizeHandle } from './PanelResizeHandle' import { ContextMenu, type ContextMenuItem } from './ContextMenu' +import { LazyNoteHoverPreview as NoteHoverPreview } from './LazyNoteHoverPreview' import { buildTaskMenuItems } from '../lib/task-context-menu' const FULL_DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] @@ -377,7 +378,10 @@ export function CalendarPanel({ ) // --- Hover preview ------------------------------------------------------- - const [hover, setHover] = useState<{ meta: NoteMeta; right: number; top: number } | null>(null) + const [hover, setHover] = useState<{ + meta: NoteMeta + rect: Pick + } | null>(null) const hoverTimer = useRef | null>(null) const clearHover = useCallback(() => { if (hoverTimer.current) clearTimeout(hoverTimer.current) @@ -386,10 +390,14 @@ export function CalendarPanel({ }, []) const armHover = useCallback((el: HTMLElement, meta: NoteMeta | undefined) => { if (!meta) return - const rect = el.getBoundingClientRect() + // The preview opens beside the panel, level with the day, so it never + // covers the days and weeks the pointer moves on to next. + const day = el.getBoundingClientRect() + const edge = el.closest('[data-calendar-panel]')?.getBoundingClientRect().left ?? day.left + const rect = { left: edge, right: edge, width: 0, top: day.top, bottom: day.bottom, height: day.height } if (hoverTimer.current) clearTimeout(hoverTimer.current) hoverTimer.current = setTimeout(() => { - setHover({ meta, right: window.innerWidth - rect.left + 8, top: rect.top }) + setHover({ meta, rect }) }, HOVER_DELAY_MS) }, []) useEffect(() => clearHover, [clearHover]) @@ -1209,21 +1217,10 @@ export function CalendarPanel({ )} - {hover && ( -
-
{hover.meta.title}
- {hover.meta.excerpt ? ( -
- {hover.meta.excerpt} -
- ) : ( -
Empty note
- )} -
- )} + {/* The day's note drawn as the note, the same preview a link gets. The + index excerpt runs every line together, so a day's plan would read + as one paragraph. */} + {hover && } {menu && ( setMenu(null)} /> diff --git a/packages/app-core/src/components/DatabaseTableView.tsx b/packages/app-core/src/components/DatabaseTableView.tsx index 8a3388707..6dd2ab59e 100644 --- a/packages/app-core/src/components/DatabaseTableView.tsx +++ b/packages/app-core/src/components/DatabaseTableView.tsx @@ -344,6 +344,14 @@ export function DatabaseTableView({ csvPath, doc, view, isActive }: Props): JSX. e.preventDefault() return openPage(c.row.id) } + case 'O': { + // Every record's page at once (o opens one). A new single-key + // shortcut, so it stays off with Vim mode off. + if (!useStore.getState().vimMode) return + e.preventDefault() + void useStore.getState().createAllRecordPages(csvPath) + return + } case 'm': { // Open the context menu (like the sidebar's `m`), anchored to the // active header or cell. On a header → field menu; on a cell → row menu. diff --git a/packages/app-core/src/components/DatabaseView.tsx b/packages/app-core/src/components/DatabaseView.tsx index d8859131e..b9586c324 100644 --- a/packages/app-core/src/components/DatabaseView.tsx +++ b/packages/app-core/src/components/DatabaseView.tsx @@ -17,7 +17,7 @@ import { DatabaseBoardView } from './DatabaseBoardView' import { ContextMenu, type ContextMenuItem } from './ContextMenu' import { DatabaseFilterMenu } from './DatabaseFilterMenu' import { Button, IconButton } from './ui/Button' -import { DatabaseIcon, TableIcon, KanbanIcon, PlusIcon, FilterIcon } from './icons' +import { DatabaseIcon, TableIcon, KanbanIcon, PlusIcon, FilterIcon, MoreIcon } from './icons' /** * Host for a CSV database tab: loads the database, renders the header @@ -40,6 +40,9 @@ export function DatabaseView({ const updateDatabaseRows = useStore((s) => s.updateDatabaseRows) const updateDatabaseSchema = useStore((s) => s.updateDatabaseSchema) const renameDatabase = useStore((s) => s.renameDatabase) + const createAllRecordPages = useStore((s) => s.createAllRecordPages) + const vimMode = useStore((s) => s.vimMode) + const [databaseMenu, setDatabaseMenu] = useState<{ x: number; y: number } | null>(null) const [viewMenu, setViewMenu] = useState<{ viewId: string; x: number; y: number } | null>(null) const [renamingView, setRenamingView] = useState(null) const [rawMode, setRawMode] = useState(false) @@ -218,6 +221,17 @@ export function DatabaseView({ )} + { + const rect = e.currentTarget.getBoundingClientRect() + setDatabaseMenu({ x: rect.left, y: rect.bottom + 4 }) + }} + > + + @@ -244,6 +258,22 @@ export function DatabaseView({ /> )} + {databaseMenu && ( + void createAllRecordPages(csvPath) + } + ]} + onClose={() => setDatabaseMenu(null)} + /> + )} + {viewMenu && ( prefs.vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, history(), + scrollerClickFocus, floatingCursorDrawSelection(), editorTabSize(prefs.editorTabSize), highlightActiveLine(), @@ -191,7 +194,7 @@ export function ExternalFileApp(): JSX.Element { ...vimHalfPageKeymap(prefs.vimMode, {}), indentWithTab, ...vimAwareDefaultKeymap(prefs.vimMode), - ...historyKeymap, + ...appHistoryKeymap, ...vimAwareSearchKeymap(prefs.vimMode) ]), EditorView.domEventHandlers({ diff --git a/packages/app-core/src/components/FloatingNoteApp.tsx b/packages/app-core/src/components/FloatingNoteApp.tsx index c957b4778..f9bae9a0a 100644 --- a/packages/app-core/src/components/FloatingNoteApp.tsx +++ b/packages/app-core/src/components/FloatingNoteApp.tsx @@ -25,7 +25,9 @@ import { lineNumbers } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' -import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, indentWithTab } from '@codemirror/commands' +import { scrollerClickFocus } from '../lib/cm-scroller-click-focus' +import { appHistoryKeymap } from '../lib/history-keymap' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { noteMarkdown } from '../lib/cm-markdown-language' @@ -348,6 +350,7 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element vimImeGuard(() => prefs.vimBlockImeInNormalMode && !isTouchPrimaryDevice()), vimVisualHighlightExtension, history(), + scrollerClickFocus, floatingCursorDrawSelection(), editorTabSize(prefs.editorTabSize), highlightActiveLine(), @@ -368,7 +371,7 @@ export function FloatingNoteApp({ notePath }: { notePath: string }): JSX.Element ...vimHalfPageKeymap(prefs.vimMode, {}), indentWithTab, ...vimAwareDefaultKeymap(prefs.vimMode), - ...historyKeymap, + ...appHistoryKeymap, ...vimAwareSearchKeymap(prefs.vimMode) ]), EditorView.updateListener.of((upd) => { diff --git a/packages/app-core/src/components/NoteHoverPreview.test.ts b/packages/app-core/src/components/NoteHoverPreview.test.ts new file mode 100644 index 000000000..b64e75cb9 --- /dev/null +++ b/packages/app-core/src/components/NoteHoverPreview.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment jsdom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useStore } from '../store' +import { NoteHoverPreview } from './NoteHoverPreview' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const PLAN = [ + '# Wednesday, October 28, 2026', + '', + '## Focus', + '- Dentist 10:30 a.m.', + "- Leave for Lori & Steve's after work", + '', + '## Tasks', + '- [ ] Pack my bags before I leave' +].join('\n') + +const note = { path: 'inbox/Daily Notes/2026-10-28.md', title: '2026-10-28' } +const anchorRect = { left: 1200, top: 300, right: 1230, bottom: 320, width: 30, height: 20 } + +// The calendar's day preview shows the note the way it reads, line by line, +// instead of the index excerpt that ran every line into one paragraph. +describe('NoteHoverPreview', () => { + let host: HTMLDivElement + let root: Root + let readNote: ReturnType + + beforeEach(() => { + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + readNote = vi.fn() + ;(window as unknown as { zen: unknown }).zen = { readNote } + useStore.setState({ activeNote: null }) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + document.body.innerHTML = '' + }) + + const render = async (): Promise => { + await act(async () => { + root.render(createElement(NoteHoverPreview, { note, anchorRect })) + }) + await act(async () => { + await Promise.resolve() + }) + return document.body.querySelector('.note-hover-preview')! + } + + it("draws a day's plan with its headings, list items and task box", async () => { + readNote.mockResolvedValue({ ...note, body: PLAN }) + const card = await render() + const article = card.querySelector('article')! + expect([...article.querySelectorAll('h2')].map((h) => h.textContent)).toEqual(['Focus', 'Tasks']) + expect(article.querySelectorAll('li')).toHaveLength(3) + expect(article.querySelector('input[type="checkbox"]')).not.toBeNull() + }) + + it('says an empty note is empty instead of loading forever', async () => { + readNote.mockResolvedValue({ ...note, body: '' }) + const card = await render() + expect(card.textContent).toContain('Empty note') + expect(card.textContent).not.toContain('Loading preview') + }) + + it('says it is loading while the note is read', async () => { + readNote.mockReturnValue(new Promise(() => {})) + const card = await render() + expect(card.textContent).toContain('Loading preview') + }) +}) diff --git a/packages/app-core/src/components/NoteHoverPreview.tsx b/packages/app-core/src/components/NoteHoverPreview.tsx index a32f906dc..983919089 100644 --- a/packages/app-core/src/components/NoteHoverPreview.tsx +++ b/packages/app-core/src/components/NoteHoverPreview.tsx @@ -186,6 +186,8 @@ export function NoteHoverPreview({ className="prose-zen prose-hover-preview" dangerouslySetInnerHTML={{ __html: html }} /> + ) : content ? ( +
Empty note
) : (
Loading preview…
)} diff --git a/packages/app-core/src/components/PinnedReferencePane.tsx b/packages/app-core/src/components/PinnedReferencePane.tsx index c9f975654..63add99cc 100644 --- a/packages/app-core/src/components/PinnedReferencePane.tsx +++ b/packages/app-core/src/components/PinnedReferencePane.tsx @@ -26,7 +26,9 @@ import { tooltips } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' -import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, indentWithTab } from '@codemirror/commands' +import { scrollerClickFocus } from '../lib/cm-scroller-click-focus' +import { appHistoryKeymap } from '../lib/history-keymap' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' import { getKeymapBinding, type KeymapOverrides } from '../lib/keymaps' @@ -142,7 +144,7 @@ function buildReferenceKeymap(vimMode: boolean, overrides: KeymapOverrides): Ext }), indentWithTab, ...vimAwareDefaultKeymap(vimMode), - ...historyKeymap, + ...appHistoryKeymap, ...vimAwareSearchKeymap(vimMode) ]) } @@ -248,6 +250,7 @@ export function PinnedReferencePane(): JSX.Element | null { ), vimVisualHighlightExtension, history(), + scrollerClickFocus, drawSelectionCompartment.of(cursorDrawSelection(s0.cursorBlink)), tabSizeCompartment.of([ editorTabSize(s0.editorTabSize), diff --git a/packages/app-core/src/components/QuickCaptureApp.tsx b/packages/app-core/src/components/QuickCaptureApp.tsx index 79e08242c..e2d24b460 100644 --- a/packages/app-core/src/components/QuickCaptureApp.tsx +++ b/packages/app-core/src/components/QuickCaptureApp.tsx @@ -42,7 +42,9 @@ import { placeholder } from '@codemirror/view' import { Vim, vim } from '@replit/codemirror-vim' -import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, indentWithTab } from '@codemirror/commands' +import { scrollerClickFocus } from '../lib/cm-scroller-click-focus' +import { appHistoryKeymap } from '../lib/history-keymap' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap, vimAwareSearchKeymap } from '../lib/cm-vim-default-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { registerDisplayLineMotion } from '../lib/cm-vim-display-line' @@ -482,6 +484,7 @@ export function QuickCaptureApp(): JSX.Element { ]) ), history(), + scrollerClickFocus, storedCursorDrawSelection(PREFS_KEY, () => loadPrefs().cursorBlink), editorTabSize(prefs.editorTabSize), highlightActiveLine(), @@ -532,7 +535,7 @@ export function QuickCaptureApp(): JSX.Element { ...vimHalfPageKeymap(prefs.vimMode, {}), indentWithTab, ...vimAwareDefaultKeymap(prefs.vimMode), - ...historyKeymap, + ...appHistoryKeymap, ...vimAwareSearchKeymap(prefs.vimMode) ]), EditorView.updateListener.of((upd) => { diff --git a/packages/app-core/src/components/SettingsModal.tsx b/packages/app-core/src/components/SettingsModal.tsx index 6f8df80ca..6f947d108 100644 --- a/packages/app-core/src/components/SettingsModal.tsx +++ b/packages/app-core/src/components/SettingsModal.tsx @@ -124,7 +124,12 @@ import { specificFolderDestination, } from "../lib/vault-layout"; import { BUILTIN_TEMPLATES } from "@shared/builtin-templates"; -import { composeTemplateFile, mergeTemplates } from "@shared/template-files"; +import { + composeTemplateFile, + findTemplate, + mergeTemplates, + templateSettingId, +} from "@shared/template-files"; import { TemplateEditorModal } from "./TemplateEditorModal"; import type { NoteTemplate } from "@bridge-contract/templates"; import { @@ -7303,8 +7308,9 @@ function TemplateSelectRow({ }): JSX.Element { // A configured template that no longer exists (deleted) shows as missing so // the user can pick a replacement; daily/weekly creation falls back to blank. - const missing = !!value && !templates.some((t) => t.id === value); - const selected = templates.find((t) => t.id === value); + // A built-in's id still names it once edited (findTemplate). + const selected = findTemplate(templates, value); + const missing = !!value && !selected; const triggerLabel = missing ? "[Missing template]" : selected @@ -7329,7 +7335,7 @@ function TemplateSelectRow({ // Highlight the current selection when the menu opens. useEffect(() => { if (!open) return; - const current = value ? items.findIndex((t) => t?.id === value) : 0; + const current = value ? items.findIndex((t) => !!t && t === selected) : 0; setActiveIdx(current >= 0 ? current : 0); }, [open, items, value]); @@ -7402,7 +7408,8 @@ function TemplateSelectRow({ setActiveIdx((i) => Math.max(0, i - 1)); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); - commit(items[activeIdx]?.id); + const picked = items[activeIdx]; + commit(picked ? templateSettingId(picked) : undefined); } else if (e.key === "Escape") { e.preventDefault(); setOpen(false); @@ -7474,7 +7481,7 @@ function TemplateSelectRow({ >
{items.map((tpl, idx) => { - const isSelected = tpl ? tpl.id === value : !value && !missing; + const isSelected = tpl ? tpl === selected : !value && !missing; const text = tpl ? `${tpl.category} · ${tpl.name}` : "None (blank note)"; @@ -7485,7 +7492,7 @@ function TemplateSelectRow({ role="option" aria-selected={isSelected} data-idx={idx} - onClick={() => commit(tpl?.id)} + onClick={() => commit(tpl ? templateSettingId(tpl) : undefined)} onMouseMove={() => setActiveIdx(idx)} className={[ "flex w-full items-center px-3 py-1.5 text-left text-sm", diff --git a/packages/app-core/src/components/TemplateEditorModal.tsx b/packages/app-core/src/components/TemplateEditorModal.tsx index abcdf21dc..ec12d8378 100644 --- a/packages/app-core/src/components/TemplateEditorModal.tsx +++ b/packages/app-core/src/components/TemplateEditorModal.tsx @@ -9,7 +9,9 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { Compartment, EditorState, type Transaction } from '@codemirror/state' import { EditorView, highlightActiveLine, keymap, tooltips } from '@codemirror/view' import { vim } from '@replit/codemirror-vim' -import { history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { history, indentWithTab } from '@codemirror/commands' +import { scrollerClickFocus } from '../lib/cm-scroller-click-focus' +import { appHistoryKeymap } from '../lib/history-keymap' import { vimAwareDefaultKeymap, vimAwareMarkdownKeymap } from '../lib/cm-vim-default-keymap' import { vimVisualHighlightExtension } from '../lib/cm-vim-visual-highlight' import { isTouchPrimaryDevice, vimImeGuard } from '../lib/cm-vim-ime-guard' @@ -130,6 +132,7 @@ export function TemplateEditorModal({ ), vimVisualHighlightExtension, history(), + scrollerClickFocus, cursorDrawSelection(useStore.getState().cursorBlink), editorTabSize(useStore.getState().editorTabSize), highlightActiveLine(), @@ -177,7 +180,7 @@ export function TemplateEditorModal({ keymap.of([ indentWithTab, ...vimAwareDefaultKeymap(vimModeRef.current), - ...historyKeymap + ...appHistoryKeymap ]), editorTheme, EditorView.updateListener.of((upd) => { diff --git a/packages/app-core/src/components/VimNav.tsx b/packages/app-core/src/components/VimNav.tsx index 0012b8c7b..5ebbe3ae5 100644 --- a/packages/app-core/src/components/VimNav.tsx +++ b/packages/app-core/src/components/VimNav.tsx @@ -34,6 +34,7 @@ import { getKeymapDisplay, getSequenceTokens, matchesSequenceToken, + matchesShortcut, matchesShortcutBinding, UNBOUND_BINDING, sequenceTokenFromEvent @@ -1266,6 +1267,17 @@ export function VimNav(): JSX.Element | null { // right test here. if (target?.closest(EXCALIDRAW_SURFACE)) return + // Next / previous tab (#973) belong to the app handler in every panel. + // The panel handlers below take every ArrowLeft / ArrowRight whatever + // its modifiers, so the Mac default Cmd+Option+Right opened the sidebar + // row under the cursor instead of stepping to the next tab. + if ( + matchesShortcut(e, overrides, 'tabs.next') || + matchesShortcut(e, overrides, 'tabs.previous') + ) { + return + } + // ------- Sidebar navigation (explicit) ----------------------------- // When focusedPanel is 'sidebar', always handle here — even if the // editor still holds stale DOM focus from a previous interaction. diff --git a/packages/app-core/src/database-record-pages.test.ts b/packages/app-core/src/database-record-pages.test.ts new file mode 100644 index 000000000..0ab2c3c4b --- /dev/null +++ b/packages/app-core/src/database-record-pages.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DatabaseDoc } from '@shared/databases' + +// "Create pages for all records": a database converted from a CSV has no +// record pages, and a record is a note links can reach only once it has one. + +const CSV = 'Books.base/data.csv' +const DUNE = 'Books.base/Dune.md' +const GONE = 'Books.base/Emma.md' + +beforeEach(() => { + vi.resetModules() + localStorage.clear() +}) + +async function setup( + overrides: { + createRecordPage?: (csv: string, title: string, body: string) => Promise + files?: Array<[string, string]> + pages?: Record + } = {} +) { + const files = new Map([[DUNE, '---\nAuthor: Herbert\n---\n# Dune\n\nRead twice.\n'], ...(overrides.files ?? [])]) + const metadata = (path: string) => ({ + path, title: path.split('/').pop()!.replace(/\.md$/, ''), folder: 'inbox' as const, + siblingOrder: 0, createdAt: 1, updatedAt: 1, size: 0, tags: [], wikilinks: [], assetEmbeds: [], hasAttachments: false, excerpt: '' + }) + const doc: DatabaseDoc = { + version: 1, path: CSV, title: 'Books', idFieldId: 'id', activeViewId: 'table', views: [], + fields: [{ id: 'id', name: 'ID', type: 'text' }, { id: 'title', name: 'Title', type: 'text' }, { id: 'author', name: 'Author', type: 'text' }], + rows: [ + { id: 'dune', cells: { id: 'dune', title: 'Dune', author: 'Herbert' } }, + // Mapped to a page that was deleted since. + { id: 'emma', cells: { id: 'emma', title: 'Emma', author: 'Austen' } }, + { id: 'kindred', cells: { id: 'kindred', title: 'Kindred', author: 'Butler' } }, + { id: 'blank', cells: { id: 'blank', title: ' ', author: 'Nobody' } } + ], + pages: overrides.pages ?? { dune: DUNE, emma: GONE }, + pageHasContent: { dune: true, emma: false } + } + const createRecordPage = vi.fn( + overrides.createRecordPage ?? + (async (_csv: string, title: string, body: string) => { + const path = `Books.base/${title}.md` + files.set(path, body) + return path + }) + ) + const bridge = { + getCapabilities: () => ({}), listNotes: async () => [...files.keys()].map(metadata), listFolders: async () => [], + scanTasks: async () => [], scanTasksForPath: async () => [], hasAssetsDir: async () => false, + getRemoteWorkspaceInfo: async () => null, setVaultSettings: async (s: unknown) => s, + readNote: vi.fn(async (path: string) => { + if (!files.has(path)) throw new Error('Missing page') + return { ...metadata(path), body: files.get(path)! } + }), + writeNote: vi.fn(async (path: string, text: string) => { + files.set(path, text) + return metadata(path) + }), + writeDatabaseRows: vi.fn(async () => {}), + writeDatabaseSchema: vi.fn(async () => {}), + createRecordPage + } + Object.defineProperty(window, 'zen', { configurable: true, value: bridge }) + const { useStore } = await import('./store') + const prompts = await import('./lib/confirm-requests') + const toasts = (await import('./lib/toast')).useToastStore + useStore.setState({ + vault: { root: '/test', name: 'Test' }, + databases: { [CSV]: doc }, + notes: [...files.keys()].map(metadata), + noteContents: {}, + noteDirty: {} + }) + const run = async (answer: boolean) => { + const running = useStore.getState().createAllRecordPages(CSV) + await vi.waitFor(() => expect(prompts.getConfirmRequest()).not.toBeNull()) + const request = prompts.getConfirmRequest()! + prompts.settleConfirmRequest(request, answer) + return { request, result: await running } + } + return { useStore, bridge, files, run, prompts, toasts, doc } +} + +describe('createAllRecordPages', () => { + it('gives each record without a page its page, and leaves the rest', async () => { + const s = await setup() + const { request, result } = await s.run(true) + + expect(request.options.title).toBe('Create pages for 2 records?') + expect(result).toEqual({ created: 2, linked: 0, existing: 1, untitled: 1 }) + expect(s.bridge.createRecordPage.mock.calls.map((c) => c[1])).toEqual(['Emma', 'Kindred']) + // The page the grid's own "open" would make: properties as frontmatter, the title as its heading. + expect(s.files.get('Books.base/Kindred.md')).toBe('---\nAuthor: Butler\n---\n# Kindred\n\n') + const pages = s.useStore.getState().databases[CSV].pages + expect(pages).toEqual({ dune: DUNE, emma: 'Books.base/Emma.md', kindred: 'Books.base/Kindred.md' }) + expect(s.files.get(DUNE)).toContain('Read twice.') + }) + + it('writes nothing when the question is declined', async () => { + const s = await setup() + const { result } = await s.run(false) + expect(result).toBeNull() + expect(s.bridge.createRecordPage).not.toHaveBeenCalled() + expect(s.useStore.getState().databases[CSV].pages).toEqual(s.doc.pages) + }) + + it('keeps the pages it made when one fails partway, and says so', async () => { + let calls = 0 + const s = await setup({ + createRecordPage: async (_csv, title) => { + calls++ + if (calls === 2) throw new Error('disk full') + return `Books.base/${title}.md` + } + }) + const { result } = await s.run(true) + expect(result).toEqual({ created: 1, linked: 0, existing: 1, untitled: 1 }) + expect(s.useStore.getState().databases[CSV].pages).toMatchObject({ emma: 'Books.base/Emma.md' }) + expect(s.useStore.getState().databases[CSV].pages).not.toHaveProperty('kindred') + expect(s.toasts.getState().toasts.at(-1)?.message).toContain('Created 1 of 2 record pages, then: disk full') + }) + + it('asks nothing when every titled record already has its page', async () => { + const s = await setup() + const done = { + ...s.doc, + rows: s.doc.rows.filter((r) => r.id === 'dune' || r.id === 'blank') + } + s.useStore.setState({ databases: { [CSV]: done } }) + const result = await s.useStore.getState().createAllRecordPages(CSV) + expect(result).toEqual({ created: 0, linked: 0, existing: 1, untitled: 1 }) + expect(s.prompts.getConfirmRequest()).toBeNull() + expect(s.bridge.createRecordPage).not.toHaveBeenCalled() + }) +}) + +// A workaround script wrote a page file for each record, named after it, +// but no schema.json entry: those files get linked, never a "Title 2.md" +// beside them. +describe('page files already in the folder', () => { + const scripted: Array<[string, string]> = [ + ['Books.base/Emma.md', '# Emma\n\nMy notes on Emma.\n'], + ['Books.base/pages/Kindred.md', '# Kindred\n\nMy notes on Kindred.\n'] + ] + + it('links them instead of writing copies', async () => { + const s = await setup({ files: scripted, pages: { dune: DUNE } }) + const { request, result } = await s.run(true) + expect(request.options.title).toBe('Link 2 records to their pages?') + expect(request.options.description).toContain('2 records already have page files in the folder; those are linked, not copied.') + expect(result).toEqual({ created: 0, linked: 2, existing: 1, untitled: 1 }) + expect(s.bridge.createRecordPage).not.toHaveBeenCalled() + expect(s.useStore.getState().databases[CSV].pages).toEqual({ + dune: DUNE, + emma: 'Books.base/Emma.md', + kindred: 'Books.base/pages/Kindred.md' + }) + // Linked as they are. + expect(s.files.get('Books.base/Emma.md')).toBe('# Emma\n\nMy notes on Emma.\n') + expect(s.toasts.getState().toasts.at(-1)?.message).toBe('Linked 2 existing pages.') + }) + + it('never takes a file another row maps', async () => { + // Emma's file is mapped to Dune's row, oddly; Emma still gets her own page. + const s = await setup({ files: [['Books.base/Emma.md', '# Emma\n']], pages: { dune: 'Books.base/Emma.md' } }) + const { result } = await s.run(true) + expect(result).toMatchObject({ linked: 0, existing: 1 }) + expect(s.bridge.createRecordPage.mock.calls.map((c) => c[1])).toEqual(['Emma', 'Kindred']) + }) + + it('opening one record links its file too', async () => { + const s = await setup({ files: scripted, pages: { dune: DUNE } }) + await s.useStore.getState().openRecordPage(CSV, 'emma') + expect(s.bridge.createRecordPage).not.toHaveBeenCalled() + expect(s.useStore.getState().databases[CSV].pages).toMatchObject({ emma: 'Books.base/Emma.md' }) + // Opening re-mirrors the row's properties and keeps the body, as for any page. + expect(s.files.get('Books.base/Emma.md')).toBe('---\nAuthor: Austen\n---\n# Emma\n\nMy notes on Emma.\n') + }) +}) + diff --git a/packages/app-core/src/lib/buffer-navigation.test.ts b/packages/app-core/src/lib/buffer-navigation.test.ts index 85d3e01e7..a2bb035f7 100644 --- a/packages/app-core/src/lib/buffer-navigation.test.ts +++ b/packages/app-core/src/lib/buffer-navigation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { NoteMeta } from '@shared/ipc' import type { PaneLayout } from './pane-layout' -import { getBufferNavigationTarget, getBufferSelectTarget } from './buffer-navigation' +import { getBufferNavigationTarget, getBufferSelectTarget, getTabStepTarget } from './buffer-navigation' function note(path: string, updatedAt: number, folder: NoteMeta['folder'] = 'inbox'): Pick { return { path, folder, updatedAt } @@ -113,3 +113,50 @@ describe('getBufferSelectTarget (#497)', () => { expect(getBufferSelectTarget(layout, 'pane-a', 1)).toEqual({ kind: 'none' }) }) }) + +describe('getTabStepTarget (#973)', () => { + const twoPanes: PaneLayout = { + kind: 'split', + id: 'root', + direction: 'row', + sizes: [0.5, 0.5], + children: [leaf('pane-a', ['one.md', 'two.md'], 'two.md'), leaf('pane-b', ['three.md'], 'three.md')] + } + + it('steps in the order gt and gT walk, across panes', () => { + expect(getTabStepTarget(twoPanes, 'pane-a', 1)).toEqual({ kind: 'focus', paneId: 'pane-b', path: 'three.md' }) + expect(getTabStepTarget(twoPanes, 'pane-a', -1)).toEqual({ kind: 'focus', paneId: 'pane-a', path: 'one.md' }) + for (const delta of [1, -1]) { + expect(getTabStepTarget(twoPanes, 'pane-a', delta)).toEqual( + getBufferNavigationTarget(twoPanes, 'pane-a', [], delta) + ) + } + }) + + it('wraps from the last tab to the first and back', () => { + expect(getTabStepTarget(twoPanes, 'pane-b', 1)).toEqual({ kind: 'focus', paneId: 'pane-a', path: 'one.md' }) + const first: PaneLayout = leaf('pane-a', ['one.md', 'two.md'], 'one.md') + expect(getTabStepTarget(first, 'pane-a', -1)).toEqual({ kind: 'focus', paneId: 'pane-a', path: 'two.md' }) + }) + + it('goes nowhere with one tab open, where gt would open a recent note', () => { + const single = leaf('pane-a', ['one.md'], 'one.md') + const notes = [note('two.md', 2), note('one.md', 1)] + expect(getTabStepTarget(single, 'pane-a', 1)).toEqual({ kind: 'none' }) + expect(getBufferNavigationTarget(single, 'pane-a', notes, 1).kind).toBe('open') + expect(getTabStepTarget(leaf('pane-a', [], null), 'pane-a', 1)).toEqual({ kind: 'none' }) + }) + + it('starts at the first tab, or the last going back, from a pane showing none', () => { + const layout: PaneLayout = { + kind: 'split', + id: 'root', + direction: 'row', + sizes: [0.5, 0.5], + children: [leaf('pane-a', ['one.md', 'two.md'], 'one.md'), leaf('pane-b', [], null)] + } + expect(getTabStepTarget(layout, 'pane-b', 1)).toEqual({ kind: 'focus', paneId: 'pane-a', path: 'one.md' }) + expect(getTabStepTarget(layout, 'pane-b', -1)).toEqual({ kind: 'focus', paneId: 'pane-a', path: 'two.md' }) + }) +}) + diff --git a/packages/app-core/src/lib/buffer-navigation.ts b/packages/app-core/src/lib/buffer-navigation.ts index d5f7f197d..fe0f4009b 100644 --- a/packages/app-core/src/lib/buffer-navigation.ts +++ b/packages/app-core/src/lib/buffer-navigation.ts @@ -108,6 +108,33 @@ export function getBufferSelectTarget( return targetFor(paneLayout, leaf.id, leaf.tabs, order[clamped - 1]) } +/** Next / previous tab for the app shortcuts and the palette (#973), with or + * without Vim: the order gt/gT walk, wrapping at either end, but open tabs + * only. gt with one tab open falls back to recent notes or a new quick note, + * the way Vim's buffer commands do; a "next tab" with one tab open has + * nowhere to go. From a pane showing no tab, next starts at the first tab + * and previous at the last. */ +export function getTabStepTarget( + paneLayout: PaneLayout, + activePaneId: string, + delta: number +): BufferNavigationTarget { + const leaf = findLeaf(paneLayout, activePaneId) + if (!leaf) return { kind: 'none' } + + const order = openTabOrder(paneLayout) + const baseIndex = leaf.activeTab ? order.indexOf(leaf.activeTab) : -1 + let nextIndex: number + if (baseIndex < 0) { + if (order.length === 0) return { kind: 'none' } + nextIndex = delta > 0 ? 0 : order.length - 1 + } else { + if (order.length < 2) return { kind: 'none' } + nextIndex = (((baseIndex + delta) % order.length) + order.length) % order.length + } + return targetFor(paneLayout, leaf.id, leaf.tabs, order[nextIndex]) +} + function applyTarget(runtime: BufferNavigationRuntime, target: BufferNavigationTarget): void { if (target.kind === 'focus') { void runtime.focusTabInPane(target.paneId, target.path) @@ -138,3 +165,7 @@ export function selectActiveBuffer(runtime: BufferNavigationRuntime, index: numb getBufferSelectTarget(runtime.paneLayout, runtime.activePaneId, index) ) } + +export function stepActiveTab(runtime: BufferNavigationRuntime, delta: number): void { + applyTarget(runtime, getTabStepTarget(runtime.paneLayout, runtime.activePaneId, delta)) +} diff --git a/packages/app-core/src/lib/cm-callout-fold.test.ts b/packages/app-core/src/lib/cm-callout-fold.test.ts index 64d4057d6..78d220f28 100644 --- a/packages/app-core/src/lib/cm-callout-fold.test.ts +++ b/packages/app-core/src/lib/cm-callout-fold.test.ts @@ -1,12 +1,13 @@ // @vitest-environment jsdom import { markdown, markdownLanguage } from '@codemirror/lang-markdown' -import { forceParsing, foldedRanges } from '@codemirror/language' -import { Compartment, EditorState } from '@codemirror/state' +import { forceParsing, foldedRanges, syntaxTreeAvailable } from '@codemirror/language' +import { Compartment, EditorState, type Extension } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { afterEach, describe, expect, it } from 'vitest' -import { calloutFolding } from './cm-callout-fold' +import { calloutFolding, toggleCalloutFold } from './cm-callout-fold' import { foldAllOutline, foldAtCursor, headingFolding } from './cm-heading-fold' +import { noteMarkdown } from './cm-markdown-language' import { wysiwygBlocksPlugin } from './cm-wysiwyg-blocks' import { noteEditingSync } from './note-lifecycle-lock' @@ -36,13 +37,16 @@ afterEach(() => { document.body.innerHTML = '' }) -function mount(doc = DOC, { caretLine = 14, livePreview = new Compartment() } = {}): EditorView { +function mount( + doc = DOC, + { caretLine = 14, livePreview = new Compartment(), language = new Compartment(), parsed = true } = {} +): EditorView { const parent = document.createElement('div') document.body.append(parent) const state = EditorState.create({ doc, extensions: [ - markdown({ base: markdownLanguage }), + language.of(parsed ? markdown({ base: markdownLanguage }) : []), headingFolding(), livePreview.of([wysiwygBlocksPlugin, calloutFolding()]) ] @@ -56,6 +60,27 @@ function mount(doc = DOC, { caretLine = 14, livePreview = new Compartment() } = return view } +/** The editor's note grammar, whose tree always spans the whole note however + * far the parse got, and plain markdown, whose tree ends where it stopped. */ +const GRAMMARS = [ + ['the note grammar', noteMarkdown], + ['plain markdown', () => markdown({ base: markdownLanguage })] +] as const + +/** Swap a markdown language in, so the tree changes and nothing else, as + * when the parser reaches more of a long note; then parse up to `upto`. */ +function parseLater( + view: EditorView, + language: Compartment, + { upto = view.state.doc.length, grammar = noteMarkdown as () => Extension } = {} +): void { + view.dispatch({ effects: language.reconfigure(grammar()) }) + forceParsing(view, upto, 5000) +} + +/** Paragraphs of filler, past where the first parse stops. */ +const FILLER = Array.from({ length: 400 }, (_, i) => `Filler paragraph ${i} keeps the note long.`).join('\n\n') + /** Folded ranges as [first line, last line] pairs. */ function folds(view: EditorView): [number, number][] { const out: [number, number][] = [] @@ -139,6 +164,86 @@ describe('foldable callouts in the editor (#853)', () => { expect(folds(view)).toEqual([[1, 2]]) }) + it.each(GRAMMARS)('folds a collapsed callout the parser reaches only after the note opened (%s)', async (_, grammar) => { + const doc = `${DOC}\n\n${FILLER}\n\n> [!note]- At the end\n> end body\n\nEnd.` + const language = new Compartment() + const view = mount(doc, { language, parsed: false }) + await settle() + expect(folds(view)).toEqual([]) + // The first parse covers the top of the note only. + parseLater(view, language, { upto: 500, grammar }) + expect(syntaxTreeAvailable(view.state)).toBe(false) + await settle() + expect(folds(view)).toEqual([[3, 6]]) + forceParsing(view, view.state.doc.length, 5000) + await settle() + const end = view.state.doc.lines - 3 + expect(folds(view)).toEqual([ + [3, 6], + [end, end + 1] + ]) + // Opened by the reader, it stays open through later parses. + toggleCalloutFold(view, view.state.doc.line(end).from) + parseLater(view, language, { grammar }) + await settle() + expect(folds(view)).toEqual([[3, 6]]) + }) + + it.each(GRAMMARS)('waits for the end of a collapsed callout the parse stopped inside (%s)', async (_, grammar) => { + const doc = `${DOC}\n\n${FILLER}\n\n> [!warning]- Long\n> first part\n>\n> second part\n\nEnd.` + const language = new Compartment() + const view = mount(doc, { language, parsed: false }) + await settle() + const title = view.state.doc.lines - 5 + // Stopped inside it, the parse shows the callout ending at its first part. + parseLater(view, language, { upto: view.state.doc.line(title).from + 4, grammar }) + expect(syntaxTreeAvailable(view.state)).toBe(false) + await settle() + expect(folds(view)).toEqual([[3, 6]]) + forceParsing(view, view.state.doc.length, 5000) + await settle() + expect(folds(view)).toEqual([ + [3, 6], + [title, title + 3] + ]) + }) + + it.each(GRAMMARS)('folds collapsed callouts that open and end the note once a tree reaches them (%s)', async (_, grammar) => { + const doc = '> [!note]- First\n> first body\n\nMiddle.\n\n> [!note]- Last\n> last body\n' + const language = new Compartment() + const view = mount(doc, { language, parsed: false, caretLine: 4 }) + await settle() + parseLater(view, language, { grammar }) + await settle() + expect(folds(view)).toEqual([ + [1, 2], + [6, 7] + ]) + }) + + it('leaves open a collapsed callout typed where the parser had not reached', async () => { + const language = new Compartment() + const view = mount(DOC, { language, parsed: false }) + await settle() + view.dispatch({ + changes: { from: view.state.doc.length, insert: '\n\n> [!note]- Typed\n> typed body' }, + selection: { anchor: 0 } + }) + await settle() + parseLater(view, language) + await settle() + expect(folds(view)).toEqual([[3, 6]]) + }) + + it('does not fold a collapsed callout over the caret', async () => { + const language = new Compartment() + const view = mount(`${DOC}\n\n> [!note]- Second\n> second body`, { language, parsed: false, caretLine: 4 }) + await settle() + parseLater(view, language) + await settle() + expect(folds(view)).toEqual([[16, 17]]) + }) + it('folds a callout from its title line with the fold keys, the whole body', async () => { const view = mount() await settle() diff --git a/packages/app-core/src/lib/cm-callout-fold.ts b/packages/app-core/src/lib/cm-callout-fold.ts index 253de367d..a03121b38 100644 --- a/packages/app-core/src/lib/cm-callout-fold.ts +++ b/packages/app-core/src/lib/cm-callout-fold.ts @@ -5,13 +5,17 @@ * The fold range is the callout's whole blockquote below its title line, so * the chevron, the fold keys and Fold All close the same span and any of them * reopens what another closed. Collapsed callouts fold once each time a note - * opens in the editor. That rides a state field which a note swap resets (a + * opens in the editor, each once the parser has read past its end: on a long + * note it reaches the rest only as the reader scrolls or jumps there. How far + * it has reached rides a state field which a note swap resets (a * whole-document replace under `noteEditingSync`) and which outlives the * live-preview compartment's reconfigures: a theme switch must not close - * again what the reader opened. + * again what the reader opened. A callout the reader edited, or that holds + * the caret, is not folded. */ import { ensureSyntaxTree, foldEffect, syntaxTree, unfoldEffect } from '@codemirror/language' import { + type ChangeDesc, type EditorState, type Extension, StateEffect, @@ -82,19 +86,15 @@ export function enclosingFoldableCallout(state: EditorState, pos: number): Callo return null } -/** Fold ranges of every callout with a marker, or only the collapsed ones. - * Pass a fully parsed tree (ensureSyntaxTree) to reach past the viewport. */ -export function foldableCalloutRanges( - state: EditorState, - tree: Tree = syntaxTree(state), - only?: CalloutFoldMarker -): FoldRange[] { +/** Fold ranges of every callout with a marker. Pass a fully parsed tree + * (ensureSyntaxTree) to reach past the viewport. */ +export function foldableCalloutRanges(state: EditorState, tree: Tree = syntaxTree(state)): FoldRange[] { const ranges: FoldRange[] = [] tree.iterate({ enter: (ref) => { if (ref.name !== 'Blockquote') return undefined const callout = calloutOf(state, ref.node) - if (callout?.marker && callout.range && (!only || callout.marker === only)) { + if (callout?.marker && callout.range) { ranges.push(callout.range) } return undefined @@ -152,7 +152,27 @@ export class CalloutFoldChevron extends WidgetType { } } -const markCalloutDefaultsApplied = StateEffect.define() +interface Span { + from: number + to: number +} + +/** The part of an opened note whose collapsed callouts have not been folded + * yet, from `from` to the end. */ +interface PendingCallouts { + from: number + /** What changed in it since the note opened: a callout touching any of it + * stays open, as typing a marker does not fold. */ + edited: readonly Span[] + /** Whether the first pass, which parses ahead, has run. */ + started: boolean +} + +const calloutDefaultsReached = StateEffect.define({ + map: (pos, mapping) => mapping.mapPos(pos) +}) + +const unopened = (): PendingCallouts => ({ from: 0, edited: [], started: false }) function replacesWholeDocument(tr: Transaction): boolean { let whole = false @@ -162,19 +182,109 @@ function replacesWholeDocument(tr: Transaction): boolean { return whole } -/** Whether this document's collapsed callouts have been folded yet. */ -const calloutDefaultsApplied = StateField.define({ - create: () => false, - update(applied, tr) { - if (tr.effects.some((effect) => effect.is(markCalloutDefaultsApplied))) return true - // Another note swapped in: its collapsed callouts start folded too. - if (applied && tr.docChanged && tr.annotation(noteEditingSync) && replacesWholeDocument(tr)) { - return false +function mapPending(pending: PendingCallouts, changes: ChangeDesc): PendingCallouts { + const from = changes.mapPos(pending.from) + const edited = pending.edited.map((span) => ({ + from: changes.mapPos(span.from, -1), + to: changes.mapPos(span.to, 1) + })) + changes.iterChangedRanges((_fromA, _toA, fromB, toB) => { + if (toB >= from) edited.push({ from: fromB, to: toB }) + }) + edited.sort((a, b) => a.from - b.from) + const merged: Span[] = [] + for (const span of edited) { + const last = merged[merged.length - 1] + if (last && span.from <= last.to) last.to = Math.max(last.to, span.to) + else merged.push(span) + } + return { ...pending, from, edited: merged } +} + +/** Null once the parser has reached the end of the note. */ +const pendingCallouts = StateField.define({ + create: unopened, + update(pending, tr) { + if (tr.docChanged) { + // Another note swapped in: its collapsed callouts start folded too. + if (tr.annotation(noteEditingSync) && replacesWholeDocument(tr)) pending = unopened() + else if (pending) pending = mapPending(pending, tr.changes) + } + for (const effect of tr.effects) { + if (!pending || !effect.is(calloutDefaultsReached)) continue + const from = Math.max(pending.from, effect.value) + pending = + from >= tr.state.doc.length + ? null + : { from, edited: pending.edited.filter((span) => span.to >= from), started: true } } - return applied + return pending } }) +/** Where the last node the parser has produced starts, -1 before it has + * produced any. The tree's length does not say how far the parse got: the + * note grammar's tree always spans the whole note (cm-markdown-language.ts). */ +function lastNodeStart(tree: Tree): number { + let node = tree.topNode.lastChild + if (!node) return -1 + for (let child = node.lastChild; child; child = child.lastChild) node = child + return node.type.isTop ? -1 : node.from +} + +/** Whether the parser has read past the block ending at `to`: it has produced + * a node after it, or only blank lines follow it. Until then the block may go + * on, and a parse that stopped early ends whatever is still open there. */ +function readPast(state: EditorState, to: number, lastStart: number): boolean { + if (lastStart > to) return true + const rest = state.doc.iterRange(to) + while (!rest.next().done) if (/\S/.test(rest.value)) return false + return true +} + +/** Fold the collapsed callouts the parser has reached since the last pass. */ +function foldReachedCallouts(view: EditorView): void { + const { state } = view + const pending = state.field(pendingCallouts, false) + if (!pending) return + const tree = (!pending.started && ensureSyntaxTree(state, state.doc.length, 200)) || syntaxTree(state) + const lastStart = lastNodeStart(tree) + // A callout the parser has yet to produce starts on a later line. + const read = lastStart < 0 ? 0 : state.doc.lineAt(lastStart).to + 1 + if (pending.started && read <= pending.from) return + const caret = state.selection.main.head + const folds: StateEffect[] = [] + let reached = read + let waiting = false + tree.iterate({ + from: pending.from, + enter: (ref) => { + if (waiting) return false + if (ref.name !== 'Blockquote' || ref.from < pending.from) return undefined + const callout = calloutOf(state, ref.node) + if (callout?.marker !== '-') return undefined + // It and everything after it wait for the parser to read past its end. + if (!readPast(state, ref.to, lastStart)) { + reached = ref.from + waiting = true + return false + } + const { range } = callout + if ( + range && + !(caret > range.from && caret <= range.to) && + !pending.edited.some((span) => span.from <= ref.to && span.to >= ref.from) && + !foldedExactly(state, range) + ) { + folds.push(foldEffect.of(range)) + } + return undefined + } + }) + if (pending.started && folds.length === 0 && reached === pending.from) return + view.dispatch({ effects: [...folds, calloutDefaultsReached.of(reached)] }) +} + const applyCalloutDefaults = ViewPlugin.fromClass( class { private scheduled = false @@ -185,7 +295,12 @@ const applyCalloutDefaults = ViewPlugin.fromClass( } update(update: ViewUpdate): void { - this.check(update.view) + if ( + syntaxTree(update.startState) !== syntaxTree(update.state) || + update.startState.field(pendingCallouts, false) !== update.state.field(pendingCallouts, false) + ) { + this.check(update.view) + } } destroy(): void { @@ -193,18 +308,12 @@ const applyCalloutDefaults = ViewPlugin.fromClass( } private check(view: EditorView): void { - if (this.scheduled || view.state.field(calloutDefaultsApplied, false) !== false) return + if (this.scheduled || !view.state.field(pendingCallouts, false)) return this.scheduled = true // Not from inside a view update: CodeMirror refuses a dispatch there. queueMicrotask(() => { this.scheduled = false - const { state } = view - if (this.destroyed || state.field(calloutDefaultsApplied, false) !== false) return - const tree = ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state) - const folds = foldableCalloutRanges(state, tree, '-') - .filter((range) => !foldedExactly(state, range)) - .map((range) => foldEffect.of(range)) - view.dispatch({ effects: [...folds, markCalloutDefaultsApplied.of(null)] }) + if (!this.destroyed) foldReachedCallouts(view) }) } } @@ -213,5 +322,5 @@ const applyCalloutDefaults = ViewPlugin.fromClass( /** Collapsed callouts start folded when a note opens. Live preview only, with * the chevrons in `cm-wysiwyg-blocks.ts`; folding itself is `codeFolding()`. */ export function calloutFolding(): Extension { - return [calloutDefaultsApplied, applyCalloutDefaults] + return [pendingCallouts, applyCalloutDefaults] } diff --git a/packages/app-core/src/lib/cm-code-block-flair.test.ts b/packages/app-core/src/lib/cm-code-block-flair.test.ts new file mode 100644 index 000000000..bcb361924 --- /dev/null +++ b/packages/app-core/src/lib/cm-code-block-flair.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { codeBlockFlairPlugin } from './cm-code-block-flair' + +describe('codeBlockFlairPlugin', () => { + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = 'Intro\n\n```js\nconst a = 1\n```\n\nEnd.' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), codeBlockFlairPlugin] }) + }) + expect(view.dom.querySelector('.cm-code-flair')).toBeNull() + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + const flairs = Array.from(view.dom.querySelectorAll('.cm-code-flair')) + expect(flairs.map((flair) => flair.textContent)).toEqual(['js']) + view.destroy() + parent.remove() + }) +}) diff --git a/packages/app-core/src/lib/cm-code-block-flair.ts b/packages/app-core/src/lib/cm-code-block-flair.ts index ed63027e2..925f68a4c 100644 --- a/packages/app-core/src/lib/cm-code-block-flair.ts +++ b/packages/app-core/src/lib/cm-code-block-flair.ts @@ -140,7 +140,13 @@ export const codeBlockFlairPlugin = ViewPlugin.fromClass( } update(update: ViewUpdate): void { - if (update.docChanged || update.viewportChanged) { + if ( + update.docChanged || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/cm-code-block-font.test.ts b/packages/app-core/src/lib/cm-code-block-font.test.ts new file mode 100644 index 000000000..2ee6c8371 --- /dev/null +++ b/packages/app-core/src/lib/cm-code-block-font.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment jsdom + +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { forceParsing } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { describe, expect, it } from 'vitest' +import { codeBlockFontPlugin } from './cm-code-block-font' + +describe('codeBlockFontPlugin', () => { + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = 'Intro\n\n```js\nconst a = 1\n```\n\nEnd.' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), codeBlockFontPlugin] }) + }) + expect(view.dom.querySelector('.cm-code-block-line')).toBeNull() + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + const lines = Array.from(view.dom.querySelectorAll('.cm-code-block-line')) + expect(lines.map((line) => line.textContent)).toEqual(['```js', 'const a = 1', '```']) + expect(view.dom.querySelector('.cm-code-block-begin')?.textContent).toBe('```js') + expect(view.dom.querySelector('.cm-code-block-end')?.textContent).toBe('```') + view.destroy() + parent.remove() + }) +}) diff --git a/packages/app-core/src/lib/cm-code-block-font.ts b/packages/app-core/src/lib/cm-code-block-font.ts index a5657114a..023bd0c24 100644 --- a/packages/app-core/src/lib/cm-code-block-font.ts +++ b/packages/app-core/src/lib/cm-code-block-font.ts @@ -78,7 +78,13 @@ export const codeBlockFontPlugin = ViewPlugin.fromClass( } update(update: ViewUpdate): void { - if (update.docChanged || update.viewportChanged) { + if ( + update.docChanged || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/cm-hashtags.test.ts b/packages/app-core/src/lib/cm-hashtags.test.ts index 2e3e1ae0a..fe3e30537 100644 --- a/packages/app-core/src/lib/cm-hashtags.test.ts +++ b/packages/app-core/src/lib/cm-hashtags.test.ts @@ -2,7 +2,7 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { forceParsing } from '@codemirror/language' -import { EditorState } from '@codemirror/state' +import { Compartment, EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { describe, expect, it } from 'vitest' import { hashtagExtension } from './cm-hashtags' @@ -45,4 +45,27 @@ describe('hashtagPlugin', () => { expect(tags).toEqual(['#real']) view.destroy() }) + + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = '```c\n#include \n```\n\n#real' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), hashtagExtension] }) + }) + const tags = (): Array => + Array.from(view.dom.querySelectorAll('.cm-hashtag')).map((e) => e.textContent) + // No tree yet, so nothing says the fence is code. + expect(tags()).toEqual(['#include', '#real']) + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + expect(tags()).toEqual(['#real']) + view.destroy() + parent.remove() + }) }) diff --git a/packages/app-core/src/lib/cm-hashtags.ts b/packages/app-core/src/lib/cm-hashtags.ts index dc8f5bb2d..7cf8cc616 100644 --- a/packages/app-core/src/lib/cm-hashtags.ts +++ b/packages/app-core/src/lib/cm-hashtags.ts @@ -79,7 +79,13 @@ const hashtagPlugin = ViewPlugin.fromClass( this.decorations = buildDecorations(view) } update(update: ViewUpdate): void { - if (update.docChanged || update.viewportChanged) { + if ( + update.docChanged || + update.viewportChanged || + // A parse cut short by its time budget finishes in a later update + // that changes nothing else. + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { this.decorations = buildDecorations(update.view) } } diff --git a/packages/app-core/src/lib/cm-heading-fold.test.ts b/packages/app-core/src/lib/cm-heading-fold.test.ts index 1427cdfb9..cdbeea5bb 100644 --- a/packages/app-core/src/lib/cm-heading-fold.test.ts +++ b/packages/app-core/src/lib/cm-heading-fold.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { describe, it, expect } from 'vitest' -import { EditorState } from '@codemirror/state' -import { foldedRanges, foldService, unfoldEffect } from '@codemirror/language' +import { Compartment, EditorState } from '@codemirror/state' +import { foldEffect, foldedRanges, foldService, forceParsing, unfoldEffect } from '@codemirror/language' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { EditorView } from '@codemirror/view' import { foldAllOutline, foldAtCursor, headingFolding, unfoldAtCursor } from './cm-heading-fold' @@ -106,6 +106,31 @@ describe('heading folding', () => { view.destroy() parent.remove() }) + + it('redraws when only the syntax tree changes, as when a cut parse finishes', () => { + // Swapping the language in changes the tree and nothing else: no edit, no + // selection, no viewport. A parse that finishes after its time budget ran + // out reaches the plugin the same way. + const language = new Compartment() + const doc = '```bash\n# a comment\n```\n\n# Real\n\nbody' + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ doc, extensions: [language.of([]), headingFolding()] }) + }) + const headingLines = (): Array => + Array.from(parent.querySelectorAll('.cm-heading-line')).map((line) => line.textContent) + // No tree yet, so nothing says the fence is code. + expect(headingLines()).toHaveLength(2) + view.dispatch({ effects: language.reconfigure(markdown({ base: markdownLanguage })) }) + forceParsing(view, doc.length, 5000) + expect(headingLines()).toHaveLength(1) + expect(headingLines()[0]).toContain('# Real') + + view.destroy() + parent.remove() + }) }) describe('list item folding (#848)', () => { @@ -139,6 +164,9 @@ describe('list item folding (#848)', () => { parent, state: state.update({ selection: { anchor: state.doc.line(line).from } }).state }) + // Folds read the syntax tree, and on a cold, loaded worker the first parse + // can run out of its time budget before it reaches the list. + forceParsing(view, text.length, 5000) return { view, done: () => { @@ -247,3 +275,162 @@ describe('list item folding (#848)', () => { done() }) }) + +describe('heading arrows', () => { + const doc = [ + '# Shapes', // 1 + '', // 2 + 'Intro', // 3 + '', // 4 + '## Empty', // 5: a heading of the same level follows + '## Has body', // 6 + '', // 7 + 'text', // 8 + '', // 9 + '### Deep', // 10 + '#### Deeper', // 11 + '##### Five', // 12 + '###### Six', // 13 + 'six body', // 14 + '## Same level next', // 15 + '## Followed', // 16 + '', // 17 + '```bash', // 18 + '# comment in fence', // 19 + '## another in fence', // 20 + '```', // 21 + '', // 22 + ' # indented code', // 23 + '', // 24 + '#nospace', // 25 + '### Three then two', // 26: a higher level follows + '## Two', // 27 + 'x', // 28 + '# Second H1', // 29 + '## Last line heading' // 30 + ].join('\n') + + /** Lines each heading's fold hides, first to last. */ + const sections: Record = { + 1: [2, 28], + 6: [7, 14], + 10: [11, 14], + 11: [12, 14], + 12: [13, 14], + 13: [14, 14], + 16: [17, 26], + 27: [28, 28], + 29: [30, 30] + } + const levels: Record = { 1: 1, 6: 2, 10: 3, 11: 4, 12: 5, 13: 6, 16: 2, 27: 2, 29: 1 } + + function mount(text: string): { view: EditorView; done: () => void } { + const parent = document.createElement('div') + document.body.append(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc: text, + extensions: [markdown({ base: markdownLanguage }), headingFolding({ showLevelLabels: true })] + }) + }) + // Only the tree knows the `#` lines in the fence and the indented block are code. + forceParsing(view, text.length, 5000) + return { + view, + done: () => { + view.destroy() + parent.remove() + } + } + } + + /** `