Compare commits

..
11 Commits
Author SHA1 Message Date
github-actions[bot] e8fef27268 chore: bump version to 0.30.1 2026-07-15 08:59:27 +00:00
Frederic CabassutandClaude Opus 4.8 fc9ab5b237 test(e2e): deflake dark-mode by awaiting full editor close in createNote
createNote returned as soon as the note card appeared (editor merely
"closing"), but the editor now dismisses via an async history.back() plus a
400ms onClose (added with close-on-back navigation, #71). A following
page.goto could race that in-flight navigation and abort it
(net::ERR_ABORTED) — surfacing only under the slower coverage-instrumented
CI build. Wait for the editor overlay to fully unmount so the close
navigation settles before createNote returns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:59:01 +02:00
github-actions[bot] b37361ee8c chore: bump version to 0.30.0 2026-07-15 08:29:43 +00:00
Claude 12f45267f7 feat(editor): close the note editor with browser back navigation
The mobile editor is fullscreen with no backdrop to tap, so the Android
back button / iOS back swipe now dismisses it instead of leaving the
page. The editor pushes a shallow-routing history entry on mount and
closes when it is popped; UI closes pop the entry themselves so history
stays clean. NotesView's hash rewriting now goes through SvelteKit's
replaceState so the router keeps handling popstate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1N5Vi12zTez7LNqRvYjUD
2026-07-15 10:25:54 +02:00
Frederic CabassutandClaude Opus 4.8 d96b20f244 fix(sync): prevent silent content loss in concurrent-edit merges
Address review feedback on the concurrent-edit merge change:

- When no base snapshot is available (e.g. a metadata-only change bumped
  the version without creating a snapshot, or the base was pruned),
  mergeContentUpdate now keeps the incoming edit instead of discarding it
  in favour of the server copy, and logs a warning. Dropping it silently
  lost the user's offline edits.
- Unify getBaseContent into one shared helper in versions-service and drop
  sync's "fall back to the latest snapshot" branch: a base newer than what
  the client actually saw made the 3-way merge treat other users' lines as
  deletions and silently remove them.
- Remove the now-dead mergeContentTwoWay helper.
- Add coverage: null-base keeps incoming (unit), a metadata-bump concurrent
  edit is preserved (integration), and getBaseContent returns null rather
  than a newer snapshot. Verified the null-base cases fail against the old
  drop-incoming behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:11:43 +02:00
Romain LE DISEZandCodex bba0ddd311 fix(sync): merge concurrent note content edits
Create version snapshots before mutating note content so later concurrent
saves can find a base version. Reuse the content merge helper for direct
updates and sync pushes, with tests covering fallback and queued-edit
merge behavior.

Co-authored-by: Codex <codex@openai.com>
2026-07-15 10:11:43 +02:00
Frederic CabassutandClaude Opus 4.8 460f39298b fix(search): preserve unsynced hits and avoid empty-state flash
Address review feedback on the search-results preservation change:

- reconcileSearchResults now keeps a result absent from the canonical
  notes store (a server hit not yet loaded locally, e.g. from another
  device) instead of dropping it, so valid matches no longer vanish
  mid-session. Locally deleted/left notes are still pruned at their call
  sites; trashed notes are still dropped.
- SearchBar commits searchQuery only once its results arrive, so the view
  no longer flashes an empty state over the current notes while a request
  is in flight, and searchQuery stays consistent with searchResults on a
  failed request.
- Expand reconcile unit tests (keep-absent, trashed-drop, mixed order,
  empty) and add an e2e test asserting notes stay visible during an
  in-flight search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:57:00 +02:00
Romain LE DISEZandGoogle Gemini b802f17504 fix: preserve search results when background sync runs
Keep canonical notes separate from active search results so periodic
reloads no longer replace the filtered view.

Cancel superseded requests and ignore stale responses to prevent older
queries from overwriting newer results. Reconcile results with canonical
notes so updates propagate and removed or trashed notes disappear.

Add unit and end-to-end coverage for reconciliation, background sync,
and out-of-order search responses.

Co-authored-by: Google Gemini <gemini@google.com>
2026-07-15 09:57:00 +02:00
Frederic CabassutandClaude Opus 4.8 32c39b0000 test(editor): assert checkbox toggle updates the editor document
Read the ProseMirror document's taskItem `checked` attribute after
tapping the mobile task checkbox, not just the native checkbox widget.
The widget toggles regardless of whether the fix's setNodeMarkup
transaction runs, so the previous assertions could not detect a
widget/document desync. Verified this fails against the pre-fix code
and passes with the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:25:34 +02:00
Romain LE DISEZandCodex 59b7a77f3a fix(editor): avoid mobile focus on task checkboxes
Intercept touch checkbox changes before TipTap's focusing handler and
update the task-item transaction directly. This prevents the mobile
keyboard from starting to open while preserving checkbox state changes.

Strengthen the mobile regression test to assert that checking and
unchecking emit no editor focus events.

Co-authored-by: Codex <codex@openai.com>
2026-07-15 09:25:34 +02:00
github-actions[bot] 9d62276682 chore: pin docker-compose to v0.29.0 [skip ci] 2026-07-12 17:21:10 +00:00
22 changed files with 697 additions and 131 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
services:
crumbs:
image: ghcr.io/bretzel-app/crumbs:0.28.4
image: ghcr.io/bretzel-app/crumbs:0.29.0
container_name: crumbs
ports:
- "3000:3000"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "crumbs",
"private": true,
"version": "0.29.0",
"version": "0.30.1",
"type": "module",
"packageManager": "pnpm@9.15.9",
"license": "MIT",
+3 -1
View File
@@ -15,7 +15,9 @@ declare global {
} | null;
}
// interface PageData {}
// interface PageState {}
interface PageState {
noteEditorOpen?: boolean;
}
// interface Platform {}
}
}
+28
View File
@@ -4,6 +4,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pushState } from '$app/navigation';
import { page } from '$app/state';
import ColorPicker from './ColorPicker.svelte';
import Checklist from './Checklist.svelte';
import FormattingToolbar from './FormattingToolbar.svelte';
@@ -404,8 +406,34 @@
pointerdownOnOverlay = false;
}
// Close on browser back — Android back button / iOS back swipe. The editor is
// fullscreen on mobile with no backdrop to tap, so back must dismiss it instead of
// leaving the page. On mount, push a shallow-routing history entry; navigating back
// pops it and closes the editor. UI closes pop the entry themselves via
// history.back() so no stale entry is left behind.
let historyEntryPushed = false;
onMount(() => {
// Reuse an existing entry (e.g. back-navigating to a note restored from the
// URL hash) instead of stacking a second one.
if (!page.state.noteEditorOpen) {
pushState('', { noteEditorOpen: true });
}
historyEntryPushed = true;
});
$effect(() => {
if (page.state.noteEditorOpen || !historyEntryPushed || closing) return;
historyEntryPushed = false;
saveAndClose();
});
function dismissOverlay() {
closing = true;
if (historyEntryPushed) {
historyEntryPushed = false;
history.back();
}
setTimeout(() => onClose(), 400);
}
+6 -2
View File
@@ -4,6 +4,7 @@
import TagFilter from '$lib/components/TagFilter.svelte';
import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, loadNotes, updateSortOrders } from '$lib/stores/notes.js';
import { onMount } from 'svelte';
import { replaceState } from '$app/navigation';
import type { Note, NoteFilter } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
import SquareCheck from 'lucide-svelte/icons/square-check';
@@ -27,16 +28,19 @@
loadNotes(filter);
});
// Use SvelteKit's replaceState (not the raw history API) so the router keeps
// ownership of the entry — NoteEditor's close-on-back relies on popstate being
// handled by SvelteKit.
function openEditor(note: Note) {
editingNote = note;
history.replaceState(null, '', `#${note.id}`);
replaceState(`#${note.id}`, {});
}
function closeEditor() {
editingNote = null;
showNewNote = false;
newNoteChecklist = false;
history.replaceState(null, '', location.pathname);
replaceState(location.pathname, {});
}
function handleReorder(noteIds: string[]) {
+44 -20
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
import { searchQuery, searchResults } from '$lib/stores/notes.js';
import type { Note } from '$lib/types/index.js';
import Search from 'lucide-svelte/icons/search';
import X from 'lucide-svelte/icons/x';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import { onDestroy } from 'svelte';
interface Props {
onClose?: () => void;
@@ -12,41 +13,64 @@
let { onClose }: Props = $props();
let query = $state('');
let originalNotes: Note[] = [];
let isSearching = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
let searchController: AbortController | undefined;
$effect(() => {
if (onClose) inputEl?.focus();
});
onDestroy(() => {
searchController?.abort();
searchQuery.set('');
searchResults.set([]);
});
async function handleSearch() {
if (!query.trim()) {
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
searchController?.abort();
searchController = undefined;
const trimmedQuery = query.trim();
if (!trimmedQuery) {
searchQuery.set('');
searchResults.set([]);
return;
}
if (!isSearching) {
notes.subscribe((n) => (originalNotes = n))();
isSearching = true;
}
const controller = new AbortController();
searchController = controller;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const results = await res.json();
notes.set(results);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmedQuery)}`, {
signal: controller.signal
});
if (res.ok) {
const results: Note[] = await res.json();
if (searchController !== controller || query.trim() !== trimmedQuery) return;
// Commit the query alongside its results so the view switches to the
// (possibly empty) result set only once it's ready — this avoids
// flashing an empty state over the current notes while the request is
// in flight, and keeps searchQuery consistent with searchResults on
// a failed request (neither updates).
searchResults.set(results);
searchQuery.set(trimmedQuery);
}
} catch (error) {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
console.error('Search failed:', error);
}
} finally {
if (searchController === controller) searchController = undefined;
}
}
function clearSearch() {
searchController?.abort();
searchController = undefined;
query = '';
if (isSearching) {
notes.set(originalNotes);
isSearching = false;
}
searchQuery.set('');
searchResults.set([]);
}
function close() {
+51 -1
View File
@@ -27,6 +27,54 @@
let element: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
function isTaskCheckboxTarget(target: EventTarget | null): boolean {
if (target instanceof Element) {
const checkbox = target.matches('input[type="checkbox"]')
? target
: target.closest('label')?.querySelector('input[type="checkbox"]');
return checkbox !== null && checkbox !== undefined;
}
return false;
}
function isTouchDevice(): boolean {
return window.matchMedia('(pointer: coarse)').matches || navigator.maxTouchPoints > 0;
}
function handleWrapperPointerDown(event: PointerEvent) {
if (event.pointerType === 'touch' && isTaskCheckboxTarget(event.target)) {
event.preventDefault();
}
}
function handleWrapperClick(event: MouseEvent) {
if (isTaskCheckboxTarget(event.target)) {
return;
}
editor?.commands.focus();
}
function handleWrapperChangeCapture(event: Event) {
if (!isTouchDevice() || !(event.target instanceof HTMLInputElement)) return;
if (event.target.type !== 'checkbox' || !editor) return;
const taskItem = event.target.closest('li[data-checked]');
if (!taskItem) return;
const taskItemPosition = editor.view.posAtDOM(taskItem, 0) - 1;
const taskItemNode = editor.state.doc.nodeAt(taskItemPosition);
if (taskItemNode?.type.name !== 'taskItem') return;
event.stopPropagation();
editor.view.dispatch(
editor.state.tr.setNodeMarkup(taskItemPosition, undefined, {
...taskItemNode.attrs,
checked: event.target.checked
})
);
}
onMount(() => {
editor = new Editor({
element: element!,
@@ -77,7 +125,9 @@
role="textbox"
aria-multiline="true"
tabindex="0"
onclick={() => editor?.commands.focus()}
onpointerdown={handleWrapperPointerDown}
onclick={handleWrapperClick}
onchangecapture={handleWrapperChangeCapture}
></div>
<style>
+40
View File
@@ -263,6 +263,46 @@ describe('updateNote', () => {
const updated = updateNote(db, OWNER_ID, 'n1', { title: 'Now with #newtag' });
expect(updated!.tags).toContain('newtag');
});
it('should merge concurrent content edits using the snapshot created by the first update', () => {
seedNote('n1', {
title: 'Shopping',
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true,
version: 1
});
shareNote('n1');
const ownerUpdate = updateNote(db, OWNER_ID, 'n1', {
content: '- [ ] Milk\n- [x] Bread',
baseVersion: 1
});
expect(ownerUpdate!.version).toBe(2);
const collabUpdate = updateNote(db, COLLAB_ID, 'n1', {
content: '- [x] Milk\n- [ ] Bread',
baseVersion: 1
});
expect(collabUpdate!.content).toBe('- [x] Milk\n- [x] Bread');
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
});
it('should keep the incoming edit when a metadata-only bump left no base snapshot', () => {
seedNote('n1', { title: 'Note', content: 'hello', version: 1 });
// A metadata-only change bumps the version but creates no snapshot.
updateNote(db, OWNER_ID, 'n1', { pinned: true });
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(0);
// A later concurrent content edit based on the pre-bump version therefore has
// no base to merge against — the incoming edit must be preserved, not dropped.
const edited = updateNote(db, OWNER_ID, 'n1', { content: 'hello world', baseVersion: 1 });
expect(edited!.content).toBe('hello world');
});
});
describe('deleteNote', () => {
+29 -41
View File
@@ -1,6 +1,6 @@
import type { Db } from './db/index.js';
import { notes, noteTags, tags, noteCollaborators, noteUserState, noteVersions } from './db/schema.js';
import { eq, and, desc, like, or, inArray, sql, lte } from 'drizzle-orm';
import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js';
import { eq, and, like, or, inArray, sql } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from './tags.js';
@@ -8,8 +8,8 @@ import { fetchAttachmentsForNotes } from './attachments.js';
import { fetchCollaboratorsForNotes } from './collaborators.js';
import { fetchSharesForNotes } from './shares-service.js';
import { canAccessNote } from './api-utils.js';
import { createSnapshot } from './versions-service.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import { createSnapshot, getBaseContent } from './versions-service.js';
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
import type { NoteFilter } from '$lib/types/index.js';
interface NoteRow {
@@ -238,29 +238,18 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; }
if (input.content !== undefined) {
// 3-way merge to preserve concurrent edits from other users.
// Only attempt merge when there's a concurrent edit: the client's
// baseVersion is behind the current version, meaning someone else
// saved between the client's load and this save.
// Preserve concurrent edits at line/block granularity when the client
// saved from an older note version.
const hasConcurrentEdit = input.baseVersion !== undefined
&& input.baseVersion < existing.version;
if (hasConcurrentEdit && input.content !== existing.content) {
const baseSnapshot = db.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, id), lte(noteVersions.version, input.baseVersion!)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (baseSnapshot && baseSnapshot.content !== existing.content) {
sharedUpdates.content = mergeContent(baseSnapshot.content, input.content, existing.content);
} else {
sharedUpdates.content = input.content;
}
} else {
sharedUpdates.content = input.content;
}
sharedUpdates.content = hasConcurrentEdit
? mergeContentUpdate({
baseContent: getBaseContent(db, id, input.baseVersion),
incomingContent: input.content,
currentContent: existing.content
})
: input.content;
hasSharedUpdates = true;
}
if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; }
@@ -278,6 +267,22 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; }
}
const hasActualContentChange =
(input.title !== undefined && input.title !== existing.title) ||
(sharedUpdates.content !== undefined && sharedUpdates.content !== existing.content) ||
(input.color !== undefined && input.color !== existing.color) ||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.color
});
}
if (hasSharedUpdates || isOwner) {
db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run();
}
@@ -324,23 +329,6 @@ export function updateNote(db: Db, userId: number, id: string, input: UpdateNote
}
}
// Create a version snapshot only when at least one content field actually changed
const hasActualContentChange =
(input.title !== undefined && input.title !== existing.title) ||
(input.content !== undefined && input.content !== existing.content) ||
(input.color !== undefined && input.color !== existing.color) ||
(input.checklistMode !== undefined && input.checklistMode !== existing.checklistMode);
if (hasActualContentChange) {
createSnapshot(db, id, {
version: existing.version,
title: existing.title,
content: existing.content,
checklistMode: existing.checklistMode,
color: existing.color
});
}
return getNote(db, userId, id);
}
+27 -1
View File
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
import { users, notes, noteVersions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import type { Db } from './db/index.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote } from './versions-service.js';
import { createSnapshot, listVersions, getVersion, snapshotCurrentNote, getBaseContent } from './versions-service.js';
let db: Db;
@@ -44,6 +44,32 @@ beforeEach(() => {
seedUser();
});
describe('getBaseContent', () => {
it('returns null when the base version is unknown', () => {
seedNote();
expect(getBaseContent(db, NOTE_ID, undefined)).toBeNull();
});
it('returns the snapshot content at or before the base version', () => {
seedNote();
createSnapshot(db, NOTE_ID, { version: 1, title: 'T', content: 'v1', checklistMode: false, color: 'default' });
createSnapshot(db, NOTE_ID, { version: 3, title: 'T', content: 'v3', checklistMode: false, color: 'default' });
expect(getBaseContent(db, NOTE_ID, 2)).toBe('v1');
expect(getBaseContent(db, NOTE_ID, 3)).toBe('v3');
});
it('returns null instead of a newer snapshot when none exists at or before the base version', () => {
seedNote();
// Only a snapshot NEWER than the requested base exists (e.g. the real base was
// pruned). Using it as the merge base would treat unseen lines as deletions,
// so getBaseContent must return null rather than fall back to it.
createSnapshot(db, NOTE_ID, { version: 5, title: 'T', content: 'v5', checklistMode: false, color: 'default' });
expect(getBaseContent(db, NOTE_ID, 2)).toBeNull();
});
});
describe('createSnapshot', () => {
it('should store a snapshot for a note', () => {
seedNote();
+30 -2
View File
@@ -1,18 +1,46 @@
import type { Db } from './db/index.js';
import { noteVersions, notes } from './db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { eq, and, desc, lte } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
const MAX_VERSIONS_PER_NOTE = 50;
const CONTENT_PREVIEW_LENGTH = 80;
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
export type SnapshotDb = Db | Transaction;
/**
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
* at or before the client's base version. Returns null when the base version is
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
* snapshot as the base — a base newer than what the client actually saw makes the
* merge treat other users' lines as deletions and silently drop them.
*/
export function getBaseContent(
db: SnapshotDb,
noteId: string,
baseVersion: number | undefined
): string | null {
if (baseVersion === undefined) return null;
const snapshot = db
.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return snapshot?.content ?? null;
}
/**
* Create a snapshot of a note's current state.
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
*/
export function createSnapshot(
db: Db,
db: SnapshotDb,
noteId: string,
snapshot: {
version: number;
+47 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeNotesByVersion } from './notes.js';
import { mergeNotesByVersion, reconcileSearchResults } from './notes.js';
import type { Note } from '$lib/types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
@@ -97,3 +97,49 @@ describe('mergeNotesByVersion', () => {
expect(result[1].content).toBe('updated on server');
});
});
describe('reconcileSearchResults', () => {
it('replaces a stale result with the canonical note', () => {
const canonical = makeNote({ id: 'n1', title: 'Updated', version: 2 });
const result = reconcileSearchResults(
[canonical],
[makeNote({ id: 'n1', title: 'Stale', version: 1 })]
);
expect(result).toEqual([canonical]);
});
it('keeps a result absent from the canonical store (hit not yet loaded locally)', () => {
const serverHit = makeNote({ id: 'n2', title: 'From another device' });
const result = reconcileSearchResults([makeNote({ id: 'n1' })], [serverHit]);
expect(result).toEqual([serverHit]);
});
it('drops a result that is trashed in the canonical store', () => {
const trashed = makeNote({ id: 'n1', trashed: true });
const result = reconcileSearchResults([trashed], [makeNote({ id: 'n1', trashed: false })]);
expect(result).toEqual([]);
});
it('preserves result order while reconciling a mix of cases', () => {
const canonical = makeNote({ id: 'n1', title: 'Canonical', version: 2 });
const trashed = makeNote({ id: 'n2', trashed: true });
const absent = makeNote({ id: 'n3', title: 'Absent' });
const result = reconcileSearchResults(
[canonical, trashed],
[makeNote({ id: 'n1', title: 'Stale', version: 1 }), trashed, absent]
);
expect(result).toEqual([canonical, absent]);
});
it('returns an empty array when there are no results', () => {
expect(reconcileSearchResults([makeNote({ id: 'n1' })], [])).toEqual([]);
});
});
+36 -3
View File
@@ -8,11 +8,41 @@ export const notes = writable<Note[]>([]);
export const currentFilter = writable<NoteFilter>('all');
export const selectedTag = writable<string | null>(null);
export const notesLoaded = writable(false);
export const searchQuery = writable<string>('');
export const searchResults = writable<Note[]>([]);
/**
* Reconcile search results against the canonical notes store so local edits and
* trashing propagate into the active result set without re-querying the server.
*
* A result absent from the canonical store is kept as-is — it's a server hit not
* yet loaded locally (e.g. created on another device), and dropping it would make
* valid matches disappear mid-session. Notes removed locally (deleted or left) are
* pruned explicitly at their call sites; trashed notes are dropped here.
*/
export function reconcileSearchResults(canonicalNotes: Note[], results: Note[]): Note[] {
const notesMap = new Map(canonicalNotes.map((note) => [note.id, note]));
return results.flatMap((result) => {
const canonical = notesMap.get(result.id);
if (!canonical) return [result];
return canonical.trashed ? [] : [canonical];
});
}
// Keep searchResults in sync with edits/trashing in the notes store. These search
// stores (like the others in this module) are client-only state; the subscription
// lives for the app's lifetime and is intentionally never unsubscribed.
notes.subscribe(($notes) => {
searchResults.update(($results) => {
if ($results.length === 0) return $results;
return reconcileSearchResults($notes, $results);
});
});
export const filteredNotes = derived(
[notes, selectedTag, currentFilter],
([$notes, $selectedTag, $filter]) => {
let result = $notes;
[notes, selectedTag, currentFilter, searchQuery, searchResults],
([$notes, $selectedTag, $filter, $searchQuery, $searchResults]) => {
let result = $searchQuery.trim() ? $searchResults : $notes;
if ($filter === 'all') {
result = result.filter((n) => !n.trashed && !n.archived);
} else if ($filter === 'archived') {
@@ -218,6 +248,7 @@ export async function deleteNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
@@ -225,6 +256,7 @@ export async function deleteNote(id: string): Promise<boolean> {
} catch (err) {
if (isNetworkError(err)) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
await addToSyncQueue({ noteId: id, operation: 'delete', timestamp: Date.now() });
showToast('Saved offline — will sync when reconnected', 'info');
@@ -263,6 +295,7 @@ export async function leaveNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
searchResults.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
+59 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb } from '../server/db/test-helpers.js';
import { notes, noteCollaborators, noteUserState, users, syncLog } from '../server/db/schema.js';
import { notes, noteCollaborators, noteUserState, users, syncLog, noteVersions } from '../server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import type * as schema from '../server/db/schema.js';
@@ -289,6 +289,64 @@ describe('processSyncPush — shared notes', () => {
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
});
it('should create a base snapshot during sync so later queued edits can merge', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: t1,
createdAt: t1,
content: '- [ ] Milk\n- [ ] Bread',
checklistMode: true,
version: 1
});
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1.getTime() + 1000,
data: { content: '- [ ] Milk\n- [x] Bread' },
baseVersion: 1
}], OWNER_ID);
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: t1.getTime() + 2000,
data: { content: '- [x] Milk\n- [ ] Bread' },
baseVersion: 1
}], COLLAB_ID);
const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!;
expect(note.content).toBe('- [x] Milk\n- [x] Bread');
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(true);
});
it('should retain at most 50 snapshots created by sync updates', async () => {
const createdAt = new Date('2024-01-01T00:00:00Z');
seedSharedNote('n1', {
updatedAt: createdAt,
createdAt,
content: 'version 1',
version: 1
});
for (let version = 2; version <= 52; version++) {
await processSyncPush(db, [{
noteId: 'n1',
operation: 'update',
timestamp: createdAt.getTime() + version,
data: { content: `version ${version}` },
baseVersion: version - 1
}], OWNER_ID);
}
const snapshots = db.select().from(noteVersions).where(eq(noteVersions.noteId, 'n1')).all();
expect(snapshots).toHaveLength(50);
expect(snapshots.some((snapshot) => snapshot.version === 1)).toBe(false);
expect(snapshots.some((snapshot) => snapshot.version === 51)).toBe(true);
});
it('should not allow non-collaborator to update the note', async () => {
const t1 = new Date('2024-01-01T00:00:00Z');
// Create note without sharing with user 3
+30 -38
View File
@@ -1,37 +1,12 @@
import type { Db } from '$lib/server/db/index.js';
import { notes, noteCollaborators, noteUserState, noteVersions, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, or, sql, desc, lte } from 'drizzle-orm';
import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js';
import { eq, gt, and, sql } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { syncNoteTags } from '$lib/server/tags.js';
import { canAccessNote } from '$lib/server/api-utils.js';
import { mergeContent } from '$lib/utils/content-merge.js';
import { createSnapshot, getBaseContent } from '$lib/server/versions-service.js';
import { mergeContentUpdate } from '$lib/utils/content-merge.js';
import type { SyncQueueItem } from './idb.js';
/**
* Find the base content for 3-way merge from version snapshots.
* Returns the content from the snapshot at or before the given version,
* or the current note content if no snapshot is found.
*/
function getBaseContent(tx: Parameters<Parameters<Db['transaction']>[0]>[0], noteId: string, baseVersion?: number): string | null {
if (baseVersion !== undefined) {
const snapshot = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
if (snapshot) return snapshot.content;
}
// Fallback: get the most recent snapshot before the current version
const latest = tx.select({ content: noteVersions.content })
.from(noteVersions)
.where(eq(noteVersions.noteId, noteId))
.orderBy(desc(noteVersions.version))
.limit(1)
.get();
return latest?.content ?? null;
}
/**
* Process incoming sync changes from client.
*/
@@ -69,16 +44,17 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
if (change.data.color !== undefined) sharedData.color = change.data.color;
if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode;
// Content: 3-way merge to preserve concurrent edits from other users
// Content: merge only when the client saved from an older note version.
if (change.data.content !== undefined && change.data.content !== note.content) {
const baseContent = getBaseContent(tx, change.noteId, change.baseVersion);
if (baseContent !== null && baseContent !== note.content) {
// 3-way merge: base, incoming (local), server current (remote)
sharedData.content = mergeContent(baseContent, change.data.content, note.content);
} else {
// No base or base matches server — take incoming content
sharedData.content = change.data.content;
}
const hasConcurrentEdit = change.baseVersion !== undefined
&& change.baseVersion < note.version;
sharedData.content = hasConcurrentEdit
? mergeContentUpdate({
baseContent: getBaseContent(tx, change.noteId, change.baseVersion),
incomingContent: change.data.content,
currentContent: note.content
})
: change.data.content;
}
if (isOwner) {
@@ -92,6 +68,22 @@ export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId:
if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder;
}
const hasActualContentChange =
(change.data.title !== undefined && change.data.title !== note.title) ||
(sharedData.content !== undefined && sharedData.content !== note.content) ||
(change.data.color !== undefined && change.data.color !== note.color) ||
(change.data.checklistMode !== undefined && change.data.checklistMode !== note.checklistMode);
if (hasActualContentChange) {
createSnapshot(tx, change.noteId, {
version: note.version,
title: note.title,
content: note.content,
checklistMode: note.checklistMode,
color: note.color
});
}
tx.update(notes)
.set(sharedData)
.where(eq(notes.id, change.noteId))
+49 -10
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeContent, mergeContentTwoWay } from './content-merge.js';
import { mergeContent, mergeContentUpdate } from './content-merge.js';
describe('mergeContent (3-way)', () => {
it('should return remote when local is unchanged', () => {
@@ -89,17 +89,56 @@ describe('mergeContent (3-way)', () => {
});
});
describe('mergeContentTwoWay', () => {
it('should return remote when both are identical', () => {
expect(mergeContentTwoWay('hello', 'hello')).toBe('hello');
describe('mergeContentUpdate', () => {
it('should merge an incoming update against current content when a base is available', () => {
const result = mergeContentUpdate({
baseContent: '- [ ] Milk\n- [ ] Bread',
incomingContent: '- [x] Milk\n- [ ] Bread',
currentContent: '- [ ] Milk\n- [x] Bread'
});
expect(result).toBe('- [x] Milk\n- [x] Bread');
});
it('should attempt merge when contents differ', () => {
const local = '- [x] Milk\n- [ ] Bread';
const remote = '- [ ] Milk\n- [ ] Bread\n- [ ] Eggs';
it('should keep the incoming edit when no base snapshot is available', () => {
// Without a common ancestor a 3-way merge is impossible; dropping the
// incoming edit would silently lose the user's changes.
const result = mergeContentUpdate({
baseContent: null,
incomingContent: '- [x] Milk',
currentContent: '- [ ] Bread'
});
const result = mergeContentTwoWay(local, remote);
// Without a base, conflict resolution prefers remote
expect(result).toContain('- [ ] Bread');
expect(result).toBe('- [x] Milk');
});
it('should keep current content when the incoming edit equals it', () => {
const result = mergeContentUpdate({
baseContent: null,
incomingContent: 'same',
currentContent: 'same'
});
expect(result).toBe('same');
});
it('should keep current content when the incoming edit matches the base (no local change)', () => {
const result = mergeContentUpdate({
baseContent: 'base',
incomingContent: 'base',
currentContent: 'server changed'
});
expect(result).toBe('server changed');
});
it('should take the incoming edit when the server is unchanged from the base', () => {
const result = mergeContentUpdate({
baseContent: 'base',
incomingContent: 'incoming change',
currentContent: 'base'
});
expect(result).toBe('incoming change');
});
});
+23 -6
View File
@@ -78,11 +78,28 @@ export function mergeContent(base: string, local: string, remote: string): strin
}
/**
* 2-way merge fallback when no base is available.
* Uses an empty string as the base, so all lines from both sides appear
* as additions. For conflicts (same region), prefers remote.
* Merge an incoming content snapshot into the current server content.
*
* Uses a 3-way merge when a base (common ancestor) is available. Without a base —
* e.g. the base snapshot was pruned, or the version was bumped by a metadata-only
* change that created no snapshot — a 3-way merge is impossible, so keep the
* incoming edit rather than silently discarding the user's changes.
*/
export function mergeContentTwoWay(local: string, remote: string): string {
if (local === remote) return remote;
return mergeContent('', local, remote);
export function mergeContentUpdate(input: {
baseContent: string | null;
incomingContent: string;
currentContent: string;
}): string {
const { baseContent, incomingContent, currentContent } = input;
if (incomingContent === currentContent) return currentContent;
if (baseContent === incomingContent) return currentContent;
if (baseContent === currentContent) return incomingContent;
if (baseContent === null) {
console.warn('[content-merge] no base snapshot for concurrent edit; keeping incoming content');
return incomingContent;
}
return mergeContent(baseContent, incomingContent, currentContent);
}
+73 -1
View File
@@ -1,5 +1,5 @@
import { test, expect, noteCard } from './helpers/fixtures.js';
import type { Page } from '@playwright/test';
import { devices, type Page } from '@playwright/test';
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
async function runTiptapCommand(page: Page, commandFn: string) {
@@ -13,6 +13,29 @@ async function runTiptapCommand(page: Page, commandFn: string) {
);
}
/**
* Read the `checked` attribute of the first task item from the live ProseMirror
* document. This reflects the editor's document state, not just the native
* checkbox widget — so it catches a widget/document desync where the DOM
* checkbox toggles but the transaction never updates the node.
*/
async function firstTaskItemChecked(page: Page): Promise<boolean> {
return page.getByTestId('tiptap-editor').evaluate((el) => {
const editor = (el as any).__tiptapEditor;
if (!editor) throw new Error('TipTap editor not found on element');
let checked: boolean | undefined;
editor.state.doc.descendants((node: any) => {
if (checked === undefined && node.type.name === 'taskItem') {
checked = Boolean(node.attrs.checked);
return false;
}
return true;
});
if (checked === undefined) throw new Error('No task item found in document');
return checked;
});
}
/** Toggle markdown mode via the overflow menu. */
async function toggleMarkdownMode(page: Page) {
await page.getByTestId('overflow-menu-btn').click();
@@ -109,3 +132,52 @@ test.describe('Rich text formatting', () => {
await expect(page.getByTestId('note-content-input')).toHaveValue('Hello **bold** world');
});
});
test.describe('Rich text formatting on mobile', () => {
const pixel7 = devices['Pixel 7'];
test.use({
viewport: pixel7.viewport,
userAgent: pixel7.userAgent,
deviceScaleFactor: pixel7.deviceScaleFactor,
isMobile: pixel7.isMobile,
hasTouch: pixel7.hasTouch
});
test('Scenario: Tapping a task-list checkbox does not focus the editor', async ({ authenticatedPage: page }) => {
// Given a regular text note contains a task list in the rich-text editor
await page.getByTestId('new-note-fab').click();
await page.getByTestId('mobile-overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
await page.getByTestId('note-content-input').fill('- [ ] Mobile task');
await page.getByTestId('mobile-overflow-menu-btn').click();
await page.getByTestId('markdown-toggle').click();
const richTextEditor = page.getByTestId('tiptap-editor').locator('.tiptap');
const checkbox = richTextEditor.locator('input[type="checkbox"]');
await expect(checkbox).not.toBeChecked();
await expect(richTextEditor).not.toBeFocused();
await richTextEditor.evaluate((element) => {
element.dataset.focusCount = '0';
element.addEventListener('focus', () => {
element.dataset.focusCount = String(Number(element.dataset.focusCount) + 1);
});
});
// When the checkbox is tapped, the change propagates to the editor's document
// without focus ever entering the editor (which would open the mobile keyboard)
await checkbox.tap();
await expect(checkbox).toBeChecked();
expect(await firstTaskItemChecked(page)).toBe(true);
await expect(richTextEditor).not.toBeFocused();
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
// And unchecking propagates to the document too, still without focusing the editor
await checkbox.tap();
await expect(checkbox).not.toBeChecked();
expect(await firstTaskItemChecked(page)).toBe(false);
await expect(richTextEditor).not.toBeFocused();
await expect(richTextEditor).toHaveAttribute('data-focus-count', '0');
});
});
+5
View File
@@ -104,6 +104,11 @@ export async function createNote(page: Page, title: string, content?: string) {
await editor.pressSequentially(content);
}
await page.getByTestId('close-editor-btn').click();
// Wait for the editor to fully unmount, not just start closing. The close is
// animated and dismisses via an async history.back() (see NoteEditor); returning
// before it settles lets a following page.goto race that navigation and abort it
// (net::ERR_ABORTED). The outer overlay is removed only when the close completes.
await expect(page.getByTestId('note-editor-overlay')).toHaveCount(0);
await expect(noteCard(page, title)).toBeVisible();
}
+34
View File
@@ -23,6 +23,40 @@ test.describe('Notes CRUD', () => {
await expect(page.getByText('Updated Title')).toBeVisible();
});
test('Scenario: Browser back closes the open editor instead of leaving the page', async ({ authenticatedPage: page }) => {
// Given a note titled "Back Closes Me" exists
await createNote(page, 'Back Closes Me', 'Some content');
// Given the note is open in the editor
await noteCard(page, 'Back Closes Me').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
// When the user navigates back
await page.goBack();
// Then the editor is closed and the notes list is still shown
await expect(page.getByTestId('note-editor')).not.toBeVisible();
await expect(noteCard(page, 'Back Closes Me')).toBeVisible();
});
test('Scenario: Closing the editor leaves browser history clean', async ({ authenticatedPage: page }) => {
// Given a note titled "Clean History" exists
await createNote(page, 'Clean History');
// Given the user opened and closed the note
await noteCard(page, 'Clean History').click();
await expect(page.getByTestId('note-editor')).toBeVisible();
await page.getByTestId('close-editor-btn').click();
await expect(page.getByTestId('note-editor')).not.toBeVisible();
// When the user navigates back
await page.goBack();
// Then the editor does not reopen and the note's URL is gone from history
await expect(page.getByTestId('note-editor')).not.toBeVisible();
expect(page.url()).not.toContain('#');
});
test('Scenario: Trashed note disappears from the main view', async ({ authenticatedPage: page }) => {
// Given a note titled "Delete Me" exists
await createNote(page, 'Delete Me');
+80
View File
@@ -31,4 +31,84 @@ test.describe('Search', () => {
// Then no notes are displayed
await expect(page.getByTestId('note-card')).toHaveCount(0);
});
test('Scenario: Background sync preserves active search results', async ({ authenticatedPage: page }) => {
await createNote(page, 'Sync Search Target 3812925');
await createNote(page, 'Sync Search Other 3812925');
await page.getByTestId('search-input').fill('Sync Search Target 3812925');
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
const notesReloaded = page.waitForResponse(
(response) => response.url().includes('/api/notes?filter=all') && response.ok()
);
await page.getByTestId('sync-indicator').click();
await notesReloaded;
await expect(page.getByText('Sync Search Target 3812925')).toBeVisible();
await expect(page.getByText('Sync Search Other 3812925')).not.toBeVisible();
});
test('Scenario: A stale response cannot replace newer search results', async ({ authenticatedPage: page }) => {
await createNote(page, 'Race Slow 3812925');
await createNote(page, 'Race Fast 3812925');
const slowResponse = await page.request.get('/api/search?q=Race%20Slow%203812925');
const fastResponse = await page.request.get('/api/search?q=Race%20Fast%203812925');
const slowResults = await slowResponse.json();
const fastResults = await fastResponse.json();
await page.route('**/api/search?*', async (route) => {
const requestQuery = new URL(route.request().url()).searchParams.get('q');
if (requestQuery === 'Race Slow 3812925') {
await new Promise((resolve) => setTimeout(resolve, 250));
await route.fulfill({ json: slowResults });
return;
}
await route.fulfill({ json: fastResults });
});
const searchInput = page.getByTestId('search-input');
const slowRequestStarted = page.waitForRequest(
(request) => new URL(request.url()).searchParams.get('q') === 'Race Slow 3812925'
);
await searchInput.fill('Race Slow 3812925');
await slowRequestStarted;
await searchInput.fill('Race Fast 3812925');
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
await page.waitForTimeout(300);
await expect(page.getByText('Race Fast 3812925')).toBeVisible();
await expect(page.getByText('Race Slow 3812925')).not.toBeVisible();
});
test('Scenario: Current notes stay visible while a search request is in flight', async ({ authenticatedPage: page }) => {
// Given two notes exist
await createNote(page, 'Flash Alpha 771903');
await createNote(page, 'Flash Beta 771903');
// And the search request is held open before it can respond
let releaseSearch: () => void = () => {};
const searchGate = new Promise<void>((resolve) => {
releaseSearch = resolve;
});
await page.route('**/api/search?*', async (route) => {
await searchGate;
await route.continue();
});
// When the user starts a search
await page.getByTestId('search-input').fill('Flash Alpha 771903');
// Then the current notes stay on screen instead of flashing an empty state
await expect(page.getByText('Flash Beta 771903')).toBeVisible();
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
// And once the results arrive, only the match remains
releaseSearch();
await expect(page.getByText('Flash Alpha 771903')).toBeVisible();
await expect(page.getByText('Flash Beta 771903')).not.toBeVisible();
});
});
+1 -1
View File
@@ -292,7 +292,7 @@
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
</ul>
<span class="footer-version">v0.29.0</span>
<span class="footer-version">v0.30.1</span>
</div>
</footer>