diff --git a/app/src/hooks/useNotes.test.ts b/app/src/hooks/useNotes.test.ts new file mode 100644 index 0000000..3a173af --- /dev/null +++ b/app/src/hooks/useNotes.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useNotes } from "./useNotes"; +import type { Note } from "../lib/types"; + +const listNotes = vi.fn(); +const saveNote = vi.fn(); +const deleteNote = vi.fn(); + +vi.mock("../lib/tauri-commands", () => ({ + listNotes: (p: string) => listNotes(p), + saveNote: (p: string, n: Note) => saveNote(p, n), + deleteNote: (p: string, id: string) => deleteNote(p, id), +})); + +const pushToast = vi.fn(); +vi.mock("../store/appState", () => ({ + useAppState: Object.assign( + (selector: (s: unknown) => unknown) => selector({ pushToast }), + { getState: () => ({ pushToast }) }, + ), +})); + +const note = (over: Partial = {}): Note => ({ + id: "n1", + title: "Deploy", + body: "one\ntwo", + pinned: false, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + ...over, +}); + +beforeEach(() => { + vi.clearAllMocks(); + listNotes.mockResolvedValue([note()]); + saveNote.mockImplementation(async (_p: string, n: Note) => n); + deleteNote.mockResolvedValue(undefined); +}); + +describe("useNotes", () => { + it("loads a project's notes on mount", async () => { + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(listNotes).toHaveBeenCalledWith("p1"); + expect(result.current.notes).toHaveLength(1); + }); + + it("reports a failed save instead of swallowing it", async () => { + // Silent save failure is data loss: the user sees their text on screen and + // believes it is stored. Same reason `useSaveState` exists. + saveNote.mockRejectedValueOnce(new Error("disk full")); + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.saveNote(note({ body: "edited" })); + }); + + expect(ok).toBe(false); + expect(result.current.saveState.status).toBe("failed"); + expect(pushToast).toHaveBeenCalled(); + }); + + it("replaces the saved note in place rather than appending", async () => { + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.saveNote(note({ body: "edited" })); + }); + + expect(result.current.notes).toHaveLength(1); + expect(result.current.notes[0].body).toBe("edited"); + }); + + it("drops a deleted note from the list", async () => { + const { result } = renderHook(() => useNotes("p1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.deleteNote("n1"); + }); + + expect(deleteNote).toHaveBeenCalledWith("p1", "n1"); + expect(result.current.notes).toHaveLength(0); + }); + + it("does not load anything for an empty project id", async () => { + // The dock renders with no project selected; it must not fire a command + // for the empty string. + renderHook(() => useNotes("")); + await waitFor(() => expect(listNotes).not.toHaveBeenCalled()); + }); +}); diff --git a/app/src/hooks/useNotes.ts b/app/src/hooks/useNotes.ts new file mode 100644 index 0000000..1d4f3fd --- /dev/null +++ b/app/src/hooks/useNotes.ts @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import * as commands from "../lib/tauri-commands"; +import type { Note } from "../lib/types"; +import type { SaveState } from "./useSaveState"; +import { useAppState } from "../store/appState"; + +/** A blank note, ordered to the top so the user can start typing immediately. */ +function draft(): Note { + const now = new Date().toISOString(); + return { + // The backend owns the real id; this one only has to be unique enough to + // key the list until the first save returns. + id: crypto.randomUUID(), + title: "", + body: "", + pinned: false, + created_at: now, + updated_at: now, + }; +} + +/** + * A project's notes, cached from the backend. + * + * The backend is the source of truth and this is a cache — every mutation goes + * through a command and the returned record replaces the local one, so the + * list can never drift from the file. `saveState` mirrors `useProjectSave` so + * `ui/SaveIndicator` can report the outcome: a save that fails silently is a + * user staring at text they believe is stored. + */ +export function useNotes(projectId: string) { + const [notes, setNotes] = useState([]); + const [loading, setLoading] = useState(true); + const [saveState, setSaveState] = useState({ status: "idle", error: null }); + const pushToast = useAppState((s) => s.pushToast); + const resetTimer = useRef | null>(null); + + useEffect(() => { + if (!projectId) { + setNotes([]); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + commands + .listNotes(projectId) + .then((loaded) => { + if (!cancelled) setNotes(loaded); + }) + .catch((e) => { + if (cancelled) return; + pushToast({ + kind: "error", + message: "Could not load notes for this project", + detail: String(e), + }); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectId, pushToast]); + + useEffect( + () => () => { + if (resetTimer.current) clearTimeout(resetTimer.current); + }, + [], + ); + + const succeeded = useCallback(() => { + setSaveState({ status: "saved", error: null }); + if (resetTimer.current) clearTimeout(resetTimer.current); + resetTimer.current = setTimeout( + () => setSaveState({ status: "idle", error: null }), + 2500, + ); + }, []); + + const saveNote = useCallback( + async (note: Note) => { + if (resetTimer.current) clearTimeout(resetTimer.current); + setSaveState({ status: "saving", error: null }); + try { + const saved = await commands.saveNote(projectId, note); + setNotes((current) => { + const index = current.findIndex((n) => n.id === saved.id); + if (index === -1) return [saved, ...current]; + const next = [...current]; + next[index] = saved; + return next; + }); + succeeded(); + return true; + } catch (e) { + const message = String(e); + setSaveState({ status: "failed", error: message }); + pushToast({ kind: "error", message: "Could not save note", detail: message }); + return false; + } + }, + [projectId, pushToast, succeeded], + ); + + const createNote = useCallback(async () => { + const note = draft(); + // Held locally first so the editor can focus it immediately; the save + // happens on blur like every other edit. + setNotes((current) => [note, ...current]); + return note; + }, []); + + const deleteNote = useCallback( + async (noteId: string) => { + try { + await commands.deleteNote(projectId, noteId); + setNotes((current) => current.filter((n) => n.id !== noteId)); + return true; + } catch (e) { + pushToast({ kind: "error", message: "Could not delete note", detail: String(e) }); + return false; + } + }, + [projectId, pushToast], + ); + + return { notes, loading, saveState, createNote, saveNote, deleteNote }; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index c9a2b1f..49349e8 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types"; +import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) => export const reconcileProjectStatuses = () => invoke("reconcile_project_statuses"); +// Notes — per-project, host-side, readable with the container stopped. +export const listNotes = (projectId: string) => + invoke("list_notes", { projectId }); +/** Insert or replace one note. `created_at` and `id` are owned by the backend. */ +export const saveNote = (projectId: string, note: Note) => + invoke("save_note", { projectId, note }); +export const deleteNote = (projectId: string, noteId: string) => + invoke("delete_note", { projectId, noteId }); + // Settings export const getSettings = () => invoke("get_settings"); export const updateSettings = (settings: AppSettings) => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 1fab346..f332171 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -565,6 +565,16 @@ export interface SchedulerNotification { created_at: string; } +/** One project note. Mirrors `models::Note` — field names are the Rust ones. */ +export interface Note { + id: string; + title: string; + body: string; + pinned: boolean; + created_at: string; + updated_at: string; +} + // ── Auth bridge ────────────────────────────────────────────────────────────── /** Which loopback family the container-side listener was found on.