Cache notes in one place, and serialise a project's writes
Implements the design spec's §2 — "notes cached in zustand keyed by
project id" — which the plan substituted with a hook-local `useState`.
Sharing the `NotesPanel` *component* between the Project Home sub-tab and
the dock did not share the *cache*. Both resolve to the same project, so
two panels mount two `useNotes(P)`, each with its own list. Edit a note
in the dock and blur; the tab's copy is still pre-edit, and the tab's
next blur commits `{...staleRecord, title, body}` — the dock's edit gone
from disk with no error and no indicator. That is the feature's own
primary workflow: take notes in the dock while the agent runs, which is
the reason the dock exists, then go back to the tab.
`notesByProject` plus a per-project in-flight flag now hold the list.
Both surfaces render from one array; two panels mounting for one project
make one read; and because the write is keyed by project, a response
that lands after the user has moved on updates the project it belongs to
rather than whichever is on screen. This is also the boundary §8 says a
detached notes window needs.
Three more bugs in the same code, fixed with it:
- Delete-after-edit could resurrect the note. Clicking Delete with the
textarea focused fires blur first, so `save_note` and `delete_note` go
out back to back; Rust's `write_lock` stops them interleaving but does
not order them, and a delete that wins the lock is undone by the
upsert behind it. A project's mutations now go through one promise
chain, module-scoped for the reason `useTerminal`'s input queue is.
- An unsaved draft vanished when any other note was saved, because the
re-read replaced the list with the backend's. "New note" now persists,
so the backend owns the row from the start — chosen over merging local
drafts because a local-only row in a *shared* cache would exist in the
panel that made it and nowhere else.
- The save outcome was reported for the wrong project after a switch:
the guard covered only the list replacement, so the new project's
SaveIndicator flashed "Saved ✓" for the old project's write. The
indicator now resets on a project change and reports only its own.
`NotesPanel` also re-seeds its draft when the *stored* text of the note
it has selected changes, so an edit made in the other surface reaches
the editor and not only the list. It never overwrites something
half-typed; that still blurs into a last-writer-wins save, as any
blur-commit editor does.
NotesPanel.shared.test.tsx is the configuration none of the existing
tests had: two panels, one project, the real hook. Four of its six
assertions fail against the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import NotesPanel from "./NotesPanel";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two panels, one project — the configuration the app actually runs in.
|
||||||
|
*
|
||||||
|
* `NotesTab` and `NotesDock` both mount a `NotesPanel`, and the dock follows
|
||||||
|
* the active tab's project, so opening the dock over a Project Home tab mounts
|
||||||
|
* two panels for the *same* project. Every other notes test mounts exactly
|
||||||
|
* one, which is precisely the configuration in which a per-panel cache looks
|
||||||
|
* correct: it is only with two that an edit made in one is seen — or lost — by
|
||||||
|
* the other. `useNotes` is deliberately **not** mocked here; the cache is what
|
||||||
|
* is under test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const files: Record<string, Note[]> = {};
|
||||||
|
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
listNotes: async (p: string) => [...(files[p] ?? [])],
|
||||||
|
saveNote: async (p: string, n: Note) => {
|
||||||
|
const list = files[p] ?? (files[p] = []);
|
||||||
|
const at = list.findIndex((x) => x.id === n.id);
|
||||||
|
if (at === -1) list.unshift(n);
|
||||||
|
else list[at] = n;
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
deleteNote: async (p: string, id: string) => {
|
||||||
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SendToAgentButton", () => ({
|
||||||
|
default: () => <button type="button">Send to agent</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
|
id: "n1",
|
||||||
|
title: "Deploy steps",
|
||||||
|
body: "one",
|
||||||
|
pinned: false,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The tab and the dock, mounted together the way `App` mounts them. */
|
||||||
|
function renderBothSurfaces() {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<div data-testid="tab">
|
||||||
|
<NotesPanel projectId="p1" />
|
||||||
|
</div>
|
||||||
|
<div data-testid="dock">
|
||||||
|
<NotesPanel projectId="p1" />
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
tab: () => within(screen.getByTestId("tab")),
|
||||||
|
dock: () => within(screen.getByTestId("dock")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of Object.keys(files)) delete files[key];
|
||||||
|
files.p1 = [note()];
|
||||||
|
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NotesPanel with the tab and the dock both open", () => {
|
||||||
|
it("shows an edit made in one surface in the other", async () => {
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
const dockTitle = dock().getByLabelText("Note title");
|
||||||
|
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||||
|
fireEvent.blur(dockTitle);
|
||||||
|
|
||||||
|
// The other surface's list *and* its editor, not just one of them.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(tab().getByRole("button", { name: /deploy steps v2/i })).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(tab().getByLabelText("Note title")).toHaveValue("Deploy steps v2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not write one surface's stale copy over the other's edit", async () => {
|
||||||
|
// The reported repro: edit in the dock, then go back to the tab and edit
|
||||||
|
// there. With a cache per panel, the tab committed `{...staleNote, ...}`
|
||||||
|
// and the dock's edit was gone from disk with no error and no indicator.
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
const dockTitle = dock().getByLabelText("Note title");
|
||||||
|
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||||
|
fireEvent.blur(dockTitle);
|
||||||
|
await waitFor(() => expect(files.p1[0].title).toBe("Deploy steps v2"));
|
||||||
|
|
||||||
|
const tabBody = tab().getByLabelText("Note body");
|
||||||
|
fireEvent.change(tabBody, { target: { value: "two" } });
|
||||||
|
fireEvent.blur(tabBody);
|
||||||
|
|
||||||
|
await waitFor(() => expect(files.p1[0].body).toBe("two"));
|
||||||
|
expect(files.p1).toHaveLength(1);
|
||||||
|
expect(files.p1[0].title).toBe("Deploy steps v2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the project once for both surfaces", async () => {
|
||||||
|
// Two panels are two `useNotes`, but the in-flight flag is per project, so
|
||||||
|
// mounting the dock over an open Notes tab does not re-read the file.
|
||||||
|
const listNotes = vi.spyOn(
|
||||||
|
await import("../../lib/tauri-commands"),
|
||||||
|
"listNotes",
|
||||||
|
);
|
||||||
|
renderBothSurfaces();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByLabelText("Note body")[0]).toHaveValue("one"),
|
||||||
|
);
|
||||||
|
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||||
|
listNotes.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps text the user is part-way through typing when the other surface saves", async () => {
|
||||||
|
// Showing a remote edit must never mean discarding an unsaved local one.
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
const tabBody = tab().getByLabelText("Note body");
|
||||||
|
fireEvent.change(tabBody, { target: { value: "half-typed" } });
|
||||||
|
|
||||||
|
const dockBody = dock().getByLabelText("Note body");
|
||||||
|
fireEvent.change(dockBody, { target: { value: "saved in the dock" } });
|
||||||
|
fireEvent.blur(dockBody);
|
||||||
|
await waitFor(() => expect(files.p1[0].body).toBe("saved in the dock"));
|
||||||
|
|
||||||
|
expect(tabBody).toHaveValue("half-typed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to another note when the selected one is deleted", async () => {
|
||||||
|
// The claim a differently-named test in NotesPanel.test.tsx used to make
|
||||||
|
// and could not keep: `useNotes` is mocked there and its list never
|
||||||
|
// changes, so the fallback was invisible. Here the list is real.
|
||||||
|
files.p1 = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||||
|
const { tab } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
fireEvent.click(tab().getByRole("button", { name: /delete note/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("beware"));
|
||||||
|
expect(tab().queryByRole("button", { name: /deploy steps/i })).not.toBeInTheDocument();
|
||||||
|
expect(files.p1).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a note created in one surface in the other", async () => {
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
fireEvent.click(dock().getByRole("button", { name: /new note/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
|
||||||
|
);
|
||||||
|
expect(files.p1).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNotes } from "../../hooks/useNotes";
|
import { useNotes } from "../../hooks/useNotes";
|
||||||
import NoteEditor from "./NoteEditor";
|
import NoteEditor from "./NoteEditor";
|
||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
@@ -30,22 +30,44 @@ export default function NotesPanel({ projectId }: Props) {
|
|||||||
[notes, selectedId],
|
[notes, selectedId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load the selected note's stored text into the draft. Keyed on the id, not
|
// What was last copied out of the store into the draft fields. The draft is
|
||||||
// the note object, so a save round trip does not stomp what is being typed.
|
// "untouched" exactly while it still matches this, which is how an edit made
|
||||||
|
// somewhere else can be shown without ever discarding something half-typed.
|
||||||
|
const seeded = useRef<{ id: string | null; title: string; body: string }>({
|
||||||
|
id: null,
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the selected note's stored text into the draft — on a change of note,
|
||||||
|
// and on a change to the *stored* text of the note already selected. The
|
||||||
|
// second case is the dock and the tab showing one project at once: an edit
|
||||||
|
// committed in one surface has to reach the other's editor, not just its
|
||||||
|
// list. It never overwrites text the user is part-way through typing; that
|
||||||
|
// blurs into a last-writer-wins save, as any blur-commit editor does.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
|
seeded.current = { id: null, title: "", body: "" };
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setBody("");
|
setBody("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTitle(selected.title);
|
const untouched =
|
||||||
setBody(selected.body);
|
title === seeded.current.title && body === seeded.current.body;
|
||||||
}, [selected?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
if (seeded.current.id !== selected.id || untouched) {
|
||||||
|
seeded.current = { id: selected.id, title: selected.title, body: selected.body };
|
||||||
|
setTitle(selected.title);
|
||||||
|
setBody(selected.body);
|
||||||
|
}
|
||||||
|
}, [selected?.id, selected?.title, selected?.body]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Reading is not editing: clicking through notes must not rewrite the file.
|
// Reading is not editing: clicking through notes must not rewrite the file.
|
||||||
if (title === selected.title && body === selected.body) return;
|
if (title === selected.title && body === selected.body) return;
|
||||||
|
// Mark the draft as matching what was just committed, so the store update
|
||||||
|
// this save produces reads as "no change" rather than as a stale re-seed.
|
||||||
|
seeded.current = { id: selected.id, title, body };
|
||||||
void saveNote({ ...selected, title, body });
|
void saveNote({ ...selected, title, body });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+173
-10
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
import { useNotes } from "./useNotes";
|
import { useNotes } from "./useNotes";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
import type { Note } from "../lib/types";
|
import type { Note } from "../lib/types";
|
||||||
|
|
||||||
const listNotes = vi.fn();
|
const listNotes = vi.fn();
|
||||||
@@ -13,14 +14,6 @@ vi.mock("../lib/tauri-commands", () => ({
|
|||||||
deleteNote: (p: string, id: string) => deleteNote(p, id),
|
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 => ({
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
id: "n1",
|
id: "n1",
|
||||||
title: "Deploy",
|
title: "Deploy",
|
||||||
@@ -31,8 +24,35 @@ const note = (over: Partial<Note> = {}): Note => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The toasts the hook pushed. The store is real, so this is what a user sees. */
|
||||||
|
const toasts = () => useAppState.getState().toasts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in for the Rust store: one list per project, upsert and delete
|
||||||
|
* applied to it, `list_notes` reading it back. Several of these tests are about
|
||||||
|
* what the *list* looks like after a sequence of writes, which a per-call
|
||||||
|
* `mockResolvedValueOnce` cannot express.
|
||||||
|
*/
|
||||||
|
function fakeBackend(initial: Record<string, Note[]> = {}) {
|
||||||
|
const files: Record<string, Note[]> = { ...initial };
|
||||||
|
listNotes.mockImplementation(async (p: string) => [...(files[p] ?? [])]);
|
||||||
|
saveNote.mockImplementation(async (p: string, n: Note) => {
|
||||||
|
const list = files[p] ?? (files[p] = []);
|
||||||
|
const at = list.findIndex((x) => x.id === n.id);
|
||||||
|
if (at === -1) list.unshift(n);
|
||||||
|
else list[at] = n;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
deleteNote.mockImplementation(async (p: string, id: string) => {
|
||||||
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// The cache is shared app state now, so it has to be reset like any other.
|
||||||
|
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||||
listNotes.mockResolvedValue([note()]);
|
listNotes.mockResolvedValue([note()]);
|
||||||
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
||||||
deleteNote.mockResolvedValue(undefined);
|
deleteNote.mockResolvedValue(undefined);
|
||||||
@@ -60,7 +80,7 @@ describe("useNotes", () => {
|
|||||||
|
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(false);
|
||||||
expect(result.current.saveState.status).toBe("failed");
|
expect(result.current.saveState.status).toBe("failed");
|
||||||
expect(pushToast).toHaveBeenCalled();
|
expect(toasts()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the saved note in place rather than appending", async () => {
|
it("replaces the saved note in place rather than appending", async () => {
|
||||||
@@ -128,7 +148,7 @@ describe("useNotes", () => {
|
|||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
expect(result.current.notes).toHaveLength(0);
|
expect(result.current.notes).toHaveLength(0);
|
||||||
expect(pushToast).toHaveBeenCalled();
|
expect(toasts()).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ends with the list the backend returned when saving a new note", async () => {
|
it("ends with the list the backend returned when saving a new note", async () => {
|
||||||
@@ -206,4 +226,147 @@ describe("useNotes", () => {
|
|||||||
expect(result.current.notes).toHaveLength(1);
|
expect(result.current.notes).toHaveLength(1);
|
||||||
expect(result.current.notes[0].id).toBe("n2");
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the notes already on screen when a refresh fails", async () => {
|
||||||
|
// The second surface mounting for a project is a refresh behind a list the
|
||||||
|
// user is already reading. One shared cache means a failed refresh would
|
||||||
|
// otherwise blank both panels.
|
||||||
|
fakeBackend({ p1: [note()] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
listNotes.mockRejectedValueOnce(new Error("read failed"));
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(tab.result.current.notes).toHaveLength(1);
|
||||||
|
expect(dock.result.current.notes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report the old project's save on the new project's indicator", async () => {
|
||||||
|
// The indicator is per-panel and reads "Saved ✓". Firing it after a switch
|
||||||
|
// tells the user their *current* project was written when it was not.
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
let resolveSave: ((n: Note) => void) | undefined;
|
||||||
|
saveNote.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => (resolveSave = resolve)),
|
||||||
|
);
|
||||||
|
let savePromise: Promise<boolean> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
savePromise = result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave?.(note({ body: "edited" }));
|
||||||
|
await savePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.saveState.status).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports a save on the indicator of the project it was made for", 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.saveState.status).toBe("saved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serialises a project's writes so an edit cannot be re-inserted after its delete", async () => {
|
||||||
|
// Clicking Delete while the textarea has focus fires blur first, so a save
|
||||||
|
// and a delete go out back to back. The Rust write lock stops them
|
||||||
|
// interleaving but does not order them: a delete that wins the lock is
|
||||||
|
// undone by the upsert behind it, and the note comes back on next load.
|
||||||
|
const files = fakeBackend({ p1: [note()] });
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const order: string[] = [];
|
||||||
|
saveNote.mockImplementationOnce(async (p: string, n: Note) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
order.push("save");
|
||||||
|
files[p] = [n];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
deleteNote.mockImplementationOnce(async (p: string, id: string) => {
|
||||||
|
order.push("delete");
|
||||||
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const save = result.current.saveNote(note({ body: "typo fixed" }));
|
||||||
|
const del = result.current.deleteNote("n1");
|
||||||
|
await Promise.all([save, del]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order).toEqual(["save", "delete"]);
|
||||||
|
expect(files.p1).toHaveLength(0);
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a new note when another note is saved right after it", async () => {
|
||||||
|
// A purely local draft used to be wiped by the next re-read: two clicks of
|
||||||
|
// "New note", type in the second, blur, and the first row was gone.
|
||||||
|
const files = fakeBackend({ p1: [note()] });
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
let first: Note | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
first = await result.current.createNote();
|
||||||
|
await result.current.createNote();
|
||||||
|
});
|
||||||
|
expect(result.current.notes).toHaveLength(3);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(3);
|
||||||
|
expect(result.current.notes.some((n) => n.id === first!.id)).toBe(true);
|
||||||
|
expect(files.p1).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one cache between every hook watching the same project", async () => {
|
||||||
|
// The Project Home sub-tab and the dock both mount a panel for the same
|
||||||
|
// project. Two caches meant an edit in one was invisible to the other, and
|
||||||
|
// the other's next blur wrote its stale copy back over it.
|
||||||
|
fakeBackend({ p1: [note()] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
await waitFor(() => expect(dock.result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// One read for both — the in-flight flag is per project, not per hook.
|
||||||
|
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await dock.result.current.saveNote(note({ body: "written in the dock" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tab.result.current.notes[0].body).toBe("written in the dock");
|
||||||
|
expect(tab.result.current.notes).toBe(dock.result.current.notes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not blank an already-loaded list when a second panel mounts", async () => {
|
||||||
|
fakeBackend({ p1: [note()] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
// No "Loading notes…" flash on the second surface.
|
||||||
|
expect(dock.result.current.loading).toBe(false);
|
||||||
|
expect(dock.result.current.notes).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+198
-82
@@ -8,8 +8,8 @@ import { useAppState } from "../store/appState";
|
|||||||
function draft(): Note {
|
function draft(): Note {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
// The backend owns the real id; this one only has to be unique enough to
|
// The backend keeps whatever id it is handed for a note it has not seen,
|
||||||
// key the list until the first save returns.
|
// so this one is the note's real id from the first save onward.
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: "",
|
title: "",
|
||||||
body: "",
|
body: "",
|
||||||
@@ -19,41 +19,108 @@ function draft(): Note {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stable empty list, so a project with nothing cached does not re-render on identity. */
|
||||||
|
const NO_NOTES: Note[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-project mutation chain.
|
||||||
|
*
|
||||||
|
* A project's writes are serialised so that two of them cannot be in flight at
|
||||||
|
* once. The Rust `write_lock` stops an upsert and a delete *interleaving*; it
|
||||||
|
* does not order them, and the order is the part that matters here. Clicking
|
||||||
|
* Delete while the textarea has focus fires `blur` first, so `save_note` and
|
||||||
|
* `delete_note` are issued back to back — and if the delete wins the lock, the
|
||||||
|
* upsert behind it re-inserts the note and it comes back on the next load.
|
||||||
|
* "Fix a typo, decide the note is useless, delete it" is an ordinary sequence.
|
||||||
|
*
|
||||||
|
* Module scope, not hook scope, for the reason `useTerminal`'s input queue is:
|
||||||
|
* several components call `useNotes` for the same project (the Project Home
|
||||||
|
* tab and the dock), and a per-hook chain would give each its own ordering and
|
||||||
|
* leave them racing each other — which is the bug, not the fix.
|
||||||
|
*/
|
||||||
|
const mutationChains = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
function enqueueMutation<T>(projectId: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = mutationChains.get(projectId) ?? Promise.resolve();
|
||||||
|
// `run` on both arms: a failed mutation must not stall every later one.
|
||||||
|
const result = previous.then(run, run);
|
||||||
|
const tail = result.then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
mutationChains.set(projectId, tail);
|
||||||
|
void tail.then(() => {
|
||||||
|
// Drop the entry once idle, so closed projects do not accumulate.
|
||||||
|
if (mutationChains.get(projectId) === tail) mutationChains.delete(projectId);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the canonical list into the shared cache.
|
||||||
|
*
|
||||||
|
* A successful save stamps a new `updated_at` and the backend sorts on it, so
|
||||||
|
* the record's position has changed and positional patching would disagree
|
||||||
|
* with what a reload would show. The backend owns the order; the webview never
|
||||||
|
* sorts. A failed re-read leaves the cache alone rather than clearing it.
|
||||||
|
*/
|
||||||
|
async function refresh(projectId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const reloaded = await commands.listNotes(projectId);
|
||||||
|
useAppState.getState().setProjectNotes(projectId, reloaded);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A project's notes, cached from the backend.
|
* A project's notes, cached from the backend.
|
||||||
*
|
*
|
||||||
* The backend is the source of truth and this is a cache — every mutation goes
|
* The backend is the source of truth and the zustand slice is the cache —
|
||||||
* through a command and the returned record replaces the local one, so the
|
* every mutation goes through a command and the returned list replaces the
|
||||||
* list can never drift from the file. `saveState` mirrors `useProjectSave` so
|
* cached one, so the list can never drift from the file. The cache lives in
|
||||||
* `ui/SaveIndicator` can report the outcome: a save that fails silently is a
|
* the store rather than in this hook because two surfaces show the same
|
||||||
* user staring at text they believe is stored.
|
* project's notes at once; see `notesByProject`.
|
||||||
|
*
|
||||||
|
* `saveState` is deliberately *not* shared: it is this panel's report of this
|
||||||
|
* panel's write, and `ui/SaveIndicator` is per-panel. A save that fails
|
||||||
|
* silently is a user staring at text they believe is stored.
|
||||||
*/
|
*/
|
||||||
export function useNotes(projectId: string) {
|
export function useNotes(projectId: string) {
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
const cached = useAppState((s) => s.notesByProject[projectId]);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
|
||||||
const pushToast = useAppState((s) => s.pushToast);
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||||
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const currentProjectId = useRef(projectId);
|
const currentProjectId = useRef(projectId);
|
||||||
currentProjectId.current = projectId;
|
currentProjectId.current = projectId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) {
|
if (!projectId) return;
|
||||||
setNotes([]);
|
// Read through `getState` rather than through subscribed values: the
|
||||||
setLoading(false);
|
// effect must fire once per project, not again every time the flag it sets
|
||||||
return;
|
// changes. Two panels mounting for the same project therefore make one
|
||||||
}
|
// read, and the second renders from the cache with no loading flash.
|
||||||
let cancelled = false;
|
const store = useAppState.getState();
|
||||||
setLoading(true);
|
if (store.notesLoading[projectId]) return;
|
||||||
setNotes([]);
|
store.setNotesLoading(projectId, true);
|
||||||
commands
|
commands
|
||||||
.listNotes(projectId)
|
.listNotes(projectId)
|
||||||
.then((loaded) => {
|
.then((loaded) => {
|
||||||
if (!cancelled) setNotes(loaded);
|
useAppState.getState().setProjectNotes(projectId, loaded);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (cancelled) return;
|
// A project that has never been read caches the empty list, so a panel
|
||||||
setNotes([]);
|
// does not sit on "Loading notes…" forever. One that *has* been read
|
||||||
|
// keeps what it has: this load is a refresh behind a list already on
|
||||||
|
// screen — the second surface mounting, say — and a failed refresh
|
||||||
|
// must not blank both of them. Same rule as `refresh()`. Neither
|
||||||
|
// branch has a stale-project hazard, because the write is keyed by the
|
||||||
|
// project it belongs to.
|
||||||
|
const store = useAppState.getState();
|
||||||
|
if (store.notesByProject[projectId] === undefined) {
|
||||||
|
store.setProjectNotes(projectId, []);
|
||||||
|
}
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
message: "Could not load notes for this project",
|
message: "Could not load notes for this project",
|
||||||
@@ -61,11 +128,8 @@ export function useNotes(projectId: string) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false);
|
useAppState.getState().setNotesLoading(projectId, false);
|
||||||
});
|
});
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [projectId, pushToast]);
|
}, [projectId, pushToast]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -75,6 +139,27 @@ export function useNotes(projectId: string) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The indicator belongs to whatever project this panel is showing *now*.
|
||||||
|
// Without this, switching project mid-save leaves the new project's
|
||||||
|
// SaveIndicator stuck on the old project's "Saving…" — the same wrong-project
|
||||||
|
// report as flashing its "Saved ✓", just in the other direction.
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
setSaveState({ status: "idle", error: null });
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this hook is still looking at the project a queued mutation was
|
||||||
|
* issued for. Only the *reporting* is gated on it — the cache write is not,
|
||||||
|
* because it is keyed by project and belongs to that project either way.
|
||||||
|
* Without this, the new project's SaveIndicator flashes "Saved ✓" for the
|
||||||
|
* old project's write.
|
||||||
|
*/
|
||||||
|
const isCurrent = useCallback(
|
||||||
|
() => currentProjectId.current === projectId,
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
|
||||||
const succeeded = useCallback(() => {
|
const succeeded = useCallback(() => {
|
||||||
setSaveState({ status: "saved", error: null });
|
setSaveState({ status: "saved", error: null });
|
||||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
@@ -85,68 +170,99 @@ export function useNotes(projectId: string) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveNote = useCallback(
|
const saveNote = useCallback(
|
||||||
async (note: Note) => {
|
(note: Note) =>
|
||||||
if (resetTimer.current) clearTimeout(resetTimer.current);
|
enqueueMutation(projectId, async () => {
|
||||||
setSaveState({ status: "saving", error: null });
|
if (isCurrent()) {
|
||||||
try {
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
await commands.saveNote(projectId, note);
|
setSaveState({ status: "saving", error: null });
|
||||||
// Re-read the canonical list from the backend. A successful save stamps a new
|
|
||||||
// `updated_at`, and the backend sorts unpinned notes by `updated_at` descending,
|
|
||||||
// so the record's position has changed and positional patching would disagree with
|
|
||||||
// what a reload would show.
|
|
||||||
try {
|
|
||||||
const reloaded = await commands.listNotes(projectId);
|
|
||||||
// Guard against a stale callback: if the project changed while the save was in
|
|
||||||
// flight, don't overwrite the new project's list with the old one. The save itself
|
|
||||||
// still succeeded, so succeeded() still fires; only the list replacement is skipped.
|
|
||||||
if (currentProjectId.current === projectId) {
|
|
||||||
setNotes(reloaded);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep the save reported as successful (it was) and leave the existing list alone
|
|
||||||
// rather than clearing it if the re-read fails.
|
|
||||||
}
|
}
|
||||||
succeeded();
|
try {
|
||||||
return true;
|
await commands.saveNote(projectId, note);
|
||||||
} catch (e) {
|
await refresh(projectId);
|
||||||
const message = String(e);
|
if (isCurrent()) succeeded();
|
||||||
setSaveState({ status: "failed", error: message });
|
return true;
|
||||||
pushToast({ kind: "error", message: "Could not save note", detail: message });
|
} catch (e) {
|
||||||
return false;
|
const message = String(e);
|
||||||
}
|
if (isCurrent()) setSaveState({ status: "failed", error: message });
|
||||||
},
|
// The toast is not project-scoped — it names the failure and stays
|
||||||
[projectId, pushToast, succeeded],
|
// readable after a switch — so it fires either way.
|
||||||
|
pushToast({ kind: "error", message: "Could not save note", detail: message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[projectId, pushToast, succeeded, isCurrent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createNote = useCallback(async () => {
|
/**
|
||||||
const note = draft();
|
* Create a note by persisting it, rather than holding it locally until the
|
||||||
// Held locally first so the editor can focus it immediately; the save
|
* first blur.
|
||||||
// happens on blur like every other edit. The note does not exist backend-side,
|
*
|
||||||
// so no re-read can place it and no backend ordering applies to it yet. Prepending
|
* The draft used to live only in the list, which meant any *other* note
|
||||||
// puts it at the top where the user can see it immediately, and on the first save
|
* being saved replaced the list with the backend's and the unsaved draft
|
||||||
// its canonical position is established.
|
* silently vanished — click "New note" twice, type in the second, blur, and
|
||||||
setNotes((current) => [note, ...current]);
|
* the first row is gone. Sharing one cache between two surfaces makes that
|
||||||
return note;
|
* worse rather than better: a local-only row would exist in whichever panel
|
||||||
}, []);
|
* created it and nowhere else. Letting the backend own the row from the
|
||||||
|
* start removes the whole class: there is no such thing as a note in the
|
||||||
const deleteNote = useCallback(
|
* list that the file does not have.
|
||||||
async (noteId: string) => {
|
*/
|
||||||
try {
|
const createNote = useCallback(
|
||||||
await commands.deleteNote(projectId, noteId);
|
() =>
|
||||||
// Guard against a stale callback: if the project changed while the delete was in
|
enqueueMutation(projectId, async () => {
|
||||||
// flight, don't modify the list. Note ids are UUIDs so a stale filter cannot match
|
const note = draft();
|
||||||
// another project's note, but applying the same guard for consistency.
|
try {
|
||||||
if (currentProjectId.current === projectId) {
|
const saved = await commands.saveNote(projectId, note);
|
||||||
setNotes((current) => current.filter((n) => n.id !== noteId));
|
if (!(await refresh(projectId))) {
|
||||||
|
// The note exists; only the re-read failed. Show it rather than
|
||||||
|
// leaving the user with a button that did nothing visible.
|
||||||
|
const store = useAppState.getState();
|
||||||
|
store.setProjectNotes(projectId, [
|
||||||
|
saved,
|
||||||
|
...(store.notesByProject[projectId] ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not create note",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return true;
|
}),
|
||||||
} catch (e) {
|
|
||||||
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[projectId, pushToast],
|
[projectId, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { notes, loading, saveState, createNote, saveNote, deleteNote };
|
const deleteNote = useCallback(
|
||||||
|
(noteId: string) =>
|
||||||
|
enqueueMutation(projectId, async () => {
|
||||||
|
try {
|
||||||
|
await commands.deleteNote(projectId, noteId);
|
||||||
|
const store = useAppState.getState();
|
||||||
|
store.setProjectNotes(
|
||||||
|
projectId,
|
||||||
|
(store.notesByProject[projectId] ?? []).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: cached ?? NO_NOTES,
|
||||||
|
// Only "loading" before the project has ever been read — never on a
|
||||||
|
// refresh behind a list that is already on screen, and never on the second
|
||||||
|
// panel to mount for a project the first one already fetched. A failed
|
||||||
|
// load caches the empty list, so this cannot latch on.
|
||||||
|
loading: Boolean(projectId) && cached === undefined,
|
||||||
|
saveState,
|
||||||
|
createNote,
|
||||||
|
saveNote,
|
||||||
|
deleteNote,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types";
|
import type {
|
||||||
|
Project,
|
||||||
|
TerminalSession,
|
||||||
|
AppSettings,
|
||||||
|
UpdateInfo,
|
||||||
|
ImageUpdateInfo,
|
||||||
|
Note,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
||||||
|
|
||||||
@@ -146,6 +153,29 @@ interface AppState {
|
|||||||
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
||||||
moveActiveTab: (delta: number) => void;
|
moveActiveTab: (delta: number) => void;
|
||||||
|
|
||||||
|
// Per-project notes, cached from the backend.
|
||||||
|
//
|
||||||
|
// Rust is the source of truth and this is a cache — but it has to be *one*
|
||||||
|
// cache. Notes are shown by two surfaces at once (the Project Home sub-tab
|
||||||
|
// and the dock, which resolves to the same project), and a hook-local
|
||||||
|
// `useState` in each gave them independent copies: an edit made in the dock
|
||||||
|
// was invisible to the tab, and the tab's next blur wrote its stale record
|
||||||
|
// back over it with no error and no indicator. Keyed by project id so a
|
||||||
|
// response that lands after the user has moved on updates the project it
|
||||||
|
// belongs to instead of whichever one is on screen.
|
||||||
|
//
|
||||||
|
// This is also the boundary a detached notes window would need: swap the
|
||||||
|
// transport for a `notes-changed` event and both windows feed the same slice.
|
||||||
|
notesByProject: Record<string, Note[]>;
|
||||||
|
/**
|
||||||
|
* Projects with a `list_notes` in flight, so two panels mounting for the
|
||||||
|
* same project make one read rather than two, and so a panel whose project
|
||||||
|
* has never been read can tell "loading" from "no notes".
|
||||||
|
*/
|
||||||
|
notesLoading: Record<string, boolean>;
|
||||||
|
setProjectNotes: (projectId: string, notes: Note[]) => void;
|
||||||
|
setNotesLoading: (projectId: string, loading: boolean) => void;
|
||||||
|
|
||||||
// Inline container progress, replacing the blocking progress modal.
|
// Inline container progress, replacing the blocking progress modal.
|
||||||
containerProgress: Record<string, string>;
|
containerProgress: Record<string, string>;
|
||||||
setContainerProgress: (projectId: string, message: string | null) => void;
|
setContainerProgress: (projectId: string, message: string | null) => void;
|
||||||
@@ -395,6 +425,22 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
return { tabOrder };
|
return { tabOrder };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
notesByProject: {},
|
||||||
|
notesLoading: {},
|
||||||
|
setProjectNotes: (projectId, notes) =>
|
||||||
|
set((state) => ({
|
||||||
|
notesByProject: { ...state.notesByProject, [projectId]: notes },
|
||||||
|
})),
|
||||||
|
setNotesLoading: (projectId, loading) =>
|
||||||
|
set((state) => {
|
||||||
|
if ((state.notesLoading[projectId] ?? false) === loading) return {};
|
||||||
|
const next = { ...state.notesLoading };
|
||||||
|
if (loading) next[projectId] = true;
|
||||||
|
else delete next[projectId];
|
||||||
|
return { notesLoading: next };
|
||||||
|
}),
|
||||||
|
|
||||||
// Container progress
|
// Container progress
|
||||||
containerProgress: {},
|
containerProgress: {},
|
||||||
setContainerProgress: (projectId, message) =>
|
setContainerProgress: (projectId, message) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user