From 82da15dd28e619ebc4a11d6f8714361803632810 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 17 Aug 2026 22:39:35 +0200 Subject: [PATCH] feat(notes): add a Details tab to the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar hosts the tabs the Files sidebar contributes but says nothing about the note itself. This adds a tab of Notes' own with the note's category, a reading estimate, its path, and a read-only marker when the note cannot be written. Size, modification date and owner are already in the sidebar header and are not repeated here. Most of it is free: everything but the reading estimate is on the note in the store. The estimate is not free, and that shapes the design. The note list payload excludes `content`, so a note that has never been opened has none client-side. Rather than fetching every body up front, the tab pulls the one note it needs and only once its tab is actually selected — opening the sidebar to share a note does not drag its body down with it. Until then the row shows a placeholder, and a body that cannot be read shows a dash rather than disappearing. fetchNote() only rejects on a missing note and reports everything else itself, so the content is what says whether the fetch worked. The body is fetched for one note at a time, and which note that is guards the result: the sidebar can be sent to another note while a fetch is still on its way — from a row of the list, or by the list navigating — and the answer to the first request must then neither be reported for the second note nor keep its own fetch from starting. Estimating a reading time means counting words without the markup, otherwise '#' and '**' inflate the number. noteStats.js strips the obvious things — fenced code, image syntax, link targets while keeping labels, heading, quote and list markers, setext underlines, emphasis — and leaves the rest alone. A full parse would be much more code for a number nobody checks to the decimal. Emphasis is matched within a line, both because it cannot span a blank line and so an unclosed marker does not scan the rest of the note for a partner. Deliberately no word or character count. The Text app shows both for the note open in rich mode, computed from its parsed document rather than from the markdown, so a second pair of numbers here would differ from those and invite the question of which is right. A reading estimate is the one figure Text does not offer. The tab is not one from the Files registry, so it renders outside the loop over the allow-listed tabs, with an order that puts it after them: Sharing stays the first and default tab. Its icon is outlined until its tab is active, following what the sharing icon already does, and its rows are inset to the same 8px the Files tabs put their own content at. resolvedTab() clamps over both kinds of tab now, so the fallback can land on this one when it is the only tab there is. "Details" is offered in the note's action menu next to Share and Versions. The sidebar also follows the note the list navigates to. It was opened for one note and then stayed on it, so picking another note left it showing the previous note's sharing, versions and details, which reads as stale data rather than as a sidebar that belongs to a different note. It now watches the note id in the route and re-opens itself for whatever the list navigates to, keeping the tab that is in view, the way the Files sidebar does. Note selection is read from the route rather than from the store: setSelectedNote() is never called anywhere, so store.notes.selectedNote is always null. Reusing onSidebarOpen() means the switch goes through the same path as opening the sidebar in the first place, so the DAV node, the parent folder and the note body are all reloaded for the new note rather than half-updated. Opening no longer skips the node context when no Files tab is registered, since the Details tab needs it. Two of the values the tab shows would otherwise go stale. updateNote() copies the note attributes only, so internalPath and readonly — reported by the server but not among them — were never refreshed. And the category endpoint answers with the category string alone, so nothing carried the new path back after a move; setCategory() refetches the note now, and a refetch that does not arrive is not reported as the category update having failed. Assisted-by: Claude Code:claude-opus-5[1m] Assisted-by: Claude Code:claude-fable-5 Co-Authored-By: Andy Scherzinger Signed-off-by: Frank Karlitschek Signed-off-by: Andy Scherzinger --- playwright/e2e/note-sidebar.spec.ts | 124 +++++++++++++++++++++++++- src/NotesService.js | 4 + src/components/NoteInfo.vue | 130 ++++++++++++++++++++++++++++ src/components/NoteItem.vue | 22 +++-- src/components/NoteSidebar.vue | 108 +++++++++++++++++++++-- src/noteStats.js | 60 +++++++++++++ src/stores/notes.js | 12 +++ src/tests/noteStats.spec.js | 64 ++++++++++++++ src/tests/notesStore.spec.js | 44 ++++++++++ 9 files changed, 548 insertions(+), 20 deletions(-) create mode 100644 src/components/NoteInfo.vue create mode 100644 src/noteStats.js create mode 100644 src/tests/noteStats.spec.js create mode 100644 src/tests/notesStore.spec.js diff --git a/playwright/e2e/note-sidebar.spec.ts b/playwright/e2e/note-sidebar.spec.ts index cae90f41b..8f83cf082 100644 --- a/playwright/e2e/note-sidebar.spec.ts +++ b/playwright/e2e/note-sidebar.spec.ts @@ -7,7 +7,7 @@ import type { Locator, Page, TestInfo } from '@playwright/test' import { expect, test } from '@playwright/test' import { login } from '../support/login.ts' -import { createNote, createNoteRevisions, newNoteButton, openNoteActions, setNoteMode, uniqueTitle } from '../support/note.ts' +import { createNote, createNoteRevisions, newNoteButton, noteRow, openNoteActions, setNoteMode, uniqueTitle } from '../support/note.ts' import { NoteEditor } from '../support/sections/NoteEditor.ts' interface EventBusWindow extends Window { @@ -43,6 +43,25 @@ function subname(page: Page): Locator { return sidebar(page).locator('.app-sidebar-header__subname') } +function detailRow(page: Page, label: string): Locator { + return sidebar(page).locator('.note-info__row') + .filter({ has: page.getByText(label, { exact: true }) }) + .locator('.note-info__value') +} + +/** + * The store only holds a note's body once it has been saved, and the reading + * estimate counts what the store holds, so the tests wait for the write. + */ +async function createSavedNote(page: Page, content: string): Promise { + const saved = page.waitForResponse((response) => /\/notes\/\d+$/.test(response.url()) + && response.request().method() === 'PUT') + const noteId = await createNote(page, content) + await saved + + return noteId +} + async function openSidebarFromActions(page: Page, noteId: number, action: string): Promise { await openNoteActions(page, noteId) await page.getByRole('menuitem', { name: action, exact: true }).click() @@ -171,14 +190,15 @@ test.describe('Note sidebar', () => { await expect(subname(page).locator('.user-bubble__content')).toContainText('admin') }) - test('renders the allow-listed tabs only', async ({ page }, testInfo: TestInfo) => { + test('renders the allow-listed tabs and the details one only', async ({ page }, testInfo: TestInfo) => { const noteId = await createNote(page, uniqueTitle('sidebar-tabs', testInfo)) await openSidebarFromActions(page, noteId, 'Share') await expect(tabButton(page, 'sharing')).toBeVisible() await expect(tabButton(page, 'files_versions')).toBeVisible() - await expect(sidebar(page).getByRole('tab')).toHaveCount(2) + await expect(tabButton(page, 'notes-info')).toBeVisible() + await expect(sidebar(page).getByRole('tab')).toHaveCount(3) }) test('switches between the sharing and versions tabs', async ({ page }, testInfo: TestInfo) => { @@ -242,6 +262,104 @@ test.describe('Note sidebar', () => { await expect(page.getByText('Internal shares')).toBeVisible({ timeout: 15000 }) }) + test('opens the details tab from the actions menu', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-details', testInfo)) + + await openSidebarFromActions(page, noteId, 'Details') + + await expect(tabButton(page, 'notes-info')).toHaveAttribute('aria-selected', 'true') + await expect(detailRow(page, 'Category')).toHaveText('Uncategorized') + await expect(detailRow(page, 'Path')).toContainText('.md') + }) + + test('fills the details icon only while its tab is active', async ({ page }, testInfo: TestInfo) => { + const noteId = await createNote(page, uniqueTitle('sidebar-details-icon', testInfo)) + + await openSidebarFromActions(page, noteId, 'Details') + + await expect(tabButton(page, 'notes-info').locator('.information-icon')).toBeVisible() + await expect(tabButton(page, 'notes-info').locator('.information-outline-icon')).toHaveCount(0) + + await tabButton(page, 'sharing').click() + + await expect(tabButton(page, 'notes-info').locator('.information-outline-icon')).toBeVisible() + await expect(tabButton(page, 'notes-info').locator('.information-icon')).toHaveCount(0) + }) + + test('estimates the reading time from the note body', async ({ page }, testInfo: TestInfo) => { + const noteId = await createSavedNote(page, `# ${uniqueTitle('sidebar-reading', testInfo)}\n\nfour plain words here`) + + await openSidebarFromActions(page, noteId, 'Details') + + await expect(detailRow(page, 'Reading time')).toHaveText('1 minute') + }) + + test('loads the body of a note that has never been opened', async ({ page }, testInfo: TestInfo) => { + const noteId = await createSavedNote(page, `# ${uniqueTitle('sidebar-body', testInfo)}\n\nfour plain words here`) + // a reload drops the body from the store, so the tab has to fetch it + await page.goto('/index.php/apps/notes/') + + await openSidebarFromActions(page, noteId, 'Share') + await tabButton(page, 'notes-info').click() + + await expect(detailRow(page, 'Reading time')).toHaveText('1 minute', { timeout: 15000 }) + }) + + test('loads the body of the note it moves to while another body is still on its way', async ({ page }, testInfo: TestInfo) => { + const held = await createSavedNote(page, uniqueTitle('sidebar-held', testInfo)) + const wanted = await createSavedNote(page, `# ${uniqueTitle('sidebar-wanted', testInfo)}\n\nfour plain words here`) + const opened = await createSavedNote(page, uniqueTitle('sidebar-opened', testInfo)) + + // a third note carries the route, so the editor loads neither of the two + // bodies the tab is after, and the reload drops them from the store + await page.goto(`/index.php/apps/notes/note/${opened}`) + + // keep the first body on its way while the sidebar is sent to the second + await page.route(`**/apps/notes/notes/${held}`, async (route) => { + if (route.request().method() !== 'GET') { + return route.continue() + } + await new Promise((resolve) => setTimeout(resolve, 5000)) + await route.continue() + }) + + await openSidebarFromActions(page, held, 'Details') + await openSidebarFromActions(page, wanted, 'Details') + + await expect(detailRow(page, 'Reading time')).toHaveText('1 minute', { timeout: 15000 }) + }) + + test('follows the note the list navigates to', async ({ page }, testInfo: TestInfo) => { + const first = await createSavedNote(page, uniqueTitle('sidebar-first', testInfo)) + const second = await createSavedNote(page, uniqueTitle('sidebar-second', testInfo)) + + await openSidebarFromActions(page, second, 'Details') + const shown = await detailRow(page, 'Path').textContent() + + await noteRow(page, first).getByRole('link').first().click() + + await expect(page).toHaveURL(new RegExp(`/note/${first}(\\?.*)?$`)) + await expect(detailRow(page, 'Path')).not.toHaveText(shown ?? '') + }) + + test('marks the reading time unavailable when the note body cannot be loaded', async ({ page }, testInfo: TestInfo) => { + const noteId = await createSavedNote(page, uniqueTitle('sidebar-unreadable', testInfo)) + + // a reload drops the body from the store, so the tab has to fetch it + await page.route( + `**/apps/notes/notes/${noteId}`, + (route) => route.request().method() === 'GET' ? route.abort() : route.continue(), + ) + await page.goto('/index.php/apps/notes/') + + await openSidebarFromActions(page, noteId, 'Details') + + const readingTime = detailRow(page, 'Reading time') + await expect(readingTime).toHaveText(/^—/) + await expect(readingTime.locator('.hidden-visually')) + .toHaveText('The note content could not be loaded.') + }) + // The editor's own actions menu only exists in the markdown editor; the rich // editor brings its own menu bar. test.describe('markdown editor', () => { diff --git a/src/NotesService.js b/src/NotesService.js index fee74d3c1..6cbec1f50 100644 --- a/src/NotesService.js +++ b/src/NotesService.js @@ -350,6 +350,10 @@ export function setCategory(noteId, category) { handleSyncError(t('notes', 'Updating the note\'s category has failed. Is the target directory writable?')) } store.notes.setNoteAttribute({ noteId, attribute: 'category', value: realCategory }) + // the endpoint answers with the category alone, but the file moves with + // it, so the new path has to come from a refetch, whose own failure + // fetchNote() has already reported + return fetchNote(noteId).catch(() => {}) }) .catch((err) => { logger.error('Updating the category for note has failed', { noteId, error: err }) diff --git a/src/components/NoteInfo.vue b/src/components/NoteInfo.vue new file mode 100644 index 000000000..4dc3af704 --- /dev/null +++ b/src/components/NoteInfo.vue @@ -0,0 +1,130 @@ + + + + + + + diff --git a/src/components/NoteItem.vue b/src/components/NoteItem.vue index e1940645d..e71221f96 100644 --- a/src/components/NoteItem.vue +++ b/src/components/NoteItem.vue @@ -35,20 +35,27 @@ {{ actionFavoriteText }} - + {{ t('notes', 'Share') }} - + {{ t('notes', 'Versions') }} + + + {{ t('notes', 'Details') }} + +