From 95fbb40e5d5edd21b334fca0147df562b6dfeb2f Mon Sep 17 00:00:00 2001 From: Moritz Reis Date: Sun, 15 Mar 2026 14:08:22 +0100 Subject: [PATCH] refactor: improve board index management and loading logic --- src-tauri/src/commands/boards.rs | 115 +++++++++++++++---------- src-tauri/src/db.rs | 130 ++++++++++++++++++++--------- src/App.tsx | 125 ++++++++++++++++++--------- src/components/ExcalidrawFrame.tsx | 71 +++++++++++----- 4 files changed, 298 insertions(+), 143 deletions(-) diff --git a/src-tauri/src/commands/boards.rs b/src-tauri/src/commands/boards.rs index 62c4d9f..6ee7e0d 100644 --- a/src-tauri/src/commands/boards.rs +++ b/src-tauri/src/commands/boards.rs @@ -163,57 +163,19 @@ pub(crate) fn set_boards_index( let mut conn = open_db(&app)?; let tx = conn.transaction().map_err(|error| error.to_string())?; - tx.execute("DELETE FROM index_items", []) - .map_err(|error| error.to_string())?; - tx.execute("DELETE FROM folder_items", []) - .map_err(|error| error.to_string())?; - tx.execute("DELETE FROM folders", []) - .map_err(|error| error.to_string())?; + clear_index_tables(&tx)?; for (position, item) in items.iter().enumerate() { - match item { - BoardListItem::Board(board) => { - tx.execute( - "INSERT INTO index_items (position, item_type, item_id) VALUES (?1, 'board', ?2)", - params![position as i64, board.id], - ) - .map_err(|error| error.to_string())?; - } - BoardListItem::Folder(folder) => { - tx.execute( - "INSERT OR REPLACE INTO folders (id, name) VALUES (?1, ?2)", - params![folder.id, folder.name], - ) - .map_err(|error| error.to_string())?; - tx.execute( - "INSERT INTO index_items (position, item_type, item_id) VALUES (?1, 'folder', ?2)", - params![position as i64, folder.id], - ) - .map_err(|error| error.to_string())?; - for (folder_pos, board) in folder.items.iter().enumerate() { - tx.execute( - "INSERT INTO folder_items (folder_id, board_id, position) VALUES (?1, ?2, ?3)", - params![folder.id, board.id, folder_pos as i64], - ) - .map_err(|error| error.to_string())?; - } - } - } + persist_index_item(&tx, position as i64, item)?; } - let mut index = BoardsIndex { + let active_board_id = get_setting(&tx, ACTIVE_BOARD_SETTING_KEY)?; + let normalized_active_board_id = resolve_active_board_id(&items, active_board_id); + let index = BoardsIndex { items, - active_board_id: get_setting(&tx, ACTIVE_BOARD_SETTING_KEY)?, + active_board_id: normalized_active_board_id, }; - if let Some(active_id) = index.active_board_id.clone() { - if !board_exists(&index.items, &active_id) { - index.active_board_id = first_board_id(&index.items); - } - } else { - index.active_board_id = first_board_id(&index.items); - } - set_setting( &tx, ACTIVE_BOARD_SETTING_KEY, @@ -223,6 +185,71 @@ pub(crate) fn set_boards_index( Ok(index) } +fn clear_index_tables(tx: &rusqlite::Transaction<'_>) -> Result<(), String> { + tx.execute("DELETE FROM index_items", []) + .map_err(|error| error.to_string())?; + tx.execute("DELETE FROM folder_items", []) + .map_err(|error| error.to_string())?; + tx.execute("DELETE FROM folders", []) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn persist_index_item( + tx: &rusqlite::Transaction<'_>, + position: i64, + item: &BoardListItem, +) -> Result<(), String> { + match item { + BoardListItem::Board(board) => { + tx.execute( + "INSERT INTO index_items (position, item_type, item_id) VALUES (?1, 'board', ?2)", + params![position, &board.id], + ) + .map_err(|error| error.to_string())?; + Ok(()) + } + BoardListItem::Folder(folder) => persist_folder_item(tx, position, folder), + } +} + +fn persist_folder_item( + tx: &rusqlite::Transaction<'_>, + position: i64, + folder: &crate::models::BoardFolder, +) -> Result<(), String> { + tx.execute( + "INSERT OR REPLACE INTO folders (id, name) VALUES (?1, ?2)", + params![&folder.id, &folder.name], + ) + .map_err(|error| error.to_string())?; + tx.execute( + "INSERT INTO index_items (position, item_type, item_id) VALUES (?1, 'folder', ?2)", + params![position, &folder.id], + ) + .map_err(|error| error.to_string())?; + + for (folder_position, board) in folder.items.iter().enumerate() { + tx.execute( + "INSERT INTO folder_items (folder_id, board_id, position) VALUES (?1, ?2, ?3)", + params![&folder.id, &board.id, folder_position as i64], + ) + .map_err(|error| error.to_string())?; + } + + Ok(()) +} + +fn resolve_active_board_id( + items: &[BoardListItem], + active_board_id: Option, +) -> Option { + match active_board_id { + Some(active_id) if board_exists(items, &active_id) => Some(active_id), + _ => first_board_id(items), + } +} + fn insert_board_with_data( tx: &rusqlite::Transaction<'_>, board: &Board, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 5d69035..bdbed3e 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -177,27 +177,34 @@ pub(crate) fn first_board_id_from_db(conn: &Connection) -> Result while let Some(row) = rows.next().map_err(|e| e.to_string())? { let item_type: String = row.get(0).map_err(|e| e.to_string())?; let item_id: String = row.get(1).map_err(|e| e.to_string())?; - if item_type == "board" { - return Ok(Some(item_id)); - } - if item_type == "folder" { - let board_id: Option = conn - .query_row( - "SELECT board_id FROM folder_items WHERE folder_id = ?1 ORDER BY position ASC LIMIT 1", - params![item_id], - |row| row.get(0), - ) - .optional() - .map_err(|e| e.to_string())?; - if board_id.is_some() { - return Ok(board_id); - } + let board_id = board_id_from_index_item(conn, &item_type, &item_id)?; + if board_id.is_some() { + return Ok(board_id); } } Ok(None) } +fn board_id_from_index_item( + conn: &Connection, + item_type: &str, + item_id: &str, +) -> Result, String> { + match item_type { + "board" => Ok(Some(item_id.to_string())), + "folder" => conn + .query_row( + "SELECT board_id FROM folder_items WHERE folder_id = ?1 ORDER BY position ASC LIMIT 1", + params![item_id], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string()), + _ => Ok(None), + } +} + fn load_folder_boards( conn: &Connection, folder_id: &str, @@ -220,6 +227,18 @@ fn load_folder_boards( } pub(crate) fn load_boards_index_from_db(conn: &Connection) -> Result { + let boards = load_boards_map(conn)?; + let folder_names = load_folder_names_map(conn)?; + let items = load_index_items(conn, &boards, &folder_names)?; + let active_board_id = get_setting(conn, "active_board_id")?; + + Ok(BoardsIndex { + items, + active_board_id, + }) +} + +fn load_boards_map(conn: &Connection) -> Result, String> { let mut boards = HashMap::new(); let mut stmt = conn .prepare( @@ -242,6 +261,10 @@ pub(crate) fn load_boards_index_from_db(conn: &Connection) -> Result Result, String> { let mut folder_names = HashMap::new(); let mut stmt = conn .prepare("SELECT id, name FROM folders") @@ -253,41 +276,70 @@ pub(crate) fn load_boards_index_from_db(conn: &Connection) -> Result, + folder_names: &HashMap, +) -> Result, String> { let mut items = Vec::new(); let mut stmt = conn .prepare("SELECT item_type, item_id FROM index_items ORDER BY position ASC") .map_err(|e| e.to_string())?; let mut rows = stmt.query([]).map_err(|e| e.to_string())?; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { let item_type: String = row.get(0).map_err(|e| e.to_string())?; let item_id: String = row.get(1).map_err(|e| e.to_string())?; - match item_type.as_str() { - "board" => { - if let Some(board) = boards.get(&item_id) { - items.push(BoardListItem::Board(board.clone())); - } - } - "folder" => { - if let Some(name) = folder_names.get(&item_id) { - let folder_items = load_folder_boards(conn, &item_id, &boards)?; - if !folder_items.is_empty() { - items.push(BoardListItem::Folder(BoardFolder { - id: item_id, - name: name.clone(), - items: folder_items, - })); - } - } - } - _ => {} + + if let Some(item) = + board_list_item_from_index_row(conn, &item_type, &item_id, boards, folder_names)? + { + items.push(item); } } - let active_board_id = get_setting(conn, "active_board_id")?; - Ok(BoardsIndex { - items, - active_board_id, - }) + Ok(items) +} + +fn board_list_item_from_index_row( + conn: &Connection, + item_type: &str, + item_id: &str, + boards: &HashMap, + folder_names: &HashMap, +) -> Result, String> { + match item_type { + "board" => Ok(boards + .get(item_id) + .map(|board| BoardListItem::Board(board.clone()))), + "folder" => folder_item_from_index_row(conn, item_id, boards, folder_names), + _ => Ok(None), + } +} + +fn folder_item_from_index_row( + conn: &Connection, + folder_id: &str, + boards: &HashMap, + folder_names: &HashMap, +) -> Result, String> { + let Some(name) = folder_names.get(folder_id) else { + return Ok(None); + }; + + let folder_items = load_folder_boards(conn, folder_id, boards)?; + if folder_items.is_empty() { + return Ok(None); + } + + Ok(Some(BoardListItem::Folder(BoardFolder { + id: folder_id.to_string(), + name: name.clone(), + items: folder_items, + }))) } pub(crate) fn normalize_active_board_id( diff --git a/src/App.tsx b/src/App.tsx index 44b71b1..fad5124 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,82 @@ import { lazy, Suspense } from 'react'; +import type { RefObject } from 'react'; import { BoardList } from './components/BoardList'; +import type { ExcalidrawData, ExcalidrawFrameHandle } from './components/ExcalidrawFrame'; import { useAppController } from './hooks/useAppController'; import './App.css'; const ExcalidrawFrame = lazy(() => import('./components/ExcalidrawFrame')); +function FullScreenLoading({ message }: { message: string }) { + return ( +
+
+

{message}

+
+ ); +} + +function ExcalidrawLoadingOverlay({ message }: { message: string }) { + return ( +
+
+
+

{message}

+
+
+ ); +} + +function AppErrorToast({ message }: { message: string | null }) { + if (!message) { + return null; + } + + return ( +
+

{message}

+
+ ); +} + +interface EditorPanelProps { + activeBoardId: string | null; + boardDataLoading: boolean; + activeBoardName: string | null; + handleDataChange: (boardId: string, data: ExcalidrawData) => Promise; + handleThumbnailGenerated: (boardId: string, dataUrl: string) => void; + currentBoardData: ExcalidrawData | null; + excalidrawRef: RefObject; +} + +function EditorPanel({ + activeBoardId, + boardDataLoading, + activeBoardName, + handleDataChange, + handleThumbnailGenerated, + currentBoardData, + excalidrawRef, +}: EditorPanelProps) { + if (activeBoardId && boardDataLoading) { + return ; + } + + return ( + }> + + + ); +} + function App() { const { items, @@ -39,14 +111,11 @@ function App() { } = useAppController(); if (loading) { - return ( -
-
-

Loading boards...

-
- ); + return ; } + const errorMessage = error ?? exportError ?? settingsError; + return (
- {activeBoardId && boardDataLoading ? ( -
-
-
-

Loading board...

-
-
- ) : ( - -
-
-

Loading editor...

-
-
- } - > - - - )} - {(error || exportError || settingsError) && ( -
-

{error || exportError || settingsError}

-
- )} + + ); } diff --git a/src/components/ExcalidrawFrame.tsx b/src/components/ExcalidrawFrame.tsx index eed8efe..a42bd9d 100644 --- a/src/components/ExcalidrawFrame.tsx +++ b/src/components/ExcalidrawFrame.tsx @@ -283,14 +283,12 @@ const useExcalidrawExports = ( return { exportPng, copyPng, exportSvg }; }; -const useExcalidrawPersistence = ( +const useExcalidrawDataPersistence = ( excalidrawApiRef: ExcalidrawApiRef, boardId: string | null, onDataChange: (boardId: string, data: ExcalidrawData) => Promise, - onThumbnailGenerated: (boardId: string, dataUrl: string) => void, ) => { const saveTimeoutRef = useRef(null); - const thumbnailTimeoutRef = useRef(null); const lastSavedDataRef = useRef(null); const collectData = useCallback((): ExcalidrawData | null => { @@ -336,10 +334,6 @@ const useExcalidrawPersistence = ( const scheduleSave = useCallback(() => { clearTimer(saveTimeoutRef); saveTimeoutRef.current = window.setTimeout(() => { - if (!boardId) { - return; - } - const data = collectData(); if (!data) { return; @@ -347,24 +341,58 @@ const useExcalidrawPersistence = ( void saveData(data); }, SAVE_DEBOUNCE_MS); - }, [boardId, collectData, saveData]); + }, [collectData, saveData]); - const generateThumbnail = useCallback(async () => { - const api = excalidrawApiRef.current; - if (!api || !boardId) { - return; - } + useEffect( + () => () => { + clearTimer(saveTimeoutRef); + }, + [], + ); + + return { flushSave, scheduleSave }; +}; + +interface ThumbnailSource { + boardId: string; + snapshot: SceneSnapshot; +} + +const getThumbnailSource = ( + excalidrawApiRef: ExcalidrawApiRef, + boardId: string | null, +): ThumbnailSource | null => { + const api = excalidrawApiRef.current; + if (!api || !boardId) { + return null; + } + + const snapshot = getSceneSnapshot(api); + if (snapshot.elements.length === 0) { + return null; + } + + return { boardId, snapshot }; +}; + +const useExcalidrawThumbnailPersistence = ( + excalidrawApiRef: ExcalidrawApiRef, + boardId: string | null, + onThumbnailGenerated: (boardId: string, dataUrl: string) => void, +) => { + const thumbnailTimeoutRef = useRef(null); - const snapshot = getSceneSnapshot(api); - if (snapshot.elements.length === 0) { + const generateThumbnail = useCallback(async () => { + const source = getThumbnailSource(excalidrawApiRef, boardId); + if (!source) { return; } try { - const blob = await createThumbnailBlob(snapshot); + const blob = await createThumbnailBlob(source.snapshot); const dataUrl = await blobToThumbnailDataUrl(blob); if (dataUrl) { - onThumbnailGenerated(boardId, dataUrl); + onThumbnailGenerated(source.boardId, dataUrl); } } catch (error) { console.error('Failed to generate thumbnail:', error); @@ -385,13 +413,12 @@ const useExcalidrawPersistence = ( useEffect( () => () => { - clearTimer(saveTimeoutRef); clearTimer(thumbnailTimeoutRef); }, [], ); - return { flushSave, flushThumbnail, scheduleSave, scheduleThumbnail }; + return { flushThumbnail, scheduleThumbnail }; }; function EmptyBoardPlaceholder() { @@ -428,10 +455,14 @@ export const ExcalidrawFrame = forwardRef