From 75f3a84e5fed727b5709e8f6b323c7120ada7a8d Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 13 Feb 2025 22:20:56 +0100 Subject: [PATCH] Move notes store to rust --- gdn-app/package.json | 4 +- gdn-app/src-tauri/src/api.rs | 112 ++++++++++++++ gdn-app/src-tauri/src/lib.rs | 26 +++- gdn-app/src-tauri/src/store.rs | 238 +++++++++++++++++++++++++++++ gdn-app/src-tauri/src/types.rs | 24 +++ gdn-app/src/api.ts | 52 +++++++ gdn-app/src/components/CNavbar.vue | 37 ++--- gdn-app/src/components/CNote.vue | 49 +++--- gdn-app/src/main.ts | 7 +- gdn-app/src/stores/notes.ts | 143 +++++++---------- gdn-app/src/types.ts | 21 +++ pnpm-lock.yaml | 42 +++++ 12 files changed, 617 insertions(+), 138 deletions(-) create mode 100644 gdn-app/src-tauri/src/api.rs create mode 100644 gdn-app/src-tauri/src/store.rs create mode 100644 gdn-app/src-tauri/src/types.rs create mode 100644 gdn-app/src/api.ts create mode 100644 gdn-app/src/types.ts diff --git a/gdn-app/package.json b/gdn-app/package.json index 7275b71..301c72b 100644 --- a/gdn-app/package.json +++ b/gdn-app/package.json @@ -16,9 +16,11 @@ "@tailwindcss/vite": "^4.0.6", "@tauri-apps/api": "^2.2.0", "@tauri-apps/plugin-opener": "^2.2.5", + "@vueuse/core": "^12.5.0", "pinia": "^2.3.1", "tailwindcss": "^4.0.6", - "vue": "^3.5.13" + "vue": "^3.5.13", + "zod": "^3.24.2" }, "devDependencies": { "@eslint/js": "^9.20.0", diff --git a/gdn-app/src-tauri/src/api.rs b/gdn-app/src-tauri/src/api.rs new file mode 100644 index 0000000..0497849 --- /dev/null +++ b/gdn-app/src-tauri/src/api.rs @@ -0,0 +1,112 @@ +use std::sync::{Arc, Mutex}; + +use tauri::{AppHandle, Emitter, State}; + +use crate::{ + ids::NoteId, + store::Store, + types::{EventNotesStoreUpdate, Note}, +}; + +// API methods are sorted alphabetically. + +fn update_if_required(store: &mut Store, app: &AppHandle) { + if let Some(store_id) = store.needs_update() { + let payload = EventNotesStoreUpdate { store_id }; + app.emit("notes_store_update", payload).unwrap(); + store.update(); + } +} + +#[tauri::command] +pub fn note_child_add( + id: NoteId, + child_id: NoteId, + child_position: isize, + app: AppHandle, + store: State<'_, Arc>>, +) { + let mut guard = store.lock().unwrap(); + guard.add_child_at_position(id, child_id, child_position); + update_if_required(&mut guard, &app); +} + +#[tauri::command] +pub fn note_child_move( + child_id: NoteId, + from_id: NoteId, + from_iteration: usize, + to_id: NoteId, + to_position: isize, + app: AppHandle, + store: State<'_, Arc>>, +) { + let mut guard = store.lock().unwrap(); + guard.move_child_by_id_to_position(child_id, from_id, from_iteration, to_id, to_position); + update_if_required(&mut guard, &app); +} + +#[tauri::command] +pub fn note_child_remove( + id: NoteId, + child_id: NoteId, + child_iteration: usize, + app: AppHandle, + store: State<'_, Arc>>, +) { + let mut guard = store.lock().unwrap(); + guard.remove_child_by_id(id, child_id, child_iteration); + update_if_required(&mut guard, &app); +} + +#[tauri::command] +pub fn note_children_set( + id: NoteId, + children: Vec, + app: AppHandle, + store: State<'_, Arc>>, +) { + let mut guard = store.lock().unwrap(); + guard.set_children(id, children); + update_if_required(&mut guard, &app); +} + +#[tauri::command] +pub fn note_create(text: String, app: AppHandle, store: State<'_, Arc>>) -> Note { + let mut guard = store.lock().unwrap(); + let id = guard.create(text); + update_if_required(&mut guard, &app); + guard.get(id).unwrap() +} + +#[tauri::command] +pub fn note_delete(id: NoteId, app: AppHandle, store: State<'_, Arc>>) { + let mut guard = store.lock().unwrap(); + guard.delete(id); + update_if_required(&mut guard, &app); +} + +#[tauri::command] +pub fn note_get(id: NoteId, store: State<'_, Arc>>) -> Option { + let guard = store.lock().unwrap(); + guard.get(id) +} + +#[tauri::command] +pub fn note_text_set( + id: NoteId, + text: String, + app: AppHandle, + store: State<'_, Arc>>, +) { + let mut guard = store.lock().unwrap(); + guard.set_text(id, text); + update_if_required(&mut guard, &app); +} + +#[tauri::command] +pub fn notes_clear(app: AppHandle, store: State<'_, Arc>>) { + let mut guard = store.lock().unwrap(); + guard.clear(); + update_if_required(&mut guard, &app); +} diff --git a/gdn-app/src-tauri/src/lib.rs b/gdn-app/src-tauri/src/lib.rs index e393a70..93262a2 100644 --- a/gdn-app/src-tauri/src/lib.rs +++ b/gdn-app/src-tauri/src/lib.rs @@ -1,16 +1,28 @@ -mod ids; +use std::sync::{Arc, Mutex}; -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +use store::Store; + +mod api; +mod ids; +pub mod store; +mod types; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .manage(Arc::new(Mutex::new(Store::new()))) + .invoke_handler(tauri::generate_handler![ + api::note_child_add, + api::note_child_move, + api::note_child_remove, + api::note_children_set, + api::note_create, + api::note_delete, + api::note_get, + api::note_text_set, + api::notes_clear, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/gdn-app/src-tauri/src/store.rs b/gdn-app/src-tauri/src/store.rs new file mode 100644 index 0000000..6b1452b --- /dev/null +++ b/gdn-app/src-tauri/src/store.rs @@ -0,0 +1,238 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{ids::NoteId, types::Note}; + +#[derive(Debug)] +pub struct NoteInfo { + pub text: String, + pub children: Vec, +} + +/// A note store for testing. +#[derive(Default)] +pub struct Store { + last_id: u64, + curr_id: u64, + notes: HashMap, + parents: HashMap>, +} + +impl Store { + pub fn new() -> Self { + Self::default() + } + + pub fn needs_update(&self) -> Option { + if self.last_id != self.curr_id { + Some(self.curr_id) + } else { + None + } + } + + pub fn update(&mut self) { + self.last_id = self.curr_id; + } + + pub fn get(&self, id: NoteId) -> Option { + let info = self.notes.get(&id)?; + + let parents = self + .parents + .get(&id) + .map(|ps| ps.keys().copied().collect::>()) + .unwrap_or_default(); + + Some(Note { + id, + text: info.text.clone(), + children: info.children.clone(), + parents, + }) + } + + fn tick(&mut self) { + self.curr_id += 1; + } + + fn update_parents(&mut self) { + self.parents.clear(); + for (id, info) in &self.notes { + for child in &info.children { + *self + .parents + .entry(*child) + .or_default() + .entry(*id) + .or_default() += 1; + } + } + } + + pub fn create(&mut self, text: String) -> NoteId { + let id = NoteId::new(); + let info = NoteInfo { + text, + children: vec![], + }; + + self.notes.insert(id, info); + self.update_parents(); + self.tick(); + + id + } + + pub fn delete(&mut self, id: NoteId) -> Option { + let info = self.notes.remove(&id)?; + self.update_parents(); + self.tick(); + Some(info) + } + + pub fn set_text(&mut self, id: NoteId, text: String) -> Option<()> { + let note = self.notes.get_mut(&id)?; + if note.text == text { + return None; + } + note.text = text; + self.tick(); + Some(()) + } + + pub fn set_children(&mut self, id: NoteId, children: Vec) -> Option<()> { + let note = self.notes.get_mut(&id)?; + if note.children == children { + return None; + } + note.children = children; + self.tick(); + Some(()) + } + + /// Find the index of a child based on its id and iteration. + /// + /// The index returned is in the range `[0, note.children.len())`. + /// + /// Iteration 0 refers to the first occurrence of the id, iteration 1 to the + /// second, and so on. Returns [`None`] if no such iteration was found. + fn resolve_child_iteration( + children: &[NoteId], + child_id: NoteId, + child_iteration: usize, + ) -> Option { + children + .iter() + .enumerate() + .filter(|(_, it)| **it == child_id) + .map(|(i, _)| i) + .nth(child_iteration) + } + + /// Find the index of a child based on its position. + /// + /// The index returned is in the range `[0, note.children.len()]`. + /// + /// # Example + /// + /// A small example for a note with children `[a, b, c]`: + /// + /// ```text + /// Child [ a, b, c] _ + /// Position 0 1 2 3 + /// Position -4 -3 -2 -1 + /// ``` + fn resolve_child_position(children: &[NoteId], child_position: isize) -> usize { + if child_position > 0 { + let child_position = child_position as usize; + child_position.min(children.len()) + } else { + let child_position = (-child_position - 1) as usize; + children.len().saturating_sub(child_position) + } + } + + /// Add a child at the specified position. + /// + /// Returns `Some(())` if the operation was successful. + pub fn add_child_at_position( + &mut self, + id: NoteId, + child_id: NoteId, + child_position: isize, + ) -> Option<()> { + let note = self.notes.get_mut(&id)?; + let index = Self::resolve_child_position(¬e.children, child_position); + note.children.insert(index, child_id); + + self.update_parents(); + self.tick(); + Some(()) + } + + /// Remove the specified iteration of a child. + /// + /// Returns `Some(())` if the operation was successful. + pub fn remove_child_by_id( + &mut self, + id: NoteId, + child_id: NoteId, + child_iteration: usize, + ) -> Option<()> { + let note = self.notes.get_mut(&id)?; + let index = Self::resolve_child_iteration(¬e.children, child_id, child_iteration)?; + note.children.remove(index); + + self.update_parents(); + self.tick(); + Some(()) + } + + /// A combination of [`Self::add_child_at_position`] and + /// [`Self::remove_child_by_id`]. + /// + /// Returns `Some(())` if the operation was successful. + pub fn move_child_by_id_to_position( + &mut self, + child_id: NoteId, + from_id: NoteId, + from_iteration: usize, + to_id: NoteId, + to_position: isize, + ) -> Option<()> { + let from = self.get(from_id)?; + let to = self.get(to_id)?; + + let from_idx = Self::resolve_child_iteration(&from.children, child_id, from_iteration)?; + let mut to_idx = Self::resolve_child_position(&to.children, to_position); + + if from_id == to_id && from_idx < to_idx { + to_idx += 1; + } + + let removed_id = self + .notes + .get_mut(&from_id) + .unwrap() + .children + .remove(from_idx); + assert!(removed_id == child_id); + + self.notes + .get_mut(&to_id) + .unwrap() + .children + .insert(to_idx, child_id); + + self.update_parents(); + self.tick(); + Some(()) + } + + pub fn clear(&mut self) { + self.notes.clear(); + + self.update_parents(); + self.tick(); + } +} diff --git a/gdn-app/src-tauri/src/types.rs b/gdn-app/src-tauri/src/types.rs new file mode 100644 index 0000000..245f2ee --- /dev/null +++ b/gdn-app/src-tauri/src/types.rs @@ -0,0 +1,24 @@ +use std::collections::HashSet; + +use serde::Serialize; + +use crate::ids::NoteId; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Note { + pub id: NoteId, + pub text: String, + pub children: Vec, + pub parents: HashSet, +} + +//////////// +// Events // +//////////// + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EventNotesStoreUpdate { + pub store_id: u64, +} diff --git a/gdn-app/src/api.ts b/gdn-app/src/api.ts new file mode 100644 index 0000000..8e59d2f --- /dev/null +++ b/gdn-app/src/api.ts @@ -0,0 +1,52 @@ +import { invoke } from "@tauri-apps/api/core"; +import { Note } from "./types"; + +export async function apiNoteChildAdd( + id: string, + childId: string, + childPosition: number, +): Promise { + await invoke("note_child_add", { id, childId, childPosition }); +} + +export async function apiNoteChildMove( + childId: string, + fromId: string, + fromIteration: number, + toId: string, + toPosition: number, +): Promise { + await invoke("note_child_move", { childId, fromId, fromIteration, toId, toPosition }); +} + +export async function apiNoteChildRemove( + id: string, + childId: string, + childIteration: number, +): Promise { + await invoke("note_child_remove", { id, childId, childIteration }); +} + +export async function apiNoteChildrenSet(id: string, children: string[]): Promise { + await invoke("note_children_set", { id, children }); +} + +export async function apiNoteCreate(text: string): Promise { + return Note.parse(await invoke("note_create", { text })); +} + +export async function apiNoteDelete(id: string): Promise { + await invoke("note_delete", { id }); +} + +export async function apiNoteGet(id: string): Promise { + return Note.nullable().parse(await invoke("note_get", { id })); +} + +export async function apiNoteTextSet(id: string, text: string): Promise { + await invoke("note_text_set", { id, text }); +} + +export async function apiNotesClear(): Promise { + await invoke("notes_clear"); +} diff --git a/gdn-app/src/components/CNavbar.vue b/gdn-app/src/components/CNavbar.vue index e8b3e6a..55e4ba8 100644 --- a/gdn-app/src/components/CNavbar.vue +++ b/gdn-app/src/components/CNavbar.vue @@ -16,41 +16,42 @@ const repos = useReposStore(); const notes = useNotesStore(); const ui = useUiStore(); -function mkNote(text: string, ...children: string[]): Note { - const note = notes.createNote(text); - for (const child of children) notes.addChild(note.id, child, -1); +async function mkNote(text: string, ...children: string[]): Promise { + const note = await notes.createNote(text); + for (const child of children) await notes.addChild(note.id, child, -1); return note; } -function createSomeNotes(): void { - notes.clearNotes(); +async function createSomeNotes(): Promise { + await notes.clearNotes(); - const n2n1 = mkNote("n2n1"); - const n2n2 = mkNote("n2n2"); - const n2n3 = mkNote("n2n3"); + const n2n1 = await mkNote("n2n1"); + const n2n2 = await mkNote("n2n2"); + const n2n3 = await mkNote("n2n3"); - const n1 = mkNote("n1"); - const n2 = mkNote("n2", n2n1.id, n2n2.id, n2n3.id); - const n3 = mkNote("n3", n2n1.id); - const n4 = mkNote("n4"); - const n5 = mkNote("n5", "NaN (not a note)"); + const n1 = await mkNote("n1"); + const n2 = await mkNote("n2", n2n1.id, n2n2.id, n2n3.id); + const n3 = await mkNote("n3", n2n1.id); + const n4 = await mkNote("n4"); + const n5 = await mkNote("n5", "n0000000000000000"); - const root = mkNote("root", n1.id, n2.id, n3.id, n4.id, n5.id, n2.id); + const root = await mkNote("root", n1.id, n2.id, n3.id, n4.id, n5.id, n2.id); ui.pushAnchorId(root.id); // Shuffle children of root - notes.setChildren( + const rootChildren = (await notes.getNote(root.id))?.children ?? []; + await notes.setChildren( root.id, - root.children + rootChildren .map((it) => ({ it, rand: Math.random() })) .sort((a, b) => a.rand - b.rand) .map(({ it }) => it), ); } -onMounted(() => { - createSomeNotes(); +onMounted(async () => { + await createSomeNotes(); }); diff --git a/gdn-app/src/components/CNote.vue b/gdn-app/src/components/CNote.vue index d01b538..13d4249 100644 --- a/gdn-app/src/components/CNote.vue +++ b/gdn-app/src/components/CNote.vue @@ -20,6 +20,7 @@ import { computed, ref, watchEffect } from "vue"; import CNoteButton from "./CNoteButton.vue"; import CNoteChildEditor from "./CNoteChildEditor.vue"; import CNoteEditor from "./CNoteEditor.vue"; +import { computedAsync } from "@vueuse/core"; const notes = useNotesStore(); const ui = useUiStore(); @@ -39,15 +40,21 @@ const { }>(); const id = computed(() => segment.id); -const note = computed(() => notes.getNote(id.value)); +const note = computedAsync(async () => await notes.getNote(id.value)); -const parents = computed(() => { - let parents = notes.getParents(id.value); - if (parentId) parents = parents.difference(new Set([parentId])); - return [...parents].sort().map((id) => ({ id, text: notes.getNote(id)?.text })); +const parents = computedAsync(async () => { + const result = []; + for (const parent of note.value?.parents ?? new Set()) { + if (parentId !== undefined && parent === parentId) continue; + result.push({ + id: parent, + text: (await notes.getNote(parent))?.text, + }); + } + result.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return result; }); -// Our children and the const children = computed(() => { if (!note.value) return []; const seen = new Map(); @@ -84,13 +91,13 @@ function onClick(): void { ui.toggleOpen(path); } -function onDeleteButtonClick(): void { - notes.deleteNote(segment.id); +async function onDeleteButtonClick(): Promise { + await notes.deleteNote(segment.id); } -function onUnlinkButtonClick(): void { +async function onUnlinkButtonClick(): Promise { if (parentId === undefined) return; - notes.removeChild(parentId, segment); + await notes.removeChild(parentId, segment); } function onEditButtonClick(): void { @@ -127,9 +134,9 @@ function onEditEditorClose(): void { ui.focus(); } -function onEditEditorFinish(text: string): void { +async function onEditEditorFinish(text: string): Promise { if (!note.value) return; - notes.setText(segment.id, text); + await notes.setText(segment.id, text); onEditEditorClose(); } @@ -137,36 +144,36 @@ function onInsertEditorClose(): void { ui.focus(); } -function onInsertEditorFinish(text: string): void { +async function onInsertEditorFinish(text: string): Promise { if (!note.value) return; if (insertIndex.value !== undefined) { - const child = notes.createNote(text); - notes.addChild(segment.id, child.id, insertIndex.value); + const child = await notes.createNote(text); + await notes.addChild(segment.id, child.id, insertIndex.value); } onInsertEditorClose(); } -function onInsertEditorMove(): void { +async function onInsertEditorMove(): Promise { if (!ui.pinned) return; if (insertIndex.value === undefined) return; if (ui.pinned.parentId) { - notes.moveChild(ui.pinned.parentId, ui.pinned.segment, segment.id, insertIndex.value); + await notes.moveChild(ui.pinned.parentId, ui.pinned.segment, segment.id, insertIndex.value); } else { - notes.addChild(segment.id, ui.pinned.segment.id, insertIndex.value); + await notes.addChild(segment.id, ui.pinned.segment.id, insertIndex.value); } onInsertEditorClose(); ui.unsetPinned(); } -function onInsertEditorCopy(): void { +async function onInsertEditorCopy(): Promise { if (!ui.pinned) return; if (insertIndex.value === undefined) return; - notes.addChild(segment.id, ui.pinned.segment.id, insertIndex.value); + await notes.addChild(segment.id, ui.pinned.segment.id, insertIndex.value); onInsertEditorClose(); } @@ -175,7 +182,7 @@ function onInsertEditorCopy(): void {