Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,6 +16,7 @@ export function App() {
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/notes" element={<Notes />} />
<Route path="/add" element={<AddVault />} />
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/vaults" element={<Vaults />} />
Expand Down
77 changes: 18 additions & 59 deletions src/app/routes/Home.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
<p className="mb-8 font-serif text-xl italic text-fg-muted">
A lens onto any Parachute Vault.
</p>
<h1 className="mb-4 font-serif text-5xl tracking-tight">Lens</h1>
<p className="mb-10 text-fg-dim tracking-wide">
Point it at a vault. Sign in. Browse, edit, visualize.
</p>

<Link
to="/add"
className="inline-block rounded-md bg-accent px-6 py-3 text-sm font-medium text-white hover:bg-accent-hover"
>
Connect a vault
</Link>
</div>
);
if (activeVault) {
return <Navigate to="/notes" replace />;
}

return (
<div className="mx-auto max-w-3xl px-6 py-16">
<p className="mb-2 text-sm uppercase tracking-wider text-fg-dim">Connected vault</p>
<h1 className="mb-2 font-serif text-4xl tracking-tight">{activeVault.name}</h1>
<p className="mb-10 font-mono text-sm text-fg-muted">{activeVault.url}</p>

<div className="rounded-xl border border-border bg-card p-8 shadow-sm">
{info.isPending ? (
<p className="text-fg-muted">Loading vault info…</p>
) : info.isError ? (
<div>
<p className="mb-1 font-medium text-red-400">Could not load vault info</p>
<p className="text-sm text-fg-muted">{info.error.message}</p>
</div>
) : info.data ? (
<dl className="grid grid-cols-3 gap-8 text-center">
<div>
<dt className="text-xs uppercase tracking-wider text-fg-dim">Notes</dt>
<dd className="mt-2 font-serif text-3xl text-fg">
{info.data.stats?.noteCount ?? 0}
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider text-fg-dim">Tags</dt>
<dd className="mt-2 font-serif text-3xl text-fg">{info.data.stats?.tagCount ?? 0}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wider text-fg-dim">Links</dt>
<dd className="mt-2 font-serif text-3xl text-fg">
{info.data.stats?.linkCount ?? 0}
</dd>
</div>
</dl>
) : null}
</div>

<p className="mt-8 text-sm text-fg-dim">
Note list and editor land in the next PRs. This page confirms the vault handshake works.
<div className="mx-auto max-w-2xl px-6 py-20 text-center">
<p className="mb-8 font-serif text-xl italic text-fg-muted">
A lens onto any Parachute Vault.
</p>
<h1 className="mb-4 font-serif text-5xl tracking-tight">Lens</h1>
<p className="mb-10 text-fg-dim tracking-wide">
Point it at a vault. Sign in. Browse, edit, visualize.
</p>

<Link
to="/add"
className="inline-block rounded-md bg-accent px-6 py-3 text-sm font-medium text-white hover:bg-accent-hover"
>
Connect a vault
</Link>
</div>
);
}
169 changes: 169 additions & 0 deletions src/app/routes/Notes.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={client}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
}

function lastNotesUrl(fetchImpl: ReturnType<typeof installFetch>): 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(<Notes />, { 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(<Notes />, { 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(<Notes />, { 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(<Notes />, { 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(<Notes />, { 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();
});
});
Loading