From fb55c57536808a098a88ecce0eb3e91a8b307fb5 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 7 May 2026 15:48:29 +0200 Subject: [PATCH 1/5] fix: show reference titles in breadcrumb --- docs/ABSTRACTIONS.md | 1 + docs/ARCHITECTURE.md | 2 +- src/components/BreadcrumbBar.test.tsx | 160 +++++++++++++++--- src/components/BreadcrumbBar.tsx | 57 ++++++- .../EditorContentLayout.test.tsx | 22 ++- .../editor-content/EditorContentLayout.tsx | 1 + 6 files changed, 216 insertions(+), 27 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index a5edf6cb..8b9abb95 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -297,6 +297,7 @@ The BlockNote body is the only title editing surface: - The first H1 is the canonical display title. - There is no separate title row above the editor, even when a note has no H1. - Notes without an H1 show the editor body and placeholder only. +- Legacy no-H1 notes whose display title differs from the filename show that title as read-only breadcrumb context beside the editable filename, so referenced notes remain identifiable without raw mode. - Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect. ### Sidebar Selection diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c70a951b..29228f49 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -208,7 +208,7 @@ flowchart TD - **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. - **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day. -- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. +- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs. - **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`. diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index c72ff8ac..36a4c3f3 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react' import { render, screen, fireEvent, act, within, waitFor } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' import { BreadcrumbBar } from './BreadcrumbBar' @@ -56,6 +57,52 @@ const defaultProps = { onToggleDiff: vi.fn(), } +type BreadcrumbBarRenderProps = Omit, 'entry'> + +function makeEntry(overrides: Partial = {}): VaultEntry { + return { ...baseEntry, ...overrides } +} + +function renderBreadcrumb( + entryOverrides: Partial = {}, + props: Partial = {}, +) { + const entry = makeEntry(entryOverrides) + return { + entry, + ...render(), + } +} + +function renderEditableFilenameBreadcrumb( + entryOverrides: Partial = {}, + props: Partial = {}, +) { + const onRenameFilename = vi.fn() + const result = renderBreadcrumb(entryOverrides, { ...props, onRenameFilename }) + return { ...result, onRenameFilename } +} + +function startFilenameRename() { + fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) + return screen.getByTestId('breadcrumb-filename-input') +} + +function expectDisplayTitleState( + entryOverrides: Partial, + expected: { displayTitle: string | null; filenameStem: string }, + props: Partial = {}, +) { + renderEditableFilenameBreadcrumb(entryOverrides, props) + + if (expected.displayTitle) { + expect(screen.getByTestId('breadcrumb-display-title')).toHaveTextContent(expected.displayTitle) + } else { + expect(screen.queryByTestId('breadcrumb-display-title')).not.toBeInTheDocument() + } + expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent(expected.filenameStem) +} + async function expectTooltip(trigger: HTMLElement, ...parts: string[]) { act(() => { fireEvent.focus(trigger) @@ -271,50 +318,121 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', }) describe('BreadcrumbBar — filename controls', () => { + it('shows a legacy display title while keeping the filename visible', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'ref-570.md', + hasH1: false, + }, + { displayTitle: 'Reference Planning Notes', filenameStem: 'ref-570' }, + ) + }) + + it('uses opened content when stale metadata marks a legacy note as H1-titled', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'ref-570.md', + hasH1: true, + }, + { displayTitle: 'Reference Planning Notes', filenameStem: 'ref-570' }, + { + content: '---\ntitle: Reference Planning Notes\ntype: Note\n---\n\nBody without an H1.', + }, + ) + }) + + it('keeps content-derived H1 notes focused on the filename breadcrumb', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'manual-filename.md', + hasH1: false, + }, + { displayTitle: null, filenameStem: 'manual-filename' }, + { + content: '---\ntitle: Reference Planning Notes\n---\n\n# Canonical H1\n\nBody.', + }, + ) + }) + + it('does not duplicate the display title when the filename already matches it', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'reference-planning-notes.md', + hasH1: false, + }, + { displayTitle: null, filenameStem: 'reference-planning-notes' }, + ) + }) + + it('does not duplicate the display title when the filename matches with spaces', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'Reference Planning Notes.md', + hasH1: false, + }, + { displayTitle: null, filenameStem: 'Reference Planning Notes' }, + ) + }) + + it('keeps H1-titled notes focused on the filename breadcrumb', () => { + expectDisplayTitleState( + { + title: 'Reference Planning Notes', + filename: 'manual-filename.md', + hasH1: true, + }, + { displayTitle: null, filenameStem: 'manual-filename' }, + ) + }) + it('shows the sync button when the filename diverges from the title slug', () => { - const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' } - render() + renderEditableFilenameBreadcrumb({ title: 'Fresh Title', filename: 'untitled-note-123.md' }) expect(screen.getByTestId('breadcrumb-sync-button')).toBeInTheDocument() }) it('hides the sync button when the filename already matches the title slug', () => { - const entry = { ...baseEntry, title: 'Test Note', filename: 'test-note.md' } - render() + renderEditableFilenameBreadcrumb({ title: 'Test Note', filename: 'test-note.md' }) expect(screen.queryByTestId('breadcrumb-sync-button')).not.toBeInTheDocument() }) it('clicking the sync button renames the file to the title slug', () => { - const onRenameFilename = vi.fn() - const entry = { ...baseEntry, title: 'Fresh Title', filename: 'untitled-note-123.md' } - render() + const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb({ + title: 'Fresh Title', + filename: 'untitled-note-123.md', + }) + fireEvent.click(screen.getByTestId('breadcrumb-sync-button')) + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'fresh-title') }) it('lets keyboard users press Enter on the filename to start editing', () => { - render() + renderEditableFilenameBreadcrumb() + fireEvent.keyDown(screen.getByTestId('breadcrumb-filename-trigger'), { key: 'Enter' }) + expect(screen.getByTestId('breadcrumb-filename-input')).toHaveValue('test') }) it('double-clicking the filename enters edit mode and Enter confirms the rename', () => { - const onRenameFilename = vi.fn() - render() + const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb() - fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) - const input = screen.getByTestId('breadcrumb-filename-input') + const input = startFilenameRename() fireEvent.change(input, { target: { value: 'renamed-file' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-file') + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'renamed-file') }) it('pressing Escape while editing cancels the inline rename', () => { - const onRenameFilename = vi.fn() - render() + const { onRenameFilename } = renderEditableFilenameBreadcrumb() - fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) - const input = screen.getByTestId('breadcrumb-filename-input') + const input = startFilenameRename() fireEvent.change(input, { target: { value: 'renamed-file' } }) fireEvent.keyDown(input, { key: 'Escape' }) @@ -323,15 +441,13 @@ describe('BreadcrumbBar — filename controls', () => { }) it('blur confirms the inline rename when the value changed', () => { - const onRenameFilename = vi.fn() - render() + const { entry, onRenameFilename } = renderEditableFilenameBreadcrumb() - fireEvent.doubleClick(screen.getByTestId('breadcrumb-filename-trigger')) - const input = screen.getByTestId('breadcrumb-filename-input') + const input = startFilenameRename() fireEvent.change(input, { target: { value: 'renamed-on-blur' } }) fireEvent.blur(input) - expect(onRenameFilename).toHaveBeenCalledWith(baseEntry.path, 'renamed-on-blur') + expect(onRenameFilename).toHaveBeenCalledWith(entry.path, 'renamed-on-blur') }) }) diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 6495f760..b6e986ed 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -3,6 +3,7 @@ import type { NoteWidthMode, VaultEntry } from '../types' import { cn } from '@/lib/utils' import { translate, type AppLocale } from '../lib/i18n' import { APP_COMMAND_IDS, formatShortcutDisplay, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog' +import { extractFrontmatterTitleFromContent, extractH1TitleFromContent } from '../utils/noteTitle' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip' @@ -67,6 +68,7 @@ interface BreadcrumbBarProps { barRef?: React.Ref locale?: AppLocale loadingTitle?: boolean + content?: string | null } const BREADCRUMB_ICON_CLASS = 'size-[16px]' @@ -579,6 +581,35 @@ function deriveSyncStem(entry: VaultEntry): string | null { return expectedStem } +interface BreadcrumbDisplayTitleState { + hasH1: boolean + title: string +} + +function deriveContentDisplayTitleState(content?: string | null): BreadcrumbDisplayTitleState | null { + if (typeof content !== 'string') return null + const h1Title = extractH1TitleFromContent(content) + if (h1Title) return { title: h1Title, hasH1: true } + + const frontmatterTitle = extractFrontmatterTitleFromContent(content) + return frontmatterTitle ? { title: frontmatterTitle, hasH1: false } : null +} + +function deriveEntryDisplayTitleState(entry: VaultEntry): BreadcrumbDisplayTitleState { + return { + title: entry.title.trim(), + hasH1: entry.hasH1, + } +} + +function deriveBreadcrumbDisplayTitle(entry: VaultEntry, filenameStem: string, content?: string | null): string | null { + const displayState = deriveContentDisplayTitleState(content) ?? deriveEntryDisplayTitleState(entry) + const displayTitle = displayState.title.trim() + if (!displayTitle || displayState.hasH1) return null + if (slugify(displayTitle) === slugify(filenameStem)) return null + return displayTitle +} + function FilenameInput({ inputRef, draftStem, @@ -669,6 +700,7 @@ function SyncFilenameButton({ } function FilenameDisplay({ + content, entry, filenameStem, syncStem, @@ -676,6 +708,7 @@ function FilenameDisplay({ onRenameFilename, onStartEditing, }: { + content?: string | null entry: VaultEntry filenameStem: string syncStem: string | null @@ -683,15 +716,29 @@ function FilenameDisplay({ onRenameFilename?: (path: string, newFilenameStem: string) => void onStartEditing: () => void }) { + const displayTitle = deriveBreadcrumbDisplayTitle(entry, filenameStem, content) + return (
+ {displayTitle && ( + <> + + {displayTitle} + + + + )}
) } -function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick) { +function FilenameCrumb({ content, entry, locale = 'en', onRenameFilename }: Pick) { const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename]) const syncStem = useMemo(() => deriveSyncStem(entry), [entry]) const [isEditing, setIsEditing] = useState(false) @@ -737,6 +784,7 @@ function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick) { +}: Pick) { const typeLabel = entry.isA ?? 'Note' return (
@@ -954,13 +1003,14 @@ function BreadcrumbTitle({
{loadingTitle ? - : } + : }
) } export const BreadcrumbBar = memo(function BreadcrumbBar({ + content, entry, barRef, locale = 'en', @@ -990,6 +1040,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({ >
({ - BreadcrumbBar: ({ noteWidth }: { noteWidth?: string }) =>
, + BreadcrumbBar: ({ content, noteWidth }: { content?: string; noteWidth?: string }) => ( +
+ ), })) vi.mock('../ArchivedNoteBanner', () => ({ @@ -109,6 +111,24 @@ describe('EditorContentLayout', () => { expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-width', 'wide') }) + it('passes the active note content into the breadcrumb', () => { + render() + + expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute( + 'data-content', + '---\ntitle: Reference Planning Notes\n---\n\nBody', + ) + }) + it('keeps raw mode out of the rich-editor content wrapper', () => { render( Date: Thu, 7 May 2026 16:29:11 +0200 Subject: [PATCH 2/5] fix: avoid mermaid html label clipping --- src/components/MermaidDiagram.test.tsx | 5 ++++- src/components/MermaidDiagram.tsx | 1 + tests/smoke/mermaid-diagram-notes.spec.ts | 17 ++++++++++++----- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/MermaidDiagram.test.tsx b/src/components/MermaidDiagram.test.tsx index 2529e3ea..a4346ab3 100644 --- a/src/components/MermaidDiagram.test.tsx +++ b/src/components/MermaidDiagram.test.tsx @@ -32,7 +32,10 @@ describe('MermaidDiagram', () => { expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull() }) expect(mermaidMock.render).toHaveBeenCalledWith(expect.stringMatching(/^tolaria-mermaid-/), 'flowchart LR\nA --> B') - expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'default' })) + expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({ + htmlLabels: false, + theme: 'default', + })) }) it('opens the rendered SVG in a lightbox', async () => { diff --git a/src/components/MermaidDiagram.tsx b/src/components/MermaidDiagram.tsx index 862888b2..c1078821 100644 --- a/src/components/MermaidDiagram.tsx +++ b/src/components/MermaidDiagram.tsx @@ -44,6 +44,7 @@ function initializeMermaid(mermaid: MermaidApi) { mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', + htmlLabels: false, theme: 'default', themeVariables: { fontFamily: 'ui-sans-serif, system-ui, sans-serif', diff --git a/tests/smoke/mermaid-diagram-notes.spec.ts b/tests/smoke/mermaid-diagram-notes.spec.ts index 2e1fefb9..cc9cbbef 100644 --- a/tests/smoke/mermaid-diagram-notes.spec.ts +++ b/tests/smoke/mermaid-diagram-notes.spec.ts @@ -208,6 +208,10 @@ async function computedCss(locator: Locator, property: 'color' | 'fill' | 'strok ), property) } +function mermaidNodeLabel(node: Locator): Locator { + return node.locator('.nodeLabel, text').first() +} + async function readStyleNonce(svg: Locator): Promise { return svg.locator('style').first().evaluate((style) => { const nonce = (style as HTMLElement).nonce @@ -218,7 +222,7 @@ async function readStyleNonce(svg: Locator): Promise { async function labelFitsNode(node: Locator): Promise { return node.evaluate((element) => { const shape = element.querySelector('rect, polygon, circle, ellipse, path')! - const label = element.querySelector('.nodeLabel')! + const label = element.querySelector('.nodeLabel, text')! const shapeBox = shape.getBoundingClientRect() const labelBox = label.getBoundingClientRect() return [ @@ -232,14 +236,15 @@ async function readReportedDiagramMetrics(page: Page, diagramIndex: number) { const svg = mermaidSvg(page, diagramIndex) const scheduledNode = mermaidNode(page, diagramIndex, 'Other path') const scheduledShape = scheduledNode.locator('rect, polygon, circle, ellipse, path').first() - const scheduledLabel = scheduledNode.locator('.nodeLabel').first() + const scheduledLabel = mermaidNodeLabel(scheduledNode) return { connectorStroke: await computedCss(svg.locator('.flowchart-link').first(), 'stroke'), markerFill: await computedCss(svg.locator('marker path').first(), 'fill'), scheduledFill: await computedCss(scheduledShape, 'fill'), scheduledStroke: await computedCss(scheduledShape, 'stroke'), - scheduledTextColor: await computedCss(scheduledLabel, 'color'), + scheduledTextFill: await computedCss(scheduledLabel, 'fill'), + foreignObjectCount: await svg.locator('foreignObject').count(), labelsFit: [ await labelFitsNode(scheduledNode), await labelFitsNode(mermaidNode(page, diagramIndex, 'Contract path')), @@ -262,7 +267,8 @@ test('Mermaid diagrams render when opening saved notes directly', async ({ page markerFill: 'rgb(31, 58, 138)', scheduledFill: 'rgb(255, 244, 214)', scheduledStroke: 'rgb(122, 91, 0)', - scheduledTextColor: 'rgb(58, 44, 0)', + scheduledTextFill: 'rgb(58, 44, 0)', + foreignObjectCount: 0, labelsFit: true, styleNonce: RUNTIME_STYLE_NONCE, text: expect.stringContaining('Linked to a planned shift?'), @@ -320,7 +326,8 @@ ${SYSTEM_OVERVIEW_DIAGRAM} markerFill: 'rgb(31, 58, 138)', scheduledFill: 'rgb(255, 244, 214)', scheduledStroke: 'rgb(122, 91, 0)', - scheduledTextColor: 'rgb(58, 44, 0)', + scheduledTextFill: 'rgb(58, 44, 0)', + foreignObjectCount: 0, labelsFit: true, styleNonce: RUNTIME_STYLE_NONCE, text: expect.stringContaining('Linked to a planned shift?'), From f816c9a175a62b873a7706b055ce0e87bbfbe046 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Thu, 7 May 2026 16:40:49 +0200 Subject: [PATCH 3/5] Added Codacy badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a2df9f85..f8fa99fd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Build](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) +![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/fa3cdc28aefa460591c74aa262530708)](https://app.codacy.com/gh/refactoringhq/tolaria/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) # 💧 Tolaria From 3e01c87f6423be415e52c4afdb3757bfe45a8a63 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 7 May 2026 17:26:51 +0200 Subject: [PATCH 4/5] test: cover note history edit stability --- package.json | 2 +- tests/smoke/note-history-edit-loop.spec.ts | 79 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/smoke/note-history-edit-loop.spec.ts diff --git a/package.json b/package.json index 64bc8722..ef2ac426 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts", + "playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/note-history-edit-loop.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts", "playwright:regression": "playwright test tests/smoke/", "playwright:integration": "playwright test --config playwright.integration.config.ts", "test:coverage": "node scripts/run-vitest-coverage.mjs", diff --git a/tests/smoke/note-history-edit-loop.spec.ts b/tests/smoke/note-history-edit-loop.spec.ts new file mode 100644 index 00000000..59d4971e --- /dev/null +++ b/tests/smoke/note-history-edit-loop.spec.ts @@ -0,0 +1,79 @@ +import { test, expect, type Page } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVault, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' +import { sendShortcut } from './helpers' + +let tempVaultDir: string + +function isReactUpdateLoop(message: string): boolean { + return ( + message.includes('Maximum update depth') || + message.includes('React error #185') || + message.includes('#185') + ) +} + +function collectReactUpdateLoopErrors(page: Page): string[] { + const errors: string[] = [] + page.on('pageerror', (error) => { + if (isReactUpdateLoop(error.message)) errors.push(error.message) + }) + page.on('console', (message) => { + if (message.type() === 'error' && isReactUpdateLoop(message.text())) { + errors.push(message.text()) + } + }) + return errors +} + +async function openNote(page: Page, title: string) { + await page.getByTestId('note-list-container').getByText(title, { exact: true }).click() + await expect(page.getByRole('heading', { name: title, level: 1 })).toBeVisible({ timeout: 5_000 }) +} + +async function openPropertiesPanel(page: Page) { + const openProperties = page.getByRole('button', { name: 'Open the properties panel' }) + if (await openProperties.count()) await openProperties.click() + await expect(page.getByText('History')).toBeVisible({ timeout: 5_000 }) +} + +async function focusHeadingEnd(page: Page, titlePattern: RegExp | string) { + const heading = page.getByRole('heading', { name: titlePattern, level: 1 }) + await expect(heading).toBeVisible({ timeout: 5_000 }) + await heading.click() + await page.keyboard.press('End') +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) + await page.setViewportSize({ width: 1180, height: 760 }) +}) + +test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('opening note history then editing and saving the same note stays stable @smoke', async ({ page }) => { + const errors = collectReactUpdateLoopErrors(page) + const titleSuffix = ` History Loop ${Date.now()}` + const updatedTitle = `Alpha Project${titleSuffix}` + + await openNote(page, 'Alpha Project') + await openPropertiesPanel(page) + + await page.getByRole('button', { name: /a1b2c3d.*Update alpha-project with latest changes/i }).click() + await expect(page.getByText('Updated paragraph at commit a1b2c3d')).toBeVisible({ timeout: 5_000 }) + + await page.getByRole('button', { name: 'Return to the editor' }).click() + await focusHeadingEnd(page, 'Alpha Project') + await page.keyboard.type(titleSuffix) + await sendShortcut(page, 's', ['Control']) + + await expect(page.getByRole('heading', { name: updatedTitle, level: 1 })).toBeVisible({ timeout: 5_000 }) + expect(errors).toEqual([]) +}) From b9ce90399106a4d7f81dca2c8c5a4971b3a51e19 Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Thu, 7 May 2026 17:59:31 +0200 Subject: [PATCH 5/5] Updated readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8fa99fd..95ce6cfe 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/fa3cdc28aefa460591c74aa262530708)](https://app.codacy.com/gh/refactoringhq/tolaria/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) +![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) # 💧 Tolaria