Add the notes hook and its IPC wrappers
This commit is contained in:
@@ -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> = {}): 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Note[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | 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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
|
|||||||
export const reconcileProjectStatuses = () =>
|
export const reconcileProjectStatuses = () =>
|
||||||
invoke<Project[]>("reconcile_project_statuses");
|
invoke<Project[]>("reconcile_project_statuses");
|
||||||
|
|
||||||
|
// Notes — per-project, host-side, readable with the container stopped.
|
||||||
|
export const listNotes = (projectId: string) =>
|
||||||
|
invoke<Note[]>("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<Note>("save_note", { projectId, note });
|
||||||
|
export const deleteNote = (projectId: string, noteId: string) =>
|
||||||
|
invoke<void>("delete_note", { projectId, noteId });
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
export const updateSettings = (settings: AppSettings) =>
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
|
|||||||
@@ -565,6 +565,16 @@ export interface SchedulerNotification {
|
|||||||
created_at: string;
|
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 ──────────────────────────────────────────────────────────────
|
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Which loopback family the container-side listener was found on.
|
/** Which loopback family the container-side listener was found on.
|
||||||
|
|||||||
Reference in New Issue
Block a user