From eacf146445e93853a079f87413c39de09f7f53dc Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 5 Oct 2026 11:54:53 -0500 Subject: [PATCH 1/5] fix(notes): a renamed or moved note keeps its creation date A note has two app-owned sidecars: its comments (`.zennotes/comments/.comments.json`) and its creation date (`.zennotes/note-metadata/.metadata.json`, written by desktop). The phone already moved the comments with a note but never the date, so a note renamed or moved here lost its creation date on every device, and Cloud sync carried the orphaned file around. Folder renames left the folder's date tree behind the same way. Found in Cloud device testing (2026-10-02). Renames and moves now carry note, comments and date together, and put all three back if any move fails, as desktop's note-sidecars.ts does: - A date already at the destination with no note beside it belongs to nobody and is discarded rather than inherited (ZenNotes#839). - Leftover comments at the destination are another note's discussion, so the move is refused with desktop's exact wording. - A folder rename onto a name that already has a date tree is refused. - Deleting a note or folder, and emptying the trash, remove the dates too; without that, a deleted folder's dates would block a later rename onto its name. Not changed: symlinked notes (the filesystem plugin cannot see links), and creating a note at a path with a stale date still inherits it. --- src/bridge/note-sidecars.test.ts | 228 +++++++++++++++++++++++++++++++ src/bridge/vault-core.ts | 18 ++- src/bridge/vault-fs.ts | 56 ++++++-- 3 files changed, 292 insertions(+), 10 deletions(-) create mode 100644 src/bridge/note-sidecars.test.ts diff --git a/src/bridge/note-sidecars.test.ts b/src/bridge/note-sidecars.test.ts new file mode 100644 index 0000000..301275a --- /dev/null +++ b/src/bridge/note-sidecars.test.ts @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +// A note's two app-owned sidecars, at the paths desktop writes them +// (apps/desktop/src/main/note-sidecars.ts and note-creation-metadata.ts). A +// rename or move that leaves either behind loses the comments and the +// creation date on every device, and Cloud sync carries the orphan around. +const comments = (rel: string): string => `.zennotes/comments/${rel}.comments.json` +const created = (rel: string): string => `.zennotes/note-metadata/${rel}.metadata.json` + +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: () => null } }) +const { MobileVault, DEFAULT_VAULT_SETTINGS } = await loadMobileModule([ + './src/bridge/vault-fs', './src/bridge/native-fs', '@zennotes/bridge-contract/ipc' +]) + +/** The NativeFs surface MobileVault uses. Like the providers, a rename never + * overwrites and a delete of a missing file fails. */ +class MemoryFs { + files = new Map() + directories = new Set() + failMove: ((from: string, to: string) => boolean) | null = null + async statVerified(path: string): Promise<'file' | 'directory' | null> { + if (this.files.has(path)) return 'file' + const inside = (key: string): boolean => key.startsWith(`${path}/`) + return this.directories.has(path) || [...this.files.keys()].some(inside) ? 'directory' : null + } + async exists(path: string): Promise { return await this.statVerified(path) !== null } + async mkdir(path: string): Promise { this.directories.add(path) } + async readText(path: string): Promise { + const body = this.files.get(path) + if (body === undefined) throw new Error(`File does not exist: ${path}`) + return body + } + async readTextOrNull(path: string): Promise { return this.files.get(path) ?? null } + async writeText(path: string, body: string): Promise { this.files.set(path, body) } + async deleteFile(path: string): Promise { + if (!this.files.delete(path)) throw new Error(`File does not exist: ${path}`) + } + async rmdir(path: string): Promise { + for (const key of [...this.files.keys()]) if (key === path || key.startsWith(`${path}/`)) this.files.delete(key) + for (const key of [...this.directories]) if (key === path || key.startsWith(`${path}/`)) this.directories.delete(key) + } + async rename(from: string, to: string): Promise { + if (this.failMove?.(from, to)) throw new Error('Provider refused move') + assert.ok(await this.exists(from), `nothing to move at ${from}`) + assert.equal(await this.exists(to), false, `a move must never overwrite ${to}`) + const moved = (key: string): boolean => key === from || key.startsWith(`${from}/`) + for (const [key, body] of [...this.files]) { + if (!moved(key)) continue + this.files.delete(key) + this.files.set(to + key.slice(from.length), body) + } + for (const key of [...this.directories]) { + if (!moved(key)) continue + this.directories.delete(key) + this.directories.add(to + key.slice(from.length)) + } + } +} + +const NOTE_BODY = '# One\n\nExact café 日本語.\n' +const COMMENTS_BODY = '{"version":1,"comments":[{"id":"c1","body":"keep me"}]}' +const CREATED_BODY = '{"version":1,"createdAt":1726000000000}\n' + +/** One note with both sidecars, in a vault whose system folders are remapped + * (inbox `Notes`, archive `Old`, trash `Bin`), or with the inbox at the root. */ +function fixture(rootMode = false) { + const fs = new MemoryFs() + const vault = new MobileVault('Fixture') + Object.assign(vault, { + fs, + settingsCache: { + ...structuredClone(DEFAULT_VAULT_SETTINGS), + primaryNotesLocation: rootMode ? 'root' : 'inbox', + systemFolderPaths: { inbox: 'Notes', archive: 'Old', trash: 'Bin', quick: 'Capture' } + }, + listNotes: async () => [], + invalidateMeta: () => {}, + metaForPath: async (path: string) => ({ path, title: path.split('/').pop() }) + }) + const primary = rootMode ? '' : 'Notes/' + const note = `${primary}Work/One.md` + fs.files.set(note, NOTE_BODY) + fs.files.set(comments(note), COMMENTS_BODY) + fs.files.set(created(note), CREATED_BODY) + return { fs, vault, primary, note, original: new Map(fs.files) } +} + +/** The note and both sidecars sit at `to`, byte for byte, and nothing at `from`. */ +function assertCarried(fs: MemoryFs, from: string, to: string): void { + assert.equal(fs.files.get(to), NOTE_BODY) + assert.equal(fs.files.get(comments(to)), COMMENTS_BODY) + assert.equal(fs.files.get(created(to)), CREATED_BODY) + for (const left of [from, comments(from), created(from)]) { + assert.equal(fs.files.has(left), false, `${left} was left behind`) + } +} + +for (const rootMode of [false, true]) { + test(`renaming a note carries its comments and creation date (root=${rootMode})`, async () => { + const s = fixture(rootMode) + const result = await s.vault.renameNote(s.note, 'Two') + assert.equal(result.path, `${s.primary}Work/Two.md`) + assertCarried(s.fs, s.note, result.path) + }) + + test(`archive, trash and restore carry the creation date through remapped folders (root=${rootMode})`, async () => { + const s = fixture(rootMode) + const archived = await s.vault.archiveNote(s.note) + assert.equal(archived.path, 'Old/Work/One.md') + assertCarried(s.fs, s.note, archived.path) + const unarchived = await s.vault.unarchiveNote(archived.path) + assert.equal(unarchived.path, s.note) + const trashed = await s.vault.moveToTrash(s.note) + assert.equal(trashed.path, 'Bin/Work/One.md') + assertCarried(s.fs, s.note, trashed.path) + const restored = await s.vault.restoreFromTrash(trashed.path) + assert.equal(restored.path, s.note) + assert.deepEqual(s.fs.files, s.original) + }) +} + +test('moving a note to another folder carries its creation date', async () => { + const s = fixture() + const result = await s.vault.moveNote(s.note, 'inbox', 'Elsewhere/Deeper') + assert.equal(result.path, 'Notes/Elsewhere/Deeper/One.md') + assertCarried(s.fs, s.note, result.path) +}) + +test('renaming a folder carries the creation dates of every note inside it', async () => { + const s = fixture() + s.fs.files.set('Notes/Work/Deep/Two.md', '# Two\n') + s.fs.files.set(created('Notes/Work/Deep/Two.md'), '{"version":1,"createdAt":1}\n') + await s.vault.renameFolder('inbox', 'Work', 'Moved') + assertCarried(s.fs, s.note, 'Notes/Moved/One.md') + assert.equal(s.fs.files.get(created('Notes/Moved/Deep/Two.md')), '{"version":1,"createdAt":1}\n') + assert.equal([...s.fs.files.keys()].some(key => key.includes('/Work/')), false) +}) + +test('a moved note with no sidecars moves alone and gains none', async () => { + const s = fixture() + s.fs.files.delete(comments(s.note)) + s.fs.files.delete(created(s.note)) + await s.vault.renameNote(s.note, 'Two') + assert.deepEqual([...s.fs.files.keys()], ['Notes/Work/Two.md']) +}) + +// ZenNotes #839: a creation date with no note beside it belongs to nobody +// (its note was moved or deleted outside ZenNotes). Desktop discards it, the +// way creating a note does, instead of handing it to the next note there. +test('a creation date left at the destination by a vanished note is discarded, not inherited', async () => { + const s = fixture() + s.fs.files.set(created('Notes/Work/Two.md'), '{"version":1,"createdAt":5}\n') + await s.vault.renameNote(s.note, 'Two') + assertCarried(s.fs, s.note, 'Notes/Work/Two.md') + + const bare = fixture() + bare.fs.files.delete(created(bare.note)) + bare.fs.files.set(created('Notes/Elsewhere/One.md'), '{"version":1,"createdAt":5}\n') + await bare.vault.moveNote(bare.note, 'inbox', 'Elsewhere') + assert.equal(bare.fs.files.has(created('Notes/Elsewhere/One.md')), false) + assert.equal(bare.fs.files.get(comments('Notes/Elsewhere/One.md')), COMMENTS_BODY) +}) + +// Taking over another note's discussion and deleting it are both wrong, so +// leftover comments refuse the move, in desktop's words, naming the file. +test('leftover comments at the destination refuse a rename or move and change nothing', async () => { + const s = fixture() + s.fs.files.set(comments('Notes/Work/Two.md'), 'an earlier discussion') + s.fs.files.set(comments('Notes/Elsewhere/One.md'), 'another discussion') + const before = new Map(s.fs.files) + await assert.rejects(s.vault.renameNote(s.note, 'Two'), { + message: 'Comments from an earlier note named “Two” are still in .zennotes/comments/Notes/Work/Two.md.comments.json. Move or delete that file to use this name.' + }) + await assert.rejects(s.vault.moveNote(s.note, 'inbox', 'Elsewhere'), { + message: 'Comments from an earlier note named “One” are still in .zennotes/comments/Notes/Elsewhere/One.md.comments.json. Move or delete that file to use this name.' + }) + assert.deepEqual(s.fs.files, before) +}) + +test('a creation date that cannot move puts the note and its comments back', async () => { + const s = fixture() + s.fs.failMove = from => from.startsWith('.zennotes/note-metadata/') + await assert.rejects(s.vault.renameNote(s.note, 'Two'), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) + await assert.rejects(s.vault.renameFolder('inbox', 'Work', 'Moved'), /Provider refused move/) + assert.deepEqual(s.fs.files, s.original) +}) + +// Desktop's relocateFolderTrees refuses any existing destination tree. +test('renaming a folder onto a leftover creation-date tree refuses and changes nothing', async () => { + const s = fixture() + s.fs.files.set(created('Notes/Moved/Ghost.md'), '{"version":1,"createdAt":5}\n') + const before = new Map(s.fs.files) + await assert.rejects( + s.vault.renameFolder('inbox', 'Work', 'Moved'), + /Destination already exists: \.zennotes\/note-metadata\/Notes\/Moved$/ + ) + assert.deepEqual(s.fs.files, before) +}) + +// Deleting takes the creation date along, as on desktop. Otherwise the phone +// would plant the leftover trees that refuse a later folder rename. +test('deleting a note, a folder or the trash leaves no creation date behind', async () => { + const note = fixture() + await note.vault.deleteNote(note.note) + assert.equal(note.fs.files.size, 0) + + const folder = fixture() + await folder.vault.deleteFolder('inbox', 'Work') + assert.equal(folder.fs.files.size, 0) + + const trash = fixture() + await trash.vault.moveToTrash(trash.note) + await trash.vault.emptyTrash() + assert.equal(trash.fs.files.size, 0) +}) + +test('a folder deleted on the phone does not block renaming another folder onto its name', async () => { + const s = fixture() + s.fs.files.set('Notes/Gone/Old.md', '# Old\n') + s.fs.files.set(created('Notes/Gone/Old.md'), '{"version":1,"createdAt":5}\n') + await s.vault.deleteFolder('inbox', 'Gone') + await s.vault.renameFolder('inbox', 'Work', 'Gone') + assertCarried(s.fs, s.note, 'Notes/Gone/One.md') + assert.equal(s.fs.files.has(created('Notes/Gone/Old.md')), false) +}) diff --git a/src/bridge/vault-core.ts b/src/bridge/vault-core.ts index 9e736ed..2e0b40e 100644 --- a/src/bridge/vault-core.ts +++ b/src/bridge/vault-core.ts @@ -7,6 +7,7 @@ * spelling — do not "fix" it and do not add an `attachments` variant. */ import type { ImportedAssetKind, NoteFolder, VaultSettings } from '@zennotes/bridge-contract/ipc' +import { noteCommentsSidecarPath } from '@zennotes/shared-domain/note-comments' import { resolveFolderPath, systemFolderForDirName, @@ -31,8 +32,13 @@ export const ATTACHMENTS_DIRS = [ASSETS_DIR, ...LEGACY_ATTACHMENTS_DIRS] export const INTERNAL_VAULT_DIR = '.zennotes' export const DELETED_ASSETS_DIR = 'deleted-assets' export const VAULT_SETTINGS_FILE = 'vault.json' -export const NOTE_COMMENTS_DIR = 'comments' -export const NOTE_COMMENTS_SUFFIX = '.comments.json' +export { NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX } from '@zennotes/shared-domain/note-comments' +// A note's creation date, which desktop keeps beside it as +// `.zennotes/note-metadata/.metadata.json` +// (apps/desktop/src/main/note-creation-metadata.ts, no shared export yet). +// The phone never reads or writes one, only moves or deletes it with its note. +export const NOTE_METADATA_DIR = 'note-metadata' +export const NOTE_METADATA_SUFFIX = '.metadata.json' export const TEMPLATES_DIR = '.zennotes/templates' export const RESERVED_ROOT_NAMES = new Set([ @@ -141,6 +147,14 @@ export function isMarkdownPath(p: string): boolean { return p.toLowerCase().endsWith('.md') } +/** Desktop's refusal (note-sidecars.ts `leftoverCommentsMessage`), word for + * word, when a rename or move lands on a name an earlier note left comments + * under: every writer refuses the same way and names the file to move aside. */ +export function leftoverCommentsMessage(notePath: string): string { + const sidecar = noteCommentsSidecarPath(INTERNAL_VAULT_DIR, notePath) + return `Comments from an earlier note named “${stemName(notePath)}” are still in ${sidecar}. Move or delete that file to use this name.` +} + // --------------------------------------------------------------------------- // Folder mapping — mirrors desktop folderForRelativePath, remap-aware since // 2.20 (vault.json `systemFolderPaths`): only the RESOLVED name of each diff --git a/src/bridge/vault-fs.ts b/src/bridge/vault-fs.ts index 05456a0..8806506 100644 --- a/src/bridge/vault-fs.ts +++ b/src/bridge/vault-fs.ts @@ -64,8 +64,11 @@ import { isExcalidrawPath, isMarkdownPath, joinPath, + leftoverCommentsMessage, NOTE_COMMENTS_DIR, NOTE_COMMENTS_SUFFIX, + NOTE_METADATA_DIR, + NOTE_METADATA_SUFFIX, RESERVED_ROOT_NAMES, resolveSafeRel, sanitizeNoteTitle, @@ -688,20 +691,37 @@ export class MobileVault { } } + /** + * Move a note with its comments and its creation date, as desktop's + * note-sidecars.ts `relocateNote` does; a sidecar the note lacks is not + * moved. A creation date already at a destination with no note beside it + * belongs to nobody (its note left outside ZenNotes) and is discarded rather + * than inherited (ZenNotes#839). Leftover comments there are another note's + * discussion: taking it over and deleting it are both wrong, so the move is + * refused. + */ private async relocateNote(oldPath: string, newPath: string): Promise { + const comments = this.commentsPathFor(newPath) + const metadata = this.metadataPathFor(newPath) + if (await this.fs.statVerified(newPath) === null) { + if (await this.fs.statVerified(comments) !== null) throw new Error(leftoverCommentsMessage(newPath)) + if (await this.fs.statVerified(metadata) === 'file') await this.fs.deleteFile(metadata) + } await relocateVaultEntries(this.relocationIO(), [ { from: oldPath, to: newPath, required: true }, - { from: this.commentsPathFor(oldPath), to: this.commentsPathFor(newPath) } + { from: this.commentsPathFor(oldPath), to: comments }, + { from: this.metadataPathFor(oldPath), to: metadata } ]) this.invalidateMeta(oldPath) } - /** Detach both trees before cleanup so a failed move can restore the original. */ - private async detachContent(path: string, comments: string, required = true): Promise { + /** Detach the content and both sidecar trees before cleanup so a failed move can restore the original. */ + private async detachContent(path: string, comments: string, metadata: string, required = true): Promise { const temporary = `${INTERNAL_VAULT_DIR}/delete-${uuid()}` const moves: VaultRelocation[] = [ { from: path, to: `${temporary}/content`, required }, - { from: comments, to: `${temporary}/comments` } + { from: comments, to: `${temporary}/comments` }, + { from: metadata, to: `${temporary}/metadata` } ] await relocateVaultEntries(this.relocationIO(), moves) try { @@ -811,7 +831,12 @@ export class MobileVault { async emptyTrash(): Promise { const trashDir = await this.folderRootRel('trash') - await this.detachContent(trashDir, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${trashDir}`, false) + await this.detachContent( + trashDir, + `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${trashDir}`, + `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${trashDir}`, + false + ) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${trashDir}/`)) this.invalidateMeta(key) } @@ -821,7 +846,7 @@ export class MobileVault { async deleteNote(relPath: string): Promise { const rel = resolveSafeRel(relPath) const folder = (await this.folderOf(rel)) ?? 'trash' - await this.detachContent(rel, this.commentsPathFor(rel)) + await this.detachContent(rel, this.commentsPathFor(rel), this.metadataPathFor(rel)) this.invalidateMeta(rel) emitVaultChange({ kind: 'unlink', path: rel, folder, scope: 'content' }) } @@ -912,10 +937,15 @@ export class MobileVault { const settingsPath = `${INTERNAL_VAULT_DIR}/vault.json` const hadSettings = await this.fs.statVerified(settingsPath) !== null const originalSettings = hadSettings ? await this.fs.readText(settingsPath) : null + // A creation-date tree already at the destination refuses the rename, as + // desktop's relocateFolderTrees does: only a single note's stale date is + // discarded (relocateNote). await relocateVaultEntries(this.relocationIO(), [ { from: oldRel, to: newRel, required: true }, { from: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${oldRel}`, - to: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${newRel}` } + to: `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${newRel}` }, + { from: `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${oldRel}`, + to: `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${newRel}` } ], async () => { try { await this.setVaultSettings({ @@ -947,7 +977,11 @@ export class MobileVault { const clean = subpath.replace(/^\/+|\/+$/g, '') if (!clean) return const rel = resolveSafeRel(joinPath(topRel, clean)) - await this.detachContent(rel, `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}`) + await this.detachContent( + rel, + `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}`, + `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${rel}` + ) for (const key of [...this.metaCache.keys()]) { if (key.startsWith(`${rel}/`)) this.invalidateMeta(key) } @@ -981,6 +1015,12 @@ export class MobileVault { return `${INTERNAL_VAULT_DIR}/${NOTE_COMMENTS_DIR}/${rel}${NOTE_COMMENTS_SUFFIX}` } + /** `.zennotes/note-metadata/.metadata.json`, the creation date desktop + * keeps for the note (see NOTE_METADATA_DIR). */ + private metadataPathFor(rel: string): string { + return `${INTERNAL_VAULT_DIR}/${NOTE_METADATA_DIR}/${rel}${NOTE_METADATA_SUFFIX}` + } + async readNoteComments(relPath: string): Promise { const rel = resolveSafeRel(relPath) const raw = await this.fs.readTextOrNull(this.commentsPathFor(rel)) From f69a58c4bbd7694f76f5d0cae528bb4b754c3d9c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 5 Oct 2026 12:13:36 -0500 Subject: [PATCH 2/5] fix(drawer): a folder lists its files after its notes The drawer built a folder's rows from app-core's Browse listing, which carried only folders, databases and notes, so attachments never appeared on the phone while the desktop's folder view shows them. Core's Browse now returns file rows (ZenNotes/zennotes, Fix(mobile): Browse lists a folder's files); the drawer renders them after the notes, with the extension label the desktop sidebar uses, and opens one through openNote like a database row. "No notes here yet" now also needs no files. The rows are read defensively, so on the pinned core 2.60.4 (no `files`) the drawer is exactly as before; they appear once the app adopts a core with the change. --- src/ui-mobile/MobileDrawer.tsx | 28 +++++++++++++++++--- src/ui-mobile/browse-files.test.ts | 41 ++++++++++++++++++++++++++++++ src/ui-mobile/browse-files.ts | 31 ++++++++++++++++++++++ src/ui-mobile/mobile.css | 9 +++++++ 4 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 src/ui-mobile/browse-files.test.ts create mode 100644 src/ui-mobile/browse-files.ts diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 1ce1b2f..99c1e51 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -27,6 +27,7 @@ import { usePins, toggleNotePin, toggleFolderPin } from './pins' import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' import { SwipeRow } from './SwipeRow' +import { drawerFileRows, fileExtensionLabel, type DrawerFileRow } from './browse-files' import { getStoragePref, icloudStatus } from '../bridge/icloud' import { answerExternalVaultReplace, getExternalVaultRef } from '../bridge/folder-picker' import { @@ -98,7 +99,9 @@ const D = { chevDown: 'M6 9l6 6 6-6', more: 'M6 12h.01M12 12h.01M18 12h.01', pencil: 'M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z', - pin: 'M12 17v5M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z' + pin: 'M12 17v5M9 3h6l-1 7 3 2v3H7v-3l3-2-1-7z', + // The editor toolbar's "Attach file" glyph. + file: 'M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l8.57-8.57A4 4 0 0118 8.84l-8.59 8.57a2 2 0 01-2.83-2.83l8.49-8.48' } /** WKWebView leaves the soft keyboard up when a focused input unmounts @@ -899,12 +902,13 @@ export function MobileDrawer(): React.JSX.Element | null { if (open) setPath(takeDrawerPath()) }, [open]) - const { childFolders, childDatabases, childNotes } = useMemo(() => { + const { childFolders, childDatabases, childNotes, childFiles } = useMemo(() => { const rows = getBrowseDirectory(browse, path, pins) return { childFolders: rows.folders.map(row => [row.directory, row.title] as [string, string]), childDatabases: rows.databases.map(row => [row.path, row.title, row.directory] as [string, string, string]), - childNotes: [...rows.notes] + childNotes: [...rows.notes], + childFiles: drawerFileRows(rows) } }, [browse, path, pins]) @@ -937,6 +941,7 @@ export function MobileDrawer(): React.JSX.Element | null { childFolders={childFolders} childDatabases={childDatabases} childNotes={childNotes} + childFiles={childFiles} noteSortOrder={noteSortOrder} close={close} go={go} @@ -1108,6 +1113,7 @@ function MobileDrawerBody(props: { childFolders: Array<[string, string]> childDatabases: Array<[string, string, string]> childNotes: Array<{ path: string; title: string }> + childFiles: readonly DrawerFileRow[] noteSortOrder: NoteSortOrder close: () => void go: (action: () => unknown) => void @@ -1126,6 +1132,7 @@ function MobileDrawerBody(props: { childFolders, childDatabases, childNotes, + childFiles, noteSortOrder, close, go @@ -1387,7 +1394,20 @@ function MobileDrawerBody(props: { ) })} - {childFolders.length === 0 && childDatabases.length === 0 && childNotes.length === 0 && ( + {/* Attachments and other files, after the notes as on desktop. A + tap opens core's viewer the way a database row opens its table. */} + {childFiles.map((file) => { + const extension = fileExtensionLabel(file.name) + return ( + + ) + })} + {childFolders.length === 0 && childDatabases.length === 0 && childNotes.length === 0 && + childFiles.length === 0 && (
No notes here yet
)} - - - - )} -