From 219f20818a01a019a2d92e87f7d15b7087318e80 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Thu, 12 Nov 2026 22:12:09 +0700 Subject: [PATCH 1/2] feat: find in the note with Ctrl+F; search notes moves to Ctrl+Shift+F Ctrl+F opens a find bar over the note. Matches are highlighted as you type, the bar counts them, Enter and Shift+Enter step through them, and the current one pops in a yellow lozenge and scrolls into view, like the macOS find indicator. Esc or Done closes the bar with the match selected. Highlights use the CSS Custom Highlight API, so the note's HTML, saving and undo never see them. Search notes moves to Ctrl+Shift+F (Alt+2 still works). A note opened from the search results scrolls to its first match and shows every match with a faint highlight, until it is edited or the search changes. Keyboard focus in the notes list now shows as a yellow ring instead of a gray fill, so it no longer looks like the row under the mouse. --- README.md | 3 +- extension/app.js | 15 +++ extension/find.js | 214 ++++++++++++++++++++++++++++++++++++++++++ extension/hotkeys.js | 3 +- extension/notes.css | 17 +++- extension/notes.html | 11 +++ tests-e2e/app.e2e.js | 18 +++- tests/find.test.js | 45 +++++++++ tests/hotkeys.test.js | 7 +- 9 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 extension/find.js create mode 100644 tests/find.test.js diff --git a/README.md b/README.md index 365b895..0f1dd11 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ Press `Ctrl+/` (`⌘/` on Mac) for the full list, grouped by topic. Every button |---|---|---| | New note | `Alt+N` | `⌥N` | | Open Sprints | `Alt+S` | `⌥S` | -| Search notes | `Ctrl+F` | `⌘F` | +| Find in the note (Enter / Shift+Enter: next / previous, Esc: done) | `Ctrl+F` | `⌘F` | +| Search notes; the note you open shows the matches | `Ctrl+Shift+F` | `⇧⌘F` | | Go to the notes list / the note | `Alt+1` / `Alt+3` | `⌥1` / `⌥3` | | Text styles menu | `Alt+A` | `⌥A` | | Checklist | `Ctrl+Shift+L` | `⇧⌘L` | diff --git a/extension/app.js b/extension/app.js index 3147143..9d5f189 100644 --- a/extension/app.js +++ b/extension/app.js @@ -5,6 +5,7 @@ import { mergeTrackers, newBoardNote } from "./board-merge.js"; import { boardSearchText, findSprint, findTask, mapTasks, removeSprint, updateSprint } from "./board.js"; import { createBoardView } from "./board-view.js"; import { NoteEditor } from "./editor.js"; +import { createFind } from "./find.js"; import { formatEditedDate } from "./format.js"; import { applyShortcutTitles, matchHotkey, setCustomKeys } from "./hotkeys.js"; import { setupLayout } from "./layout.js"; @@ -48,6 +49,7 @@ const written = new Map(); const saves = createSaveScheduler({ delayMs: SAVE_DELAY_MS, maxWaitMs: SAVE_MAX_WAIT_MS, save: saveNow }); const state = { currentId: null, mode: "notes", query: "", ui: null, settings: null }; let editor; +let find; let taskEditor; let layout; let boardView; @@ -84,6 +86,7 @@ async function main() { showTheme(state.ui.theme); await removeStaleNotes(); editor = new NoteEditor({ element: byId("editor"), isMac: IS_MAC, onChange: onEditorChange }); + find = createFind({ bar: byId("find-bar"), editorElement: byId("editor"), scroller: byId("editor-scroll") }); layout = setupLayout({ resizer: byId("resizer"), ui: state.ui, onUiChange: saveUiSoon }); const taskNote = byId("task-note"); taskEditor = new NoteEditor({ element: taskNote, isMac: IS_MAC, onChange: onTaskNoteChange }); @@ -208,6 +211,7 @@ function selectNote(id, { scrollTop = 0, reveal = true } = {}) { loadEditor(notes.get(id)); renderAll(); byId("editor-scroll").scrollTop = scrollTop; + find.showSearchMatches(state.query); if (reveal) layout.showEditor(); saveUiSoon({ lastNoteId: id, scrollTop }); navigation.replace(); @@ -282,9 +286,14 @@ function renderEditorPane({ whenIdle = false } = {}) { document.body.classList.toggle("has-note", Boolean(note)); document.body.classList.toggle("viewing-deleted", inDeleted); editor.setReadOnly(inDeleted || !note); + if (isPlainNote(note)) find.refresh(); + else find.close(); renderNoteDetails(note, { whenIdle }); } +// The Sprints note shows its board, which find does not search. +const isPlainNote = (note) => Boolean(note) && !note.board; + function renderNoteDetails(note, { whenIdle = false } = {}) { const board = note?.board ?? null; document.body.classList.toggle("board-view", board !== null); @@ -413,6 +422,7 @@ function openCreated(note) { function clearSearch() { state.query = ""; byId("search").value = ""; + find.clearSearchMatches(); } function togglePin() { @@ -653,6 +663,7 @@ function wireSidebar() { wireNoteDragging(); byId("search").addEventListener("input", (event) => { state.query = event.target.value.trim().toLowerCase(); + find.clearSearchMatches(); renderList(); }); } @@ -749,6 +760,10 @@ function clearDropMarks() { // Each returns false when it does not apply here, so the key keeps its normal meaning. const APP_HOTKEYS = { + findInNote: () => isPlainNote(notes.get(state.currentId)) && openNote(() => { + layout.showEditor(); + find.open(); + }), search: () => showListFrom(() => { layout.showList(); byId("search").focus(); diff --git a/extension/find.js b/extension/find.js new file mode 100644 index 0000000..564317a --- /dev/null +++ b/extension/find.js @@ -0,0 +1,214 @@ +import { LINE_SELECTOR } from "./lines.js"; + +const LOZENGE_PADDING_PX = 3; +const BOUNCE_DURATION_MS = 900; +// Grow, settle, hold, then fade, like the macOS find indicator. +const BOUNCE_KEYFRAMES = [ + { transform: "scale(1)", opacity: 1 }, + { transform: "scale(1.25)", opacity: 1, offset: 0.15 }, + { transform: "scale(1)", opacity: 1, offset: 0.35 }, + { transform: "scale(1)", opacity: 1, offset: 0.75 }, + { transform: "scale(1)", opacity: 0 }, +]; +const REDUCED_MOTION = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)"); + +const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const TEXT_NODE = 3; + +const textNodes = (node) => (node.nodeType === TEXT_NODE ? [node] : [...node.childNodes].flatMap(textNodes)); + +/** + * Every case-insensitive occurrence of `query` in the text under `root`, in document order. + * A match never crosses from one line into the next. Each is `{ start, end }`, both + * `{ node, offset }` positions in text nodes, ready for a Range. + */ +export function findMatches(root, query) { + if (query === "") return []; + const pattern = new RegExp(escapeRegExp(query), "giu"); + return lineRuns(root).flatMap((run) => matchesInRun(run, pattern)); +} + +// Consecutive text nodes on the same line, so a match can span bold or a link but not a line break. +function lineRuns(root) { + return textNodes(root).reduce((runs, node) => { + const line = node.parentElement.closest(LINE_SELECTOR); + const last = runs.at(-1); + if (last?.line === line) last.nodes.push(node); + else runs.push({ line, nodes: [node] }); + return runs; + }, []); +} + +function matchesInRun(run, pattern) { + const text = run.nodes.map((node) => node.data).join(""); + return [...text.matchAll(pattern)].map((match) => ({ + start: locate(run.nodes, { index: match.index, atEnd: false }), + end: locate(run.nodes, { index: match.index + match[0].length, atEnd: true }), + })); +} + +// An index on the boundary of two nodes is the end of the first or the start of the second. +function locate(nodes, { index, atEnd }) { + let nodeStart = 0; + for (const node of nodes) { + const nodeEnd = nodeStart + node.data.length; + if (atEnd ? index <= nodeEnd : index < nodeEnd) return { node, offset: index - nodeStart }; + nodeStart = nodeEnd; + } + const last = nodes.at(-1); + return { node: last, offset: last.data.length }; +} + +function toRange({ start, end }) { + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + return range; +} + +// The CSS Custom Highlight API paints matches without touching the note's HTML, so saving and undo never see them. +function paint(name, ranges) { + if (!globalThis.CSS?.highlights) return; + if (ranges.length === 0) CSS.highlights.delete(name); + else CSS.highlights.set(name, new Highlight(...ranges)); +} + +function scrollToRange(scroller, range) { + const match = range.getBoundingClientRect(); + if (match.height === 0) return; + const view = scroller.getBoundingClientRect(); + if (match.top >= view.top && match.bottom <= view.bottom) return; + scroller.scrollTop += match.top - view.top - (view.height - match.height) / 2; +} + +// A copy of the matched text in a yellow lozenge pops over the match, then fades. +function bounce(range) { + if (REDUCED_MOTION?.matches) return; + const box = range.getBoundingClientRect(); + if (box.height === 0) return; + const lozenge = document.createElement("span"); + lozenge.className = "find-lozenge"; + lozenge.append(range.cloneContents()); + Object.assign(lozenge.style, { + left: `${box.left - LOZENGE_PADDING_PX}px`, + top: `${box.top}px`, + height: `${box.height}px`, + lineHeight: `${box.height}px`, + font: getComputedStyle(range.startContainer.parentElement).font, + }); + document.body.append(lozenge); + lozenge.animate(BOUNCE_KEYFRAMES, { duration: BOUNCE_DURATION_MS, easing: "ease-out" }) + .addEventListener("finish", () => lozenge.remove()); +} + +function countText(total, index) { + if (total === 0) return "Not found"; + return `${index + 1} of ${total}`; +} + +/** + * The find bar over the note: Ctrl+F opens it, Enter and Shift+Enter step through matches, Esc + * or Done close it and leave the current match selected. It also shows a notes search's matches + * in the note opened from the results. + */ +export function createFind({ bar, editorElement, scroller }) { + const input = bar.querySelector(".find-input"); + const count = bar.querySelector(".find-count"); + let ranges = []; + let index = 0; + + const isOpen = () => !bar.hidden; + const current = () => ranges[index] ?? null; + + function update() { + ranges = findMatches(editorElement, input.value).map(toRange); + index = Math.min(index, Math.max(ranges.length - 1, 0)); + paint("find-match", ranges); + paint("find-current", ranges.length > 0 ? [current()] : []); + count.textContent = input.value === "" ? "" : countText(ranges.length, index); + } + + function reveal() { + const range = current(); + if (!range) return; + scrollToRange(scroller, range); + bounce(range); + } + + function step(direction) { + if (ranges.length === 0) return; + index = (index + direction + ranges.length) % ranges.length; + update(); + reveal(); + } + + function open() { + clearSearchMatches(); + bar.hidden = false; + input.focus(); + input.select(); + update(); + reveal(); + } + + function close() { + if (!isOpen()) return; + bar.hidden = true; + ranges = []; + paint("find-match", []); + paint("find-current", []); + } + + // Like macOS, the editor gets back the focus with the current match selected. + function finish() { + const range = current(); + close(); + editorElement.focus(); + if (!range) return; + document.getSelection().removeAllRanges(); + document.getSelection().addRange(range); + } + + // A note just opened from the search results scrolls to its first match. + function showSearchMatches(query) { + const found = findMatches(editorElement, query).map(toRange); + paint("search-match", found); + if (found.length > 0) scrollToRange(scroller, found[0]); + } + + function clearSearchMatches() { + paint("search-match", []); + } + + function onInputKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + finish(); + } else if (event.key === "Enter") { + event.preventDefault(); + step(event.shiftKey ? -1 : 1); + } + } + + input.addEventListener("input", () => { + index = 0; + update(); + reveal(); + }); + input.addEventListener("keydown", onInputKeydown); + bar.querySelector(".find-previous").addEventListener("click", () => step(-1)); + bar.querySelector(".find-next").addEventListener("click", () => step(1)); + bar.querySelector(".find-done").addEventListener("click", finish); + editorElement.addEventListener("input", () => { + clearSearchMatches(); + if (isOpen()) update(); + }); + + // The note under the bar changed: its matches are found again where they now are. + const refresh = () => { + if (isOpen()) update(); + }; + + return { open, close, refresh, showSearchMatches, clearSearchMatches }; +} diff --git a/extension/hotkeys.js b/extension/hotkeys.js index 3465fe0..545839f 100644 --- a/extension/hotkeys.js +++ b/extension/hotkeys.js @@ -22,6 +22,7 @@ const DEFAULT_HOTKEYS = [ // Word's key; nothing selected changes the word at the caret. { group: "Editing", action: "changeCase", label: "Change case: lower, UPPER, Title", shift: true, code: "F3" }, { group: "Editing", action: "pastePlain", label: "Paste as plain text", mod: true, shift: true, code: "KeyV", native: true }, + { group: "Editing", action: "findInNote", label: "Find in note", mod: true, code: "KeyF" }, // VS Code's line keys. { group: "Lines", action: "copyLine", label: "Copy line (nothing selected)", mod: true, code: "KeyC", native: true }, { group: "Lines", action: "cutLine", label: "Cut line (nothing selected)", mod: true, code: "KeyX", native: true }, @@ -43,7 +44,7 @@ const DEFAULT_HOTKEYS = [ { group: "Notes", action: "deleteNote", label: "Delete note", mod: true, shift: true, code: "Backspace" }, { group: "Notes", action: "recover", label: "Recover deleted note", alt: true, code: "KeyU" }, { group: "Navigate", action: "focusList", label: "Go to notes list", alt: true, code: "Digit1" }, - { group: "Navigate", action: "search", label: "Search notes", mod: true, code: "KeyF" }, + { group: "Navigate", action: "search", label: "Search notes", mod: true, shift: true, code: "KeyF" }, { group: "Navigate", action: "search", label: "Search notes", alt: true, code: "Digit2" }, { group: "Navigate", action: "focusEditor", label: "Go to note", alt: true, code: "Digit3" }, { group: "Navigate", action: "toggleSidebar", label: "Show / hide notes list", alt: true, code: "Digit0" }, diff --git a/extension/notes.css b/extension/notes.css index 4df6507..82e161d 100644 --- a/extension/notes.css +++ b/extension/notes.css @@ -101,8 +101,8 @@ button:disabled { cursor: default; } .note-row:hover { background: var(--hover); } .note-row[aria-current="true"]::before, .note-row[aria-current="true"] + .note-row::before { display: none; } .note-row[aria-current="true"] { background: var(--selected); } -/* The card clips an outline, so keyboard focus shows as a fill (or a ring on the open note). */ -.note-row:focus-visible { outline: none; background: var(--fill); } +/* The card clips an outline, so keyboard focus shows as an inset ring; gray is left to the mouse. */ +.note-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent); } .note-row[aria-current="true"]:focus-visible { background: var(--selected); box-shadow: inset 0 0 0 2px var(--accent); } .note-row.dragging { opacity: 0.45; } .note-row.drop-before { box-shadow: inset 0 3px 0 var(--accent) !important; } @@ -135,6 +135,15 @@ body.viewing-deleted #format-button, body.viewing-deleted #checklist-button, body.viewing-deleted #pin-button, body:not(.viewing-deleted) #recover-button { display: none; } +.find-bar { display: flex; align-items: center; gap: 0.25rem; padding: 0 0.75rem 0.5rem; } +.find-input { flex: 1; min-width: 0; padding: 0.35rem 0.75rem; border: 0; border-radius: 0.6rem; background: var(--fill); color: var(--text); font: inherit; } +.find-input:focus { outline: 2px solid var(--accent); outline-offset: -2px; } +.find-count { padding: 0 0.25rem; color: var(--secondary); font-size: 0.85rem; font-variant-numeric: tabular-nums; white-space: nowrap; } +/* Same yellows in both themes, as in macOS; the current match stays readable with black text. */ +::highlight(find-match) { background-color: rgb(255 214 10 / 0.4); } +::highlight(find-current) { background-color: #ffd60a; color: #000000; } +::highlight(search-match) { background-color: rgb(255 214 10 / 0.3); } +.find-lozenge { position: fixed; z-index: 10; padding: 0 3px; border-radius: 4px; background: #ffd60a; color: #000000; box-shadow: 0 1px 4px rgb(0 0 0 / 0.4); white-space: pre; pointer-events: none; } /* A 46rem column keeps lines readable on wide screens. */ .editor-scroll { flex: 1; overflow-y: auto; padding: 0 max(clamp(1.5rem, 5vw, 3rem), (100% - 46rem) / 2) 4rem; } .note-date { margin: 0.25rem 0 1rem; text-align: center; font-size: 0.8rem; color: var(--secondary); } @@ -353,7 +362,7 @@ label.form-row > input[type="text"]:focus { background: var(--fill); outline: no .note-row[aria-current="true"] { background: none; } .note-row[aria-current="true"]::before, .note-row[aria-current="true"] + .note-row::before { display: block; } .note-row[aria-current="true"]:hover { background: var(--hover); } - .note-row[aria-current="true"]:focus-visible { background: var(--fill); box-shadow: none; } + .note-row[aria-current="true"]:focus-visible { background: none; } } /* Motion after iPhone Notes: a step deeper slides the new screen in from the right while the old @@ -368,6 +377,7 @@ label.form-row > input[type="text"]:focus { background: var(--fill); outline: no /* Opaque, so a sliding screen covers the one under it. */ .editor-pane, .toolbar { background: var(--bg); } ::view-transition-old(root), ::view-transition-new(root) { animation: none; } + .find-bar:not([hidden]) { animation: 150ms var(--ease-ios) find-bar-in; } ::view-transition-group(*) { animation-duration: var(--step-duration); animation-timing-function: var(--ease-ios); } html[data-nav="forward"]::view-transition-new(pane) { animation: var(--step-duration) var(--ease-ios) both slide-in-from-right; box-shadow: -0.5rem 0 1.5rem rgb(0 0 0 / 0.2); } html[data-nav="forward"]::view-transition-old(pane) { animation: var(--step-duration) var(--ease-ios) both drift-out-left; } @@ -403,6 +413,7 @@ label.form-row > input[type="text"]:focus { background: var(--fill); outline: no .format-menu:popover-open { opacity: 0; transform: scale(0.9); } } +@keyframes find-bar-in { from { transform: translateY(-0.75rem); opacity: 0; } } @keyframes slide-in-from-right { from { transform: translateX(100%); } } @keyframes slide-out-to-right { to { transform: translateX(100%); } } @keyframes drift-out-left { to { transform: translateX(-30%); filter: brightness(0.8); } } diff --git a/extension/notes.html b/extension/notes.html index b140fff..08bd9e0 100644 --- a/extension/notes.html +++ b/extension/notes.html @@ -55,6 +55,17 @@

Notes

+

diff --git a/tests-e2e/app.e2e.js b/tests-e2e/app.e2e.js index d4b5aa0..8a6f228 100644 --- a/tests-e2e/app.e2e.js +++ b/tests-e2e/app.e2e.js @@ -59,16 +59,30 @@ test("Alt+N adds a note and Alt+1 / Alt+3 move between the list and the note", a await app.waitFor("return document.activeElement?.closest('#editor') !== null"); }); -test("Ctrl+/ opens and closes the shortcut list; Ctrl+F goes to search", async () => { +test("Ctrl+/ opens and closes the shortcut list; Ctrl+Shift+F goes to search", async () => { await app.press({ mod: true, code: "Slash" }); await app.waitFor("return document.getElementById('help').open"); await app.press({ mod: true, code: "Slash" }); await app.waitFor("return !document.getElementById('help').open"); - await app.press({ mod: true, code: "KeyF" }); + await app.press({ mod: true, shift: true, code: "KeyF" }); await app.waitFor("return document.activeElement?.id === 'search'"); await app.press({ code: "Escape" }); }); +test("Ctrl+F finds in the note, Enter steps and Esc leaves the match selected", async () => { + await newNoteInEditor("find me, then find me again"); + await app.press({ mod: true, code: "KeyF" }); + await app.waitFor("return document.activeElement?.classList.contains('find-input')"); + await app.type("find"); + await app.waitFor("return document.querySelector('.find-count').textContent === '1 of 2'"); + await app.press({ code: "Enter" }); + await app.waitFor("return document.querySelector('.find-count').textContent === '2 of 2'"); + await app.press({ code: "Escape" }); + await app.waitFor("return document.getElementById('find-bar').hidden"); + assert.equal(await app.execute("return document.getSelection().toString()"), "find"); + assert.equal(await app.execute("return document.activeElement?.id"), "editor"); +}); + test("Ctrl+Z undoes typing", async () => { await newNoteInEditor("undo me"); const typed = await app.execute("return document.getElementById('editor').innerText"); diff --git a/tests/find.test.js b/tests/find.test.js new file mode 100644 index 0000000..cda3b2e --- /dev/null +++ b/tests/find.test.js @@ -0,0 +1,45 @@ +import "./dom.js"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseHtml } from "../extension/model.js"; +import { findMatches } from "../extension/find.js"; + +const note = (html) => parseHtml(html).body; +const spans = (matches) => matches.map(({ start, end }) => [start.node.data, start.offset, end.node.data, end.offset]); + +test("finds every match, ignoring case, in document order", () => { + const body = note("

Plan

plan the PLAN

"); + assert.deepEqual(spans(findMatches(body, "plan")), [ + ["Plan", 0, "Plan", 4], + ["plan the PLAN", 0, "plan the PLAN", 4], + ["plan the PLAN", 9, "plan the PLAN", 13], + ["a plan", 2, "a plan", 6], + ]); +}); + +test("a match can span bold or a link on one line", () => { + const body = note("

release

"); + assert.deepEqual(spans(findMatches(body, "release")), [["re", 0, "se", 2]]); +}); + +test("a match ending at the end of a node ends there, not at the next node's start", () => { + const body = note("

tag v1

"); + assert.deepEqual(spans(findMatches(body, "tag")), [["tag", 0, "tag", 3]]); +}); + +test("a match never runs from one line into the next", () => { + const body = note("

foo

bar

"); + assert.deepEqual(findMatches(body, "foobar"), []); + assert.equal(findMatches(body, "bar").length, 1); +}); + +test("the query is plain text, not a pattern", () => { + const body = note("

a.b axb (x) [y]

"); + assert.equal(findMatches(body, "a.b").length, 1); + assert.equal(findMatches(body, "(x)").length, 1); + assert.equal(findMatches(body, "[y]").length, 1); +}); + +test("an empty query finds nothing", () => { + assert.deepEqual(findMatches(note("

text

"), ""), []); +}); diff --git a/tests/hotkeys.test.js b/tests/hotkeys.test.js index 4de3fa6..8bbe77f 100644 --- a/tests/hotkeys.test.js +++ b/tests/hotkeys.test.js @@ -26,11 +26,12 @@ test("Chrome-reserved shortcuts use Alt fallbacks that avoid Windows' Alt+Shift assert.equal(matchHotkey(key("KeyN", { altKey: true }), false), "newNote"); }); -test("undo, both redo forms and search are matched", () => { +test("undo, both redo forms, find in note and search are matched", () => { assert.equal(matchHotkey(key("KeyZ", { ctrlKey: true }), false), "undo"); assert.equal(matchHotkey(key("KeyZ", { ctrlKey: true, shiftKey: true }), false), "redo"); assert.equal(matchHotkey(key("KeyY", { ctrlKey: true }), false), "redo"); - assert.equal(matchHotkey(key("KeyF", { ctrlKey: true }), false), "search"); + assert.equal(matchHotkey(key("KeyF", { ctrlKey: true }), false), "findInNote"); + assert.equal(matchHotkey(key("KeyF", { ctrlKey: true, shiftKey: true }), false), "search"); }); test("shortcuts the browser already handles are not matched", () => { @@ -86,7 +87,7 @@ test("every button's shortcut is matched", () => { test("tooltips name each button's shortcut for the platform", () => { assert.equal(fillShortcuts("Delete note ({deleteNote})", false), "Delete note (Ctrl+Shift+Backspace)"); assert.equal(fillShortcuts("Delete note ({deleteNote})", true), "Delete note (⌘⇧⌫)"); - assert.equal(fillShortcuts("Search ({search}) · settings ({trackerSettings})", false), "Search (Ctrl+F) · settings (Ctrl+,)"); + assert.equal(fillShortcuts("Search ({search}) · settings ({trackerSettings})", false), "Search (Ctrl+Shift+F) · settings (Ctrl+,)"); }); test("every shortcut has a group and each group is listed in one run, so help shows it once", () => { From b107ae41ca15d77e486a6ee92c2c736f6c319ec1 Mon Sep 17 00:00:00 2001 From: iam-truongtrungnghia Date: Thu, 12 Nov 2026 22:12:09 +0700 Subject: [PATCH 2/2] chore: release 0.10.0 --- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 08e219e..868ae56 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notelet" -version = "0.9.0" +version = "0.10.0" description = "Lightweight notes with sprint tracking" edition = "2021" publish = false diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index b69a338..648198c 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Notelet", - "version": "0.9.0", + "version": "0.10.0", "identifier": "com.ttncode.notelet", "build": { "frontendDist": "../extension" diff --git a/package-lock.json b/package-lock.json index 5b35bfd..e3b495c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notelet", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notelet", - "version": "0.9.0", + "version": "0.10.0", "devDependencies": { "@tauri-apps/cli": "^2.12.4", "linkedom": "^0.18.13" diff --git a/package.json b/package.json index 1080551..7957645 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notelet", - "version": "0.9.0", + "version": "0.10.0", "private": true, "type": "module", "scripts": {