From e9fadc0584bc0e902f7538542c12acb93605de73 Mon Sep 17 00:00:00 2001
From: j4rviscmd
Date: Sat, 28 Mar 2026 09:28:15 +0900
Subject: [PATCH 1/2] feat: add duplicate note via sidebar context menu
Add a "Duplicate" option to the note context menu that creates a copy
of the selected note. The Rust-side `duplicate_note` command handles
title suffixing, group inheritance and H1 heading rewrite in a single
IPC round-trip.
Co-Authored-By: Claude Opus 4.6
---
src-tauri/src/db.rs | 115 ++++++++++++++++++++----
src-tauri/src/lib.rs | 7 +-
src/App.tsx | 27 +++++-
src/features/editor/api/notes.ts | 13 +++
src/features/editor/index.ts | 1 +
src/features/sidebar/ui/NoteItem.tsx | 32 +++++--
src/features/sidebar/ui/NoteSidebar.tsx | 89 +++++++++++++++---
7 files changed, 238 insertions(+), 46 deletions(-)
diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs
index 2c44331..413c085 100644
--- a/src-tauri/src/db.rs
+++ b/src-tauri/src/db.rs
@@ -112,26 +112,16 @@ pub fn init_db(app: &tauri::AppHandle) -> Result<(), String> {
.map_err(|e| format!("failed to init schema: {e}"))?;
// Migration: add is_pinned column if it does not exist (existing databases).
- if conn
- .execute(
- "ALTER TABLE notes ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0",
- [],
- )
- .is_err()
- {
- // Column already exists — ignore the error.
- }
+ let _ = conn.execute(
+ "ALTER TABLE notes ADD COLUMN is_pinned INTEGER NOT NULL DEFAULT 0",
+ [],
+ );
// Migration: add group_id column if it does not exist.
- if conn
- .execute(
- "ALTER TABLE notes ADD COLUMN group_id TEXT REFERENCES groups(id) ON DELETE SET NULL",
- [],
- )
- .is_err()
- {
- // Column already exists — ignore the error.
- }
+ let _ = conn.execute(
+ "ALTER TABLE notes ADD COLUMN group_id TEXT REFERENCES groups(id) ON DELETE SET NULL",
+ [],
+ );
app.manage(DbState(Mutex::new(conn)));
Ok(())
@@ -344,3 +334,92 @@ pub fn delete_note(state: tauri::State, id: String) -> Result<(), Strin
.map_err(|e| e.to_string())?;
Ok(())
}
+
+/// Duplicates an existing note, copying its content and group membership.
+///
+/// The duplicated note receives a new UUID, a title suffixed with " (copy)",
+/// `is_pinned = false`, and the same `group_id` as the original.
+///
+/// # Arguments
+///
+/// * `state` - Managed database state injected by Tauri.
+/// * `id` - The UUID of the note to duplicate.
+///
+/// # Returns
+///
+/// The newly created [`Note`].
+///
+/// # Errors
+///
+/// Returns a `String` if the database lock is poisoned, the source note is
+/// not found, or the INSERT fails.
+#[tauri::command]
+pub fn duplicate_note(state: tauri::State, id: String) -> Result {
+ let conn = state.0.lock().map_err(|e| e.to_string())?;
+
+ let source = conn
+ .query_row(
+ "SELECT id, title, content, created_at, updated_at, is_pinned, group_id FROM notes WHERE id = ?1",
+ rusqlite::params![id],
+ note_from_row,
+ )
+ .map_err(|e| e.to_string())?;
+
+ let new_id = uuid::Uuid::new_v4().to_string();
+ let now = chrono::Utc::now().to_rfc3339();
+ let title = format!("{} (copy)", source.title);
+
+ // Update the first heading block's text in the content JSON so the H1
+ // heading also carries the "(copy)" suffix, matching the sidebar label.
+ let content = rewrite_first_heading(&source.content, &title);
+
+ conn.execute(
+ "INSERT INTO notes (id, title, content, created_at, updated_at, is_pinned, group_id) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6)",
+ rusqlite::params![new_id, title, content, now, now, source.group_id],
+ )
+ .map_err(|e| e.to_string())?;
+
+ Ok(Note {
+ id: new_id,
+ title,
+ content,
+ created_at: now.clone(),
+ updated_at: now,
+ is_pinned: false,
+ group_id: source.group_id,
+ })
+}
+
+/// Rewrites the text of the first heading block in a BlockNote content JSON.
+///
+/// BlockNote content is a JSON array of block objects. Each block may have a
+/// `content` array of inline objects whose `text` field holds the visible
+/// string. This function finds the first block whose `type` is `"heading"`
+/// and replaces the **concatenated** text of its inline content with `new_title`.
+///
+/// If the content cannot be parsed or no heading block is found, the original
+/// content string is returned unchanged.
+fn rewrite_first_heading(content_json: &str, new_title: &str) -> String {
+ let mut blocks: Vec = match serde_json::from_str(content_json) {
+ Ok(v) => v,
+ Err(_) => return content_json.to_owned(),
+ };
+
+ for block in blocks.iter_mut() {
+ let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
+ if block_type != "heading" {
+ continue;
+ }
+ if let Some(inline_content) = block.get_mut("content").and_then(|c| c.as_array_mut()) {
+ if let Some(first_inline) = inline_content.first_mut() {
+ first_inline["text"] = serde_json::Value::String(new_title.to_owned());
+ }
+ // Truncate any additional inline nodes after the first so the
+ // title stays coherent (e.g. bold prefix + plain suffix).
+ inline_content.truncate(1);
+ break;
+ }
+ }
+
+ serde_json::to_string(&blocks).unwrap_or_else(|_| content_json.to_owned())
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 7da0049..2638f36 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -4,6 +4,8 @@ mod groups;
mod link_preview;
mod window;
+use std::collections::HashMap;
+use std::sync::Mutex;
use tauri::Manager;
/// Initializes and runs the Tauri application.
@@ -41,8 +43,8 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.setup(|app| {
db::init_db(app.handle())?;
- app.manage(link_preview::LinkPreviewCache(std::sync::Mutex::new(
- std::collections::HashMap::new(),
+ app.manage(link_preview::LinkPreviewCache(Mutex::new(
+ HashMap::new(),
)));
window::create_main_window(app.handle())?;
Ok(())
@@ -58,6 +60,7 @@ pub fn run() {
db::create_note,
db::update_note,
db::delete_note,
+ db::duplicate_note,
db::toggle_pin,
groups::list_groups,
groups::create_group,
diff --git a/src/App.tsx b/src/App.tsx
index 6a943df..361ac18 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,6 +26,7 @@ import {
createNote,
DEFAULT_CONTENT,
deleteNote,
+ duplicateNote,
Editor,
exportToMarkdown,
extractTitle,
@@ -266,10 +267,7 @@ function AppContent() {
* @param id - The ID of the note that was saved.
*/
const handleNoteSaved = useCallback((id: string) => {
- setSelectedNoteId((current) => {
- if (current === null || current === id) return id
- return current
- })
+ setSelectedNoteId((current) => (current === null ? id : current))
setRefreshKey((v) => v + 1)
}, [])
@@ -334,6 +332,26 @@ function AppContent() {
[]
)
+ /**
+ * Duplicates the specified note and selects the newly created copy.
+ *
+ * @param noteId - The ID of the note to duplicate.
+ * @throws Shows an error toast if duplication fails.
+ */
+ const handleDuplicateNote = useCallback(
+ async (noteId: string) => {
+ try {
+ const duplicated = await duplicateNote(noteId)
+ selectNote(duplicated.id)
+ setRefreshKey((v) => v + 1)
+ toast.success('Note duplicated')
+ } catch {
+ toast.error('Failed to duplicate note')
+ }
+ },
+ [selectNote]
+ )
+
/**
* Exports the given note as a Markdown file via a native save dialog.
*
@@ -411,6 +429,7 @@ function AppContent() {
onNewNote={handleNewNote}
onDeleteNote={handleDeleteNote}
onTogglePin={handleTogglePin}
+ onDuplicateNote={handleDuplicateNote}
onExportNote={handleExportNote}
onImportNote={handleImportNote}
refreshKey={refreshKey}
diff --git a/src/features/editor/api/notes.ts b/src/features/editor/api/notes.ts
index e944a82..1656765 100644
--- a/src/features/editor/api/notes.ts
+++ b/src/features/editor/api/notes.ts
@@ -89,6 +89,19 @@ export async function togglePinNote(
return invoke('toggle_pin', { id, pinned })
}
+/**
+ * Duplicates an existing note.
+ *
+ * Creates a new note with the same content and group as the original.
+ * The title is suffixed with " (copy)" and the pin state is not inherited.
+ *
+ * @param id - The UUID of the note to duplicate.
+ * @returns The newly created duplicate note.
+ */
+export async function duplicateNote(id: string): Promise {
+ return invoke('duplicate_note', { id })
+}
+
/**
* Reads a text file and returns its content.
*
diff --git a/src/features/editor/index.ts b/src/features/editor/index.ts
index ead3a5f..c00dd4c 100644
--- a/src/features/editor/index.ts
+++ b/src/features/editor/index.ts
@@ -9,6 +9,7 @@ export type { Note } from './api/notes'
export {
createNote,
deleteNote,
+ duplicateNote,
getNote,
listNotes,
readTextFile,
diff --git a/src/features/sidebar/ui/NoteItem.tsx b/src/features/sidebar/ui/NoteItem.tsx
index d50a8c6..a1f0504 100644
--- a/src/features/sidebar/ui/NoteItem.tsx
+++ b/src/features/sidebar/ui/NoteItem.tsx
@@ -1,6 +1,7 @@
import { useDraggable } from '@dnd-kit/core'
import {
Check,
+ Copy,
Download,
FileText,
FolderInput,
@@ -24,12 +25,27 @@ import type { Group } from '@/features/groups'
import { formatRelativeDate } from '@/features/groups'
import { cn } from '@/lib/utils'
+/**
+ * Props for the {@link NoteItem} component.
+ *
+ * @property note - The note object to render.
+ * @property selectedNoteId - The ID of the currently selected note, or `null`.
+ * @property onSelectNote - Callback invoked when the user clicks this note.
+ * @property onTogglePin - Callback to pin or unpin the note.
+ * @property onDeleteNote - Callback invoked when the user confirms deletion.
+ * @property onDuplicateNote - Callback invoked to duplicate the note.
+ * @property onExportNote - Callback invoked to export the note as Markdown.
+ * @property onMoveToGroup - Callback invoked to move the note to a different group.
+ * @property groups - The full list of groups available for the "Move to group" submenu.
+ * @property justPinnedId - The ID of the note that was just pinned (for bounce animation), or `null`.
+ */
interface NoteItemProps {
note: Note
selectedNoteId: string | null
onSelectNote: (id: string) => void
onTogglePin: (id: string, pinned: boolean) => void
onDeleteNote: () => void
+ onDuplicateNote: (noteId: string) => void
onExportNote: (noteId: string) => void
onMoveToGroup: (noteId: string, groupId: string | null) => void
groups: Group[]
@@ -47,6 +63,7 @@ export function NoteItem({
onSelectNote,
onTogglePin,
onDeleteNote,
+ onDuplicateNote,
onExportNote,
onMoveToGroup,
groups,
@@ -101,16 +118,11 @@ export function NoteItem({
onTogglePin(note.id, !note.isPinned)}>
{note.isPinned ? (
- <>
-
- Unpin
- >
+
) : (
- <>
-
- Pin to top
- >
+
)}
+ {note.isPinned ? 'Unpin' : 'Pin to top'}
@@ -140,6 +152,10 @@ export function NoteItem({
Export as Markdown
+ onDuplicateNote(note.id)}>
+
+ Duplicate
+
diff --git a/src/features/sidebar/ui/NoteSidebar.tsx b/src/features/sidebar/ui/NoteSidebar.tsx
index 816eca6..37c5346 100644
--- a/src/features/sidebar/ui/NoteSidebar.tsx
+++ b/src/features/sidebar/ui/NoteSidebar.tsx
@@ -37,6 +37,17 @@ import { UncategorizedSection } from './UncategorizedSection'
/**
* Props for the {@link NoteSidebar} component.
+ *
+ * @property selectedNoteId - The ID of the currently selected note, or `null`.
+ * @property onSelectNote - Callback invoked when the user selects a note.
+ * @property onNewNote - Callback invoked to create a new note.
+ * @property onDeleteNote - Callback invoked after a note has been deleted.
+ * @property onTogglePin - Callback to pin or unpin a note.
+ * @property onExportNote - Callback invoked to export a note as Markdown.
+ * @property onDuplicateNote - Callback invoked to duplicate a note.
+ * @property onImportNote - Callback invoked to import a Markdown file.
+ * @property refreshKey - A monotonically increasing key used to trigger data refresh.
+ * @property onRefresh - Callback invoked to bump the refresh key after a mutation.
*/
interface NoteSidebarProps {
selectedNoteId: string | null
@@ -45,6 +56,7 @@ interface NoteSidebarProps {
onDeleteNote: (noteId: string) => void
onTogglePin: (noteId: string, pinned: boolean) => void
onExportNote: (noteId: string) => void
+ onDuplicateNote: (noteId: string) => void
onImportNote: () => void
refreshKey: number
onRefresh: () => void
@@ -64,6 +76,7 @@ export function NoteSidebar({
onDeleteNote,
onTogglePin,
onExportNote,
+ onDuplicateNote,
onImportNote,
refreshKey,
onRefresh,
@@ -97,6 +110,13 @@ export function NoteSidebar({
})
)
+ /**
+ * Handles the start of a drag operation.
+ *
+ * Tracks the dragged note ID so the {@link DragOverlay} can render a preview.
+ *
+ * @param event - The drag-start event from `@dnd-kit`.
+ */
const handleDragStart = useCallback((event: DragStartEvent) => {
const data = event.active.data.current
if (data?.type === 'note') {
@@ -104,6 +124,15 @@ export function NoteSidebar({
}
}, [])
+ /**
+ * Handles the end of a drag operation.
+ *
+ * Supports two drag scenarios:
+ * 1. **Note -> Group/Uncategorized**: Moves the note to the target group.
+ * 2. **Group -> Group**: Reorders the groups by swapping positions.
+ *
+ * @param event - The drag-end event from `@dnd-kit`.
+ */
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
setActiveDragNoteId(null)
@@ -116,16 +145,15 @@ export function NoteSidebar({
// Note dropped on a group or uncategorized
if (activeData?.type === 'note') {
const noteId = activeData.noteId as string
+ let targetGroupId: string | null | undefined
if (overData?.type === 'group') {
- const groupId = overData.groupId as string
- try {
- await moveNote(noteId, groupId)
- } catch {
- toast.error('Failed to move note')
- }
+ targetGroupId = overData.groupId as string
} else if (overData?.type === 'uncategorized') {
+ targetGroupId = null
+ }
+ if (targetGroupId !== undefined) {
try {
- await moveNote(noteId, null)
+ await moveNote(noteId, targetGroupId)
} catch {
toast.error('Failed to move note')
}
@@ -150,6 +178,13 @@ export function NoteSidebar({
[groups, moveNote, reorder]
)
+ /**
+ * Toggles the pin state of a note and triggers a brief bounce animation
+ * when pinning.
+ *
+ * @param noteId - The ID of the note to pin or unpin.
+ * @param pinned - `true` to pin, `false` to unpin.
+ */
const handleTogglePin = useCallback(
(noteId: string, pinned: boolean) => {
onTogglePin(noteId, pinned)
@@ -161,6 +196,13 @@ export function NoteSidebar({
[onTogglePin]
)
+ /**
+ * Moves a note to a specified group (or to uncategorized).
+ *
+ * @param noteId - The ID of the note to move.
+ * @param groupId - The target group ID, or `null` for uncategorized.
+ * @throws Shows an error toast if the move operation fails.
+ */
const handleMoveToGroup = useCallback(
async (noteId: string, groupId: string | null) => {
try {
@@ -172,6 +214,15 @@ export function NoteSidebar({
[moveNote]
)
+ /**
+ * Renders a single {@link NoteItem} with the current sidebar state and
+ * callbacks wired up.
+ *
+ * Memoised to avoid unnecessary re-renders when unrelated state changes.
+ *
+ * @param note - The note to render.
+ * @returns The rendered {@link NoteItem} element.
+ */
const renderNoteItem = useCallback(
(note: Note) => (
setDeleteTarget(note.id)}
onExportNote={onExportNote}
+ onDuplicateNote={onDuplicateNote}
onMoveToGroup={handleMoveToGroup}
groups={groups}
justPinnedId={justPinnedId}
@@ -191,6 +243,7 @@ export function NoteSidebar({
selectedNoteId,
onSelectNote,
handleTogglePin,
+ onDuplicateNote,
onExportNote,
handleMoveToGroup,
groups,
@@ -198,6 +251,17 @@ export function NoteSidebar({
]
)
+ /**
+ * Renders the main body of the sidebar based on the current state.
+ *
+ * Handles four visual states:
+ * - **noResults**: Shows a "no matches" message when a search yields nothing.
+ * - **isEmpty**: Shows a placeholder when there are no notes at all.
+ * - **isSearching**: Flattens all groups into date-bucketed results.
+ * - **default**: Renders pinned section, group sections, and uncategorized section.
+ *
+ * @returns The rendered sidebar body content.
+ */
function renderSidebarBody(): React.ReactNode {
if (noResults) {
return (
@@ -208,6 +272,10 @@ export function NoteSidebar({
)
}
+ if (isEmpty) {
+ return No notes yet
+ }
+
// During search: bypass groups, show flat date-grouped results
if (isSearching) {
const searchBuckets = bucketByDate(
@@ -215,9 +283,6 @@ export function NoteSidebar({
.flatMap((g) => g.dateBuckets.flatMap((b) => b.items))
.concat(uncategorized.flatMap((b) => b.items))
)
- if (searchBuckets.length === 0) {
- return No notes yet
- }
return searchBuckets.map((bucket) => (
No notes yet
- }
-
const hasGroups = groups.length > 0
return (
From 5d8e3910890f357759bdef18b4e9a07f17cd7b9e Mon Sep 17 00:00:00 2001
From: j4rviscmd
Date: Sat, 28 Mar 2026 09:32:06 +0900
Subject: [PATCH 2/2] style: fix cargo fmt for CI compliance
Co-Authored-By: Claude Opus 4.6
---
src-tauri/src/lib.rs | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 2638f36..589d76f 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -43,9 +43,7 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.setup(|app| {
db::init_db(app.handle())?;
- app.manage(link_preview::LinkPreviewCache(Mutex::new(
- HashMap::new(),
- )));
+ app.manage(link_preview::LinkPreviewCache(Mutex::new(HashMap::new())));
window::create_main_window(app.handle())?;
Ok(())
})