From 4f717eddbb4e78d1f4e8e62b3a468eeb2d40d07c Mon Sep 17 00:00:00 2001 From: Aaron Gabriel Date: Sat, 18 Apr 2026 14:58:41 -0600 Subject: [PATCH] Add /notes list with search, tag and path filters, and sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default landing for a connected vault is the note list — everything in Lens starts from here. The filter surface mirrors the vault's query API (full-text search, tag filter with any/all match, path prefix, and createdAt sort), debounced at 300ms so each keystroke doesn't fire a request. Pagination is limit+offset at 50/page; next/previous disable at the boundaries. Row layout leads with the path (or id fallback) and shows preview, tags, and a relative-time stamp. VaultAuthError bubbles through TanStack Query so an expired session routes users back to /add with a Reconnect button instead of thrashing. Skeleton rows on first load, placeholder-data retention while a filter change refetches. Empty state distinguishes "no notes yet" from "no notes match" so the copy is honest in both cases. Fixed a latent instability in useActiveVaultClient: the zustand selector was calling loadToken() which JSON.parses on every render, returning a new object identity each time and pushing components into infinite re-render loops when any token was present. Moved the token lookup into useMemo keyed on activeVaultId. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/App.tsx | 2 + src/app/routes/Home.tsx | 77 ++------ src/app/routes/Notes.test.tsx | 169 +++++++++++++++++ src/app/routes/Notes.tsx | 310 +++++++++++++++++++++++++++++++ src/hooks/useDebouncedValue.ts | 10 + src/lib/time.test.ts | 28 +++ src/lib/time.ts | 24 +++ src/lib/vault/client.test.ts | 48 +++++ src/lib/vault/client.ts | 11 +- src/lib/vault/index.ts | 1 + src/lib/vault/note-query.test.ts | 77 ++++++++ src/lib/vault/note-query.ts | 47 +++++ src/lib/vault/queries.ts | 40 +++- src/lib/vault/types.ts | 17 ++ 14 files changed, 797 insertions(+), 64 deletions(-) create mode 100644 src/app/routes/Notes.test.tsx create mode 100644 src/app/routes/Notes.tsx create mode 100644 src/hooks/useDebouncedValue.ts create mode 100644 src/lib/time.test.ts create mode 100644 src/lib/time.ts create mode 100644 src/lib/vault/note-query.test.ts create mode 100644 src/lib/vault/note-query.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index e9abd47..e908101 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,6 +3,7 @@ import { QueryProvider } from "@/providers/QueryProvider"; import { BrowserRouter, Route, Routes } from "react-router"; import { AddVault } from "./routes/AddVault"; import { Home } from "./routes/Home"; +import { Notes } from "./routes/Notes"; import { OAuthCallback } from "./routes/OAuthCallback"; import { Vaults } from "./routes/Vaults"; @@ -15,6 +16,7 @@ export function App() {
} /> + } /> } /> } /> } /> diff --git a/src/app/routes/Home.tsx b/src/app/routes/Home.tsx index 5743199..7ced3ff 100644 --- a/src/app/routes/Home.tsx +++ b/src/app/routes/Home.tsx @@ -1,70 +1,29 @@ -import { useVaultInfo, useVaultStore } from "@/lib/vault"; -import { Link } from "react-router"; +import { useVaultStore } from "@/lib/vault"; +import { Link, Navigate } from "react-router"; export function Home() { const activeVault = useVaultStore((s) => s.getActiveVault()); - const info = useVaultInfo(); - if (!activeVault) { - return ( -
-

- A lens onto any Parachute Vault. -

-

Lens

-

- Point it at a vault. Sign in. Browse, edit, visualize. -

- - - Connect a vault - -
- ); + if (activeVault) { + return ; } return ( -
-

Connected vault

-

{activeVault.name}

-

{activeVault.url}

- -
- {info.isPending ? ( -

Loading vault info…

- ) : info.isError ? ( -
-

Could not load vault info

-

{info.error.message}

-
- ) : info.data ? ( -
-
-
Notes
-
- {info.data.stats?.noteCount ?? 0} -
-
-
-
Tags
-
{info.data.stats?.tagCount ?? 0}
-
-
-
Links
-
- {info.data.stats?.linkCount ?? 0} -
-
-
- ) : null} -
- -

- Note list and editor land in the next PRs. This page confirms the vault handshake works. +

+

+ A lens onto any Parachute Vault.

+

Lens

+

+ Point it at a vault. Sign in. Browse, edit, visualize. +

+ + + Connect a vault +
); } diff --git a/src/app/routes/Notes.test.tsx b/src/app/routes/Notes.test.tsx new file mode 100644 index 0000000..aa7b8ed --- /dev/null +++ b/src/app/routes/Notes.test.tsx @@ -0,0 +1,169 @@ +import { Notes } from "@/app/routes/Notes"; +import { useVaultStore } from "@/lib/vault/store"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { BrowserRouter } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +interface FetchState { + notes: unknown[]; + tags: unknown[]; +} + +function installFetch(state: FetchState) { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + const body = url.includes("/api/tags") ? state.tags : state.notes; + return { + ok: true, + status: 200, + json: async () => body, + text: async () => "", + } as Response; + }); + vi.stubGlobal("fetch", fetchImpl); + return fetchImpl; +} + +function seedStore() { + // Directly mutate zustand state so we don't touch localStorage. + useVaultStore.setState({ + vaults: { + dev: { + id: "dev", + url: "http://localhost:1940", + name: "dev", + issuer: "http://localhost:1940", + clientId: "client-test", + scope: "full", + addedAt: "2026-04-18T00:00:00.000Z", + lastUsedAt: "2026-04-18T00:00:00.000Z", + }, + }, + activeVaultId: "dev", + }); + localStorage.setItem( + "lens:token:dev", + JSON.stringify({ accessToken: "pvt_abc", scope: "full", vault: "default" }), + ); +} + +function Wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return ( + + {children} + + ); +} + +function lastNotesUrl(fetchImpl: ReturnType): string { + const calls = fetchImpl.mock.calls.map((c) => String(c[0])); + const noteCalls = calls.filter((u) => u.includes("/api/notes")); + return noteCalls[noteCalls.length - 1] ?? ""; +} + +describe("Notes route", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + useVaultStore.setState({ vaults: {}, activeVaultId: null }); + seedStore(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("renders fetched notes with path, preview, tags, and relative time", async () => { + installFetch({ + notes: [ + { + id: "n1", + path: "Projects/lens/README", + preview: "A lens onto any Parachute Vault.", + tags: ["project"], + createdAt: "2026-04-18T10:00:00.000Z", + updatedAt: "2026-04-18T11:00:00.000Z", + }, + ], + tags: [{ name: "project", count: 1 }], + }); + + render(, { wrapper: Wrapper }); + + const pathLink = await screen.findByText("Projects/lens/README"); + expect(pathLink).toBeInTheDocument(); + expect(screen.getByText(/A lens onto any Parachute Vault\./)).toBeInTheDocument(); + // Tag chip should live inside the same row as the path. + const row = pathLink.closest("li"); + expect(row).not.toBeNull(); + expect(row?.textContent).toContain("project"); + }); + + it("debounces the search input and sends the search param after 300ms", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const fetchImpl = installFetch({ notes: [], tags: [] }); + + render(, { wrapper: Wrapper }); + + await waitFor(() => { + expect(fetchImpl.mock.calls.some((c) => String(c[0]).includes("/api/notes"))).toBe(true); + }); + + const input = screen.getByLabelText(/search notes/i); + fireEvent.change(input, { target: { value: "hello" } }); + + // Debounce: no search= yet right after typing. + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(lastNotesUrl(fetchImpl)).not.toContain("search=hello"); + + // After the full debounce window, the search param lands on the URL. + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + await waitFor(() => { + expect(lastNotesUrl(fetchImpl)).toContain("search=hello"); + }); + }); + + it("toggles sort direction via the header button", async () => { + const fetchImpl = installFetch({ notes: [], tags: [] }); + render(, { wrapper: Wrapper }); + + await waitFor(() => { + expect(lastNotesUrl(fetchImpl)).toContain("sort=desc"); + }); + + fireEvent.click(screen.getByRole("button", { name: /toggle sort/i })); + + await waitFor(() => { + expect(lastNotesUrl(fetchImpl)).toContain("sort=asc"); + }); + }); + + it("shows empty state when no notes and no active filters", async () => { + installFetch({ notes: [], tags: [] }); + render(, { wrapper: Wrapper }); + expect(await screen.findByText(/this vault has no notes yet/i)).toBeInTheDocument(); + }); + + it("shows filtered-empty state and hides the zero-vault copy", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + installFetch({ notes: [], tags: [] }); + render(, { wrapper: Wrapper }); + + fireEvent.change(screen.getByLabelText(/search notes/i), { target: { value: "xyz" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(await screen.findByText(/no notes match these filters/i)).toBeInTheDocument(); + }); +}); diff --git a/src/app/routes/Notes.tsx b/src/app/routes/Notes.tsx new file mode 100644 index 0000000..a513818 --- /dev/null +++ b/src/app/routes/Notes.tsx @@ -0,0 +1,310 @@ +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { relativeTime } from "@/lib/time"; +import { + DEFAULT_NOTE_QUERY, + DEFAULT_PAGE_SIZE, + type NoteQueryState, + isFilteringActive, + useNotes, + useTags, + useVaultStore, +} from "@/lib/vault"; +import { VaultAuthError } from "@/lib/vault/client"; +import type { Note, TagSummary } from "@/lib/vault/types"; +import { useEffect, useMemo, useState } from "react"; +import { Link, Navigate } from "react-router"; + +export function Notes() { + const activeVault = useVaultStore((s) => s.getActiveVault()); + + const [search, setSearch] = useState(""); + const [pathPrefix, setPathPrefix] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const [tagMatch, setTagMatch] = useState<"any" | "all">("any"); + const [sort, setSort] = useState<"asc" | "desc">("desc"); + const [offset, setOffset] = useState(0); + + const debouncedSearch = useDebouncedValue(search, 300); + const debouncedPrefix = useDebouncedValue(pathPrefix, 300); + + // Any filter change resets pagination. + // biome-ignore lint/correctness/useExhaustiveDependencies: offset is the target, not a trigger + useEffect(() => { + setOffset(0); + }, [debouncedSearch, debouncedPrefix, selectedTags, tagMatch, sort]); + + const queryState: NoteQueryState = useMemo( + () => ({ + ...DEFAULT_NOTE_QUERY, + search: debouncedSearch, + pathPrefix: debouncedPrefix, + tags: selectedTags, + tagMatch, + sort, + offset, + }), + [debouncedSearch, debouncedPrefix, selectedTags, tagMatch, sort, offset], + ); + + const notes = useNotes(queryState); + const tags = useTags(); + + if (!activeVault) return ; + + const pageFirst = offset + 1; + const pageLast = offset + (notes.data?.length ?? 0); + const hasPrev = offset > 0; + const hasNext = (notes.data?.length ?? 0) === DEFAULT_PAGE_SIZE; + + return ( +
+
+
+

{activeVault.name}

+

Notes

+
+ +
+ +
+ setSearch(e.target.value)} + className="w-full rounded-md border border-border bg-card px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none" + aria-label="Search notes" + /> +
+ setPathPrefix(e.target.value)} + className="flex-1 min-w-48 rounded-md border border-border bg-card px-3 py-2 font-mono text-sm text-fg focus:border-accent focus:outline-none" + aria-label="Filter by path prefix" + /> + + setSelectedTags((cur) => + cur.includes(name) ? cur.filter((t) => t !== name) : [...cur, name], + ) + } + tagMatch={tagMatch} + onTagMatchChange={setTagMatch} + onClear={() => setSelectedTags([])} + /> +
+
+ + {notes.isPending ? ( + + ) : notes.isError ? ( + + ) : notes.data && notes.data.length > 0 ? ( +
    + {notes.data.map((n) => ( + + ))} +
+ ) : ( + + )} + +
+ + {notes.data && notes.data.length > 0 + ? `Showing ${pageFirst}–${pageLast}` + : notes.isFetching + ? "Loading…" + : ""} + +
+ + +
+
+
+ ); +} + +function NoteRow({ note }: { note: Note }) { + const label = note.path ?? note.id; + const stamp = note.updatedAt ?? note.createdAt; + return ( +
  • + +
    + {label} + {relativeTime(stamp)} +
    + {note.preview ? ( +

    {note.preview}

    + ) : null} + {note.tags && note.tags.length > 0 ? ( +
    + {note.tags.map((t) => ( + + {t} + + ))} +
    + ) : null} + +
  • + ); +} + +function TagFilter({ + tags, + selected, + onToggle, + tagMatch, + onTagMatchChange, + onClear, +}: { + tags: TagSummary[]; + selected: string[]; + onToggle: (name: string) => void; + tagMatch: "any" | "all"; + onTagMatchChange: (mode: "any" | "all") => void; + onClear: () => void; +}) { + return ( +
    + + Tags{selected.length > 0 ? ` (${selected.length})` : ""} + +
    + {selected.length > 1 ? ( +
    + Match mode + + + +
    + ) : null} + {tags.length === 0 ? ( +

    No tags in this vault.

    + ) : ( +
      + {tags.map((t) => ( +
    • + +
    • + ))} +
    + )} +
    +
    + ); +} + +function SkeletonRows() { + return ( +
      + {[0, 1, 2, 3, 4].map((i) => ( +
    1. +
      +
      +
    2. + ))} +
    + ); +} + +function ErrorBlock({ error }: { error: Error }) { + const isAuth = error instanceof VaultAuthError; + return ( +
    +

    + {isAuth ? "Session expired" : "Could not load notes"} +

    +

    {error.message}

    + {isAuth ? ( + + Reconnect vault + + ) : null} +
    + ); +} + +function EmptyBlock({ filtering }: { filtering: boolean }) { + return ( +
    + {filtering ? ( +

    No notes match these filters.

    + ) : ( + <> +

    This vault has no notes yet.

    +

    Creating notes lands in a later PR.

    + + )} +
    + ); +} diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..b27a2a8 --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + return debounced; +} diff --git a/src/lib/time.test.ts b/src/lib/time.test.ts new file mode 100644 index 0000000..8de724b --- /dev/null +++ b/src/lib/time.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { relativeTime } from "./time"; + +const NOW = new Date("2026-04-18T12:00:00.000Z"); + +describe("relativeTime", () => { + it("returns empty string for undefined or invalid dates", () => { + expect(relativeTime(undefined, NOW)).toBe(""); + expect(relativeTime("not a date", NOW)).toBe(""); + }); + + it("says 'just now' under one minute", () => { + expect(relativeTime("2026-04-18T11:59:30.000Z", NOW)).toBe("just now"); + }); + + it("formats minutes, hours, days, weeks, months, years", () => { + expect(relativeTime("2026-04-18T11:55:00.000Z", NOW)).toBe("5m ago"); + expect(relativeTime("2026-04-18T09:00:00.000Z", NOW)).toBe("3h ago"); + expect(relativeTime("2026-04-16T12:00:00.000Z", NOW)).toBe("2d ago"); + expect(relativeTime("2026-04-04T12:00:00.000Z", NOW)).toBe("2w ago"); + expect(relativeTime("2026-01-18T12:00:00.000Z", NOW)).toBe("3mo ago"); + expect(relativeTime("2024-04-18T12:00:00.000Z", NOW)).toBe("2y ago"); + }); + + it("handles future dates with 'in' prefix", () => { + expect(relativeTime("2026-04-20T12:00:00.000Z", NOW)).toBe("in 2d"); + }); +}); diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..e41274b --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,24 @@ +const UNITS: Array<[string, number]> = [ + ["y", 365 * 24 * 60 * 60 * 1000], + ["mo", 30 * 24 * 60 * 60 * 1000], + ["w", 7 * 24 * 60 * 60 * 1000], + ["d", 24 * 60 * 60 * 1000], + ["h", 60 * 60 * 1000], + ["m", 60 * 1000], +]; + +export function relativeTime(iso: string | undefined, now: Date = new Date()): string { + if (!iso) return ""; + const date = new Date(iso); + const t = date.getTime(); + if (Number.isNaN(t)) return ""; + const diff = now.getTime() - t; + const abs = Math.abs(diff); + for (const [label, ms] of UNITS) { + if (abs >= ms) { + const n = Math.floor(abs / ms); + return diff >= 0 ? `${n}${label} ago` : `in ${n}${label}`; + } + } + return "just now"; +} diff --git a/src/lib/vault/client.test.ts b/src/lib/vault/client.test.ts index 1541b56..2185d74 100644 --- a/src/lib/vault/client.test.ts +++ b/src/lib/vault/client.test.ts @@ -57,4 +57,52 @@ describe("VaultClient", () => { await client.vaultInfo(false); expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost:1940/api/vault"); }); + + it("queryNotes passes URLSearchParams to /api/notes and parses the array", async () => { + const fetchImpl = mockFetch({ + json: [{ id: "a", createdAt: "2026-04-18T00:00:00Z", tags: ["daily"] }], + }); + const client = new VaultClient({ + vaultUrl: "http://localhost:1940", + accessToken: "pvt_abc", + fetchImpl, + }); + + const params = new URLSearchParams({ search: "hello", sort: "desc", limit: "50" }); + const rows = await client.queryNotes(params); + expect(rows).toHaveLength(1); + expect(rows[0]?.tags).toEqual(["daily"]); + expect(fetchImpl.mock.calls[0]?.[0]).toBe( + "http://localhost:1940/api/notes?search=hello&sort=desc&limit=50", + ); + }); + + it("queryNotes omits the querystring entirely when params are empty", async () => { + const fetchImpl = mockFetch({ json: [] }); + const client = new VaultClient({ + vaultUrl: "http://localhost:1940", + accessToken: "pvt_abc", + fetchImpl, + }); + await client.queryNotes(new URLSearchParams()); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost:1940/api/notes"); + }); + + it("listTags hits /api/tags and returns the summary array", async () => { + const fetchImpl = mockFetch({ + json: [ + { name: "daily", count: 42 }, + { name: "work", count: 7 }, + ], + }); + const client = new VaultClient({ + vaultUrl: "http://localhost:1940", + accessToken: "pvt_abc", + fetchImpl, + }); + const tags = await client.listTags(); + expect(tags).toHaveLength(2); + expect(tags[0]).toEqual({ name: "daily", count: 42 }); + expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost:1940/api/tags"); + }); }); diff --git a/src/lib/vault/client.ts b/src/lib/vault/client.ts index fa35229..f4cee3f 100644 --- a/src/lib/vault/client.ts +++ b/src/lib/vault/client.ts @@ -1,4 +1,4 @@ -import type { VaultInfo } from "./types"; +import type { Note, TagSummary, VaultInfo } from "./types"; export interface VaultClientOptions { vaultUrl: string; @@ -49,4 +49,13 @@ export class VaultClient { const query = includeStats ? "?include_stats=true" : ""; return this.request(`/api/vault${query}`); } + + async queryNotes(params: URLSearchParams): Promise { + const qs = params.toString(); + return this.request(`/api/notes${qs ? `?${qs}` : ""}`); + } + + async listTags(): Promise { + return this.request("/api/tags"); + } } diff --git a/src/lib/vault/index.ts b/src/lib/vault/index.ts index fb2f6e8..a949a92 100644 --- a/src/lib/vault/index.ts +++ b/src/lib/vault/index.ts @@ -1,5 +1,6 @@ export * from "./client"; export * from "./discovery"; +export * from "./note-query"; export * from "./oauth"; export * from "./pkce"; export * from "./queries"; diff --git a/src/lib/vault/note-query.test.ts b/src/lib/vault/note-query.test.ts new file mode 100644 index 0000000..32dcc01 --- /dev/null +++ b/src/lib/vault/note-query.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_NOTE_QUERY, + type NoteQueryState, + buildNoteQueryParams, + isFilteringActive, +} from "./note-query"; + +function state(overrides: Partial = {}): NoteQueryState { + return { ...DEFAULT_NOTE_QUERY, ...overrides }; +} + +describe("buildNoteQueryParams", () => { + it("emits sort and limit even with no filters", () => { + const params = buildNoteQueryParams(state()); + expect(params.get("sort")).toBe("desc"); + expect(params.get("limit")).toBe("50"); + expect(params.get("search")).toBeNull(); + expect(params.get("tag")).toBeNull(); + expect(params.get("offset")).toBeNull(); + }); + + it("omits offset when zero, emits when positive", () => { + expect(buildNoteQueryParams(state({ offset: 0 })).get("offset")).toBeNull(); + expect(buildNoteQueryParams(state({ offset: 50 })).get("offset")).toBe("50"); + }); + + it("trims whitespace from search and drops when blank", () => { + expect(buildNoteQueryParams(state({ search: " " })).get("search")).toBeNull(); + expect(buildNoteQueryParams(state({ search: " hello " })).get("search")).toBe("hello"); + }); + + it("joins tags comma-separated", () => { + const params = buildNoteQueryParams(state({ tags: ["daily", "work"] })); + expect(params.get("tag")).toBe("daily,work"); + }); + + it("includes tag_match only when 2+ tags are selected", () => { + expect( + buildNoteQueryParams(state({ tags: ["daily"], tagMatch: "all" })).get("tag_match"), + ).toBeNull(); + expect( + buildNoteQueryParams(state({ tags: ["daily", "work"], tagMatch: "all" })).get("tag_match"), + ).toBe("all"); + expect( + buildNoteQueryParams(state({ tags: ["daily", "work"], tagMatch: "any" })).get("tag_match"), + ).toBe("any"); + }); + + it("passes path_prefix when present, trims blanks", () => { + expect(buildNoteQueryParams(state({ pathPrefix: " " })).get("path_prefix")).toBeNull(); + expect(buildNoteQueryParams(state({ pathPrefix: " Projects/" })).get("path_prefix")).toBe( + "Projects/", + ); + }); + + it("respects explicit sort direction", () => { + expect(buildNoteQueryParams(state({ sort: "asc" })).get("sort")).toBe("asc"); + }); +}); + +describe("isFilteringActive", () => { + it("is false by default", () => { + expect(isFilteringActive(state())).toBe(false); + }); + + it("is true when any filter is set", () => { + expect(isFilteringActive(state({ search: "foo" }))).toBe(true); + expect(isFilteringActive(state({ tags: ["daily"] }))).toBe(true); + expect(isFilteringActive(state({ pathPrefix: "Projects/" }))).toBe(true); + }); + + it("ignores trivially-blank search/prefix", () => { + expect(isFilteringActive(state({ search: " " }))).toBe(false); + expect(isFilteringActive(state({ pathPrefix: " " }))).toBe(false); + }); +}); diff --git a/src/lib/vault/note-query.ts b/src/lib/vault/note-query.ts new file mode 100644 index 0000000..f68570c --- /dev/null +++ b/src/lib/vault/note-query.ts @@ -0,0 +1,47 @@ +export interface NoteQueryState { + search: string; + tags: string[]; + tagMatch: "any" | "all"; + pathPrefix: string; + sort: "asc" | "desc"; + limit: number; + offset: number; +} + +export const DEFAULT_PAGE_SIZE = 50; + +export const DEFAULT_NOTE_QUERY: NoteQueryState = { + search: "", + tags: [], + tagMatch: "any", + pathPrefix: "", + sort: "desc", + limit: DEFAULT_PAGE_SIZE, + offset: 0, +}; + +export function buildNoteQueryParams(state: NoteQueryState): URLSearchParams { + const params = new URLSearchParams(); + const search = state.search.trim(); + if (search) params.set("search", search); + + if (state.tags.length > 0) { + params.set("tag", state.tags.join(",")); + if (state.tags.length > 1) params.set("tag_match", state.tagMatch); + } + + const prefix = state.pathPrefix.trim(); + if (prefix) params.set("path_prefix", prefix); + + params.set("sort", state.sort); + params.set("limit", String(state.limit)); + if (state.offset > 0) params.set("offset", String(state.offset)); + + return params; +} + +export function isFilteringActive(state: NoteQueryState): boolean { + return ( + state.search.trim().length > 0 || state.tags.length > 0 || state.pathPrefix.trim().length > 0 + ); +} diff --git a/src/lib/vault/queries.ts b/src/lib/vault/queries.ts index fda04e1..8213714 100644 --- a/src/lib/vault/queries.ts +++ b/src/lib/vault/queries.ts @@ -1,12 +1,19 @@ -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; import { VaultClient } from "./client"; +import { type NoteQueryState, buildNoteQueryParams } from "./note-query"; +import { loadToken } from "./storage"; import { useVaultStore } from "./store"; export function useActiveVaultClient(): VaultClient | null { const vault = useVaultStore((s) => s.getActiveVault()); - const token = useVaultStore((s) => s.getActiveToken()); - if (!vault || !token) return null; - return new VaultClient({ vaultUrl: vault.url, accessToken: token.accessToken }); + const activeId = useVaultStore((s) => s.activeVaultId); + return useMemo(() => { + if (!vault || !activeId) return null; + const token = loadToken(activeId); + if (!token) return null; + return new VaultClient({ vaultUrl: vault.url, accessToken: token.accessToken }); + }, [vault, activeId]); } export function useVaultInfo() { @@ -20,3 +27,28 @@ export function useVaultInfo() { staleTime: 30_000, }); } + +export function useNotes(queryState: NoteQueryState) { + const client = useActiveVaultClient(); + const activeId = useVaultStore((s) => s.activeVaultId); + + return useQuery({ + queryKey: ["notes", activeId, queryState], + enabled: !!client, + queryFn: () => client!.queryNotes(buildNoteQueryParams(queryState)), + staleTime: 10_000, + placeholderData: keepPreviousData, + }); +} + +export function useTags() { + const client = useActiveVaultClient(); + const activeId = useVaultStore((s) => s.activeVaultId); + + return useQuery({ + queryKey: ["tags", activeId], + enabled: !!client, + queryFn: () => client!.listTags(), + staleTime: 60_000, + }); +} diff --git a/src/lib/vault/types.ts b/src/lib/vault/types.ts index 18e6cda..eb5f81e 100644 --- a/src/lib/vault/types.ts +++ b/src/lib/vault/types.ts @@ -52,6 +52,23 @@ export interface VaultInfo { }; } +export interface Note { + id: string; + path?: string; + createdAt: string; + updatedAt?: string; + tags?: string[]; + metadata?: Record; + preview?: string; + byteSize?: number; + content?: string; +} + +export interface TagSummary { + name: string; + count: number; +} + export interface PendingOAuthState { vaultUrl: string; issuer: string;