fix(search): keep result paths out of the bookmarks clone (closes #10021)

Bookmarking a note clones it under `_lbBookmarks`, so every descendant gains a
second note path running through `_hidden`. Searching creates an ad-hoc search
note under `_hidden/_search` and activates it. Result cards address their target
by bare note id and leave the path to the client, and `getSortedNotePathRecords`
compared the active note's path prefix before every other criterion. The real
path shares only `root` with the active search note while the bookmark clone
shares `root/_hidden`, so two segments beat one and the hidden check below never
ran. The card opened the note under Bookmarks, and in grid view the breadcrumb
said so too.

The tiebreaker comes from #7552, where it picks the nearest clone so that
opening a note keeps the user in the subtree they came from. Every candidate
there is an ordinary clone, so ranking it below the hoisted, archived, hidden
and search comparisons preserves that and only stops it from overruling them.

The server was never wrong here: `compareNotePathRecords` ranks hidden paths
last and takes no active path. The client threw that answer away and re-derived
the path from froca.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Elian Doran 2026-09-11 23:21:23 +02:00
parent 7edff12d9b
commit cd381e09fe
No known key found for this signature in database
2 changed files with 48 additions and 10 deletions

View File

@ -714,6 +714,33 @@ describe("FNote paths & hierarchy", () => {
expect(leaf.getBestNotePathString()).toContain("sharedLeaf");
});
it("keeps a bookmark clone out of the best path while a hidden note is active", () => {
// Bookmarking clones the note under `_lbBookmarks`, so its children gain a second path
// through `_hidden`. A search activates a search note that lives under `_hidden` as well,
// and that shared prefix must not promote the bookmark path over the real one.
const root = froca.notes["root"] ?? buildNote({ id: "root", title: "root" });
const hidden = froca.notes["_hidden"] ?? buildNote({ id: "_hidden", title: "Hidden" });
wireChild(root, hidden);
let launchBarParent = hidden;
for (const id of ["_lbRoot", "_lbVisibleLaunchers", "_lbBookmarks"]) {
const launcher = buildNote({ id, title: id });
wireChild(launchBarParent, launcher);
launchBarParent = launcher;
}
const inbox = buildNote({ id: "bmInbox", title: "Inbox" });
wireChild(root, inbox);
wireChild(launchBarParent, inbox);
const leaf = buildNote({ id: "bmLeaf", title: "ThisIsTest" });
wireChild(inbox, leaf);
expect(leaf.getAllNotePaths()).toHaveLength(2);
expect(leaf.getBestNotePath("root", "root/_hidden/_search/202609/searchNoteId"))
.toEqual(["root", "bmInbox", "bmLeaf"]);
});
it("getSortedNotePathRecords orders by archived / hidden / search / length without an active path", () => {
const leaf = froca.notes["sharedLeaf"];
expect(leaf).toBeDefined();
@ -1269,6 +1296,14 @@ function makeBlob(content: string | undefined): FBlob {
});
}
/** Wires one froca note as a child of another, the way a branch does in the application. */
function wireChild(parent: FNote, child: FNote) {
const branchId = `br-${parent.noteId}-${child.noteId}`;
registerBranch(branchId, child.noteId, parent.noteId, 0);
parent.addChild(child.noteId, branchId, false);
child.addParent(parent.noteId, branchId, false);
}
function registerBranch(branchId: string, noteId: string, parentNoteId: string, notePosition: number | undefined) {
froca.branches[branchId] = new FBranch(froca, {
branchId,

View File

@ -453,16 +453,6 @@ export default class FNote {
};
notePaths.sort((a, b) => {
if (activeNotePath) {
const activeSegments = activeNotePath.split('/');
const aOverlap = prefixMatchLength(a.notePath, activeSegments);
const bOverlap = prefixMatchLength(b.notePath, activeSegments);
// Paths with more matching prefix segments are prioritized
// when the match count is equal, other criteria are used for sorting
if (bOverlap !== aOverlap) {
return bOverlap - aOverlap;
}
}
if (a.isInHoistedSubTree !== b.isInHoistedSubTree) {
return a.isInHoistedSubTree ? -1 : 1;
} else if (a.isArchived !== b.isArchived) {
@ -474,6 +464,19 @@ export default class FNote {
return a.isSearch ? 1 : -1;
}
/* v8 ignore stop */
if (activeNotePath) {
// Among otherwise equal paths, the one sharing the longest prefix with the active
// note wins, so opening a clone keeps the user where they came from. The checks
// above outrank it: an active `_hidden` note must not promote a bookmark clone.
const activeSegments = activeNotePath.split("/");
const aOverlap = prefixMatchLength(a.notePath, activeSegments);
const bOverlap = prefixMatchLength(b.notePath, activeSegments);
if (bOverlap !== aOverlap) {
return bOverlap - aOverlap;
}
}
return a.notePath.length - b.notePath.length;
});