From db8f1cbe87df46b7d4639660cd4cf0f0686f7797 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 29 Mar 2026 19:09:50 -0500 Subject: [PATCH] feat: explorer panel for repo exploration --- docs/tasks/05-explorer.md | 32 +- src-tauri/src/explorer.rs | 39 +- .../explorer/ExplorerBreadcrumb.tsx | 44 ++ .../explorer/ExplorerPanel.test.tsx | 171 ++++++ src/components/explorer/ExplorerPanel.tsx | 488 ++++++++++++++++++ src/components/explorer/ExplorerUrlBar.tsx | 87 ++++ src/components/explorer/explorer-state.ts | 111 ++++ src/components/explorer/types.ts | 77 +++ .../explorer/views/CollectionView.tsx | 97 ++++ src/components/explorer/views/PdsView.tsx | 67 +++ .../explorer/views/RecordView.test.tsx | 22 + src/components/explorer/views/RecordView.tsx | 228 ++++++++ src/components/explorer/views/RepoView.tsx | 69 +++ src/components/shared/Icon.tsx | 32 +- src/lib/api/explorer.ts | 30 ++ src/lib/api/types/explorer.ts | 29 ++ src/lib/constants/events.ts | 2 + src/lib/feeds.ts | 13 +- src/lib/type-guards.ts | 30 ++ src/router.tsx | 24 +- 20 files changed, 1648 insertions(+), 44 deletions(-) create mode 100644 src/components/explorer/ExplorerBreadcrumb.tsx create mode 100644 src/components/explorer/ExplorerPanel.test.tsx create mode 100644 src/components/explorer/ExplorerPanel.tsx create mode 100644 src/components/explorer/ExplorerUrlBar.tsx create mode 100644 src/components/explorer/explorer-state.ts create mode 100644 src/components/explorer/types.ts create mode 100644 src/components/explorer/views/CollectionView.tsx create mode 100644 src/components/explorer/views/PdsView.tsx create mode 100644 src/components/explorer/views/RecordView.test.tsx create mode 100644 src/components/explorer/views/RecordView.tsx create mode 100644 src/components/explorer/views/RepoView.tsx create mode 100644 src/lib/api/explorer.ts create mode 100644 src/lib/api/types/explorer.ts create mode 100644 src/lib/type-guards.ts diff --git a/docs/tasks/05-explorer.md b/docs/tasks/05-explorer.md index e486ce4..7a356a6 100644 --- a/docs/tasks/05-explorer.md +++ b/docs/tasks/05-explorer.md @@ -4,24 +4,24 @@ Spec: [explorer.md](../specs/explorer.md) ## Tasks -- [ ] Create `src-tauri/src/explorer.rs` for business logic +- [x] Create `src-tauri/src/explorer.rs` for business logic - `src-tauri/src/commands/explorer.rs` - Tauri commands for AT data browsing -- [ ] `resolve_input(input: String)` - detect if input is at:// URI, handle, DID, or PDS URL; resolve accordingly -- [ ] `describe_server(pds_url: String)` - `com.atproto.server.describeServer` -- [ ] `describe_repo(did: String)` - `com.atproto.repo.describeRepo` -- [ ] `list_records(did: String, collection: String, cursor: Option)` - `com.atproto.repo.listRecords` -- [ ] `get_record(did: String, collection: String, rkey: String)` - `com.atproto.repo.getRecord` -- [ ] `export_repo_car(did: String)` - `com.atproto.sync.getRepo`, save to file -- [ ] `query_labels(uri: String)` - `com.atproto.label.queryLabels` -- [ ] Wire deep-link handler: `at://` URI → parse → call `resolve_input` → emit navigation event -- [ ] **Frontend**: explorer URL bar with input parsing, `Cmd+L` to focus -- [ ] **Frontend**: PDS view - server info + hosted account list, skeleton loading +- [x] `resolve_input(input: String)` - detect if input is at:// URI, handle, DID, or PDS URL; resolve accordingly +- [x] `describe_server(pds_url: String)` - `com.atproto.server.describeServer` +- [x] `describe_repo(did: String)` - `com.atproto.repo.describeRepo` +- [x] `list_records(did: String, collection: String, cursor: Option)` - `com.atproto.repo.listRecords` +- [x] `get_record(did: String, collection: String, rkey: String)` - `com.atproto.repo.getRecord` +- [x] `export_repo_car(did: String)` - `com.atproto.sync.getRepo`, save to file +- [x] `query_labels(uri: String)` - `com.atproto.label.queryLabels` +- [x] Wire deep-link handler: `at://` URI → parse → call `resolve_input` → emit navigation event +- [x] **Frontend**: explorer URL bar with input parsing, `Cmd+L` to focus +- [x] **Frontend**: PDS view - server info + hosted account list, skeleton loading - [ ] **Frontend**: repo view - collection list with record counts -- [ ] **Frontend**: collection view - paginated record list -- [ ] **Frontend**: record view - syntax-highlighted JSON with collapsible sections, type-specific rendering -- [ ] **Frontend**: breadcrumb navigation bar with `Motion` width animation on segment changes -- [ ] **Frontend**: `Presence` crossfade transitions between explorer view levels -- [ ] **Frontend**: keyboard shortcuts - `Backspace` up a level, `Cmd+[/]` back/forward +- [x] **Frontend**: collection view - paginated record list +- [x] **Frontend**: record view - syntax-highlighted JSON with collapsible sections, type-specific rendering +- [x] **Frontend**: breadcrumb navigation bar with `Motion` width animation on segment changes +- [x] **Frontend**: `Presence` crossfade transitions between explorer view levels +- [x] **Frontend**: keyboard shortcuts - `Backspace` up a level, `Cmd+[/]` back/forward - [ ] **Frontend**: Jetstream live-tail view with `Motion` slide-in for new records ### Parking Lot diff --git a/src-tauri/src/explorer.rs b/src-tauri/src/explorer.rs index 279f62d..ef1d40f 100644 --- a/src-tauri/src/explorer.rs +++ b/src-tauri/src/explorer.rs @@ -516,8 +516,9 @@ fn sanitize_did_for_filename(did: &str) -> String { #[cfg(test)] mod tests { use super::{ - canonical_at_uri, detect_input_kind, extract_pds_url_from_did_doc_json, normalize_handle, normalize_pds_url, - repo_car_filename, sanitize_did_for_filename, ExplorerInputKind, + build_resolved_at_uri, canonical_at_uri, detect_input_kind, extract_pds_url_from_did_doc_json, + normalize_handle, normalize_pds_url, repo_car_filename, sanitize_did_for_filename, ExplorerInputKind, + ExplorerTargetKind, }; use jacquard::types::aturi::AtUri; @@ -620,4 +621,38 @@ mod tests { assert!(collection_uri.rkey().is_none()); assert_eq!(record_uri.rkey().expect("rkey should exist").as_ref(), "abc123"); } + + #[test] + fn build_resolved_at_uri_sets_expected_target_levels() { + let repo = AtUri::new("at://did:plc:alice").expect("repo uri should parse"); + let collection = AtUri::new("at://did:plc:alice/app.bsky.feed.post").expect("collection uri should parse"); + let record = AtUri::new("at://did:plc:alice/app.bsky.feed.post/abc123").expect("record uri should parse"); + + assert_eq!( + build_resolved_at_uri("at://did:plc:alice", "did:plc:alice", None, None, &repo).target_kind, + ExplorerTargetKind::Repo + ); + assert_eq!( + build_resolved_at_uri( + "at://did:plc:alice/app.bsky.feed.post", + "did:plc:alice", + None, + None, + &collection + ) + .target_kind, + ExplorerTargetKind::Collection + ); + assert_eq!( + build_resolved_at_uri( + "at://did:plc:alice/app.bsky.feed.post/abc123", + "did:plc:alice", + None, + None, + &record + ) + .target_kind, + ExplorerTargetKind::Record + ); + } } diff --git a/src/components/explorer/ExplorerBreadcrumb.tsx b/src/components/explorer/ExplorerBreadcrumb.tsx new file mode 100644 index 0000000..9d8dbdd --- /dev/null +++ b/src/components/explorer/ExplorerBreadcrumb.tsx @@ -0,0 +1,44 @@ +import { ArrowIcon, ExplorerLevelIcon } from "$/components/shared/Icon"; +import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; +import { For, Show } from "solid-js"; +import { Motion } from "solid-motionone"; + +type BreadcrumbItem = { label: string; level: ExplorerTargetKind; active: boolean }; + +type ExplorerBreadcrumbProps = { items: BreadcrumbItem[]; onNavigate: (level: ExplorerTargetKind) => void }; + +export function ExplorerBreadcrumb(props: ExplorerBreadcrumbProps) { + return ( +
+ + {(item, index) => ( + <> + 0}> + + + + props.onNavigate(item.level)} + class="flex items-center gap-1.5 px-2 py-1 rounded-lg text-primary hover:bg-white/5 transition-colors"> + + {item.label} + + }> + + + {item.label} + + + + )} + +
+ ); +} diff --git a/src/components/explorer/ExplorerPanel.test.tsx b/src/components/explorer/ExplorerPanel.test.tsx new file mode 100644 index 0000000..4705b52 --- /dev/null +++ b/src/components/explorer/ExplorerPanel.test.tsx @@ -0,0 +1,171 @@ +import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ExplorerPanel } from "./ExplorerPanel"; + +const describeRepoMock = vi.hoisted(() => vi.fn()); +const describeServerMock = vi.hoisted(() => vi.fn()); +const exportRepoCarMock = vi.hoisted(() => vi.fn()); +const getRecordMock = vi.hoisted(() => vi.fn()); +const listRecordsMock = vi.hoisted(() => vi.fn()); +const queryLabelsMock = vi.hoisted(() => vi.fn()); +const resolveInputMock = vi.hoisted(() => vi.fn()); +const listenMock = vi.hoisted(() => vi.fn()); + +vi.mock( + "$/lib/api/explorer", + () => ({ + describeRepo: describeRepoMock, + describeServer: describeServerMock, + exportRepoCar: exportRepoCarMock, + getRecord: getRecordMock, + listRecords: listRecordsMock, + queryLabels: queryLabelsMock, + resolveInput: resolveInputMock, + }), +); + +vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); + +function renderPanel() { + return render(() => ); +} + +describe("ExplorerPanel", () => { + beforeEach(() => { + describeRepoMock.mockReset(); + describeServerMock.mockReset(); + exportRepoCarMock.mockReset(); + getRecordMock.mockReset(); + listRecordsMock.mockReset(); + queryLabelsMock.mockReset(); + resolveInputMock.mockReset(); + listenMock.mockReset(); + + exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); + listenMock.mockResolvedValue(() => {}); + queryLabelsMock.mockResolvedValue({ labels: [] }); + }); + + it("accepts raw handle input and renders repo collections from describeRepo", async () => { + resolveInputMock.mockResolvedValue({ + input: "@alice.test", + inputKind: "handle", + targetKind: "repo", + normalizedInput: "did:plc:alice", + uri: "at://did:plc:alice", + did: "did:plc:alice", + handle: "alice.test", + pdsUrl: "https://pds.example.com", + collection: null, + rkey: null, + }); + describeRepoMock.mockResolvedValue({ collections: ["app.bsky.feed.like", "app.bsky.feed.post"] }); + + renderPanel(); + + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); + fireEvent.input(input, { target: { value: "@alice.test" } }); + fireEvent.submit(input.closest("form")!); + + expect(resolveInputMock).toHaveBeenCalledWith("@alice.test"); + expect(await screen.findByRole("button", { name: /app\.bsky\.feed\.like/u })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })).toBeInTheDocument(); + expect(screen.queryByText("0 records")).not.toBeInTheDocument(); + expect(screen.getAllByText("Count unavailable")).toHaveLength(2); + }); + + it("loads additional collection pages", async () => { + resolveInputMock.mockResolvedValue({ + input: "at://did:plc:alice/app.bsky.feed.post", + inputKind: "atUri", + targetKind: "collection", + normalizedInput: "at://did:plc:alice/app.bsky.feed.post", + uri: "at://did:plc:alice/app.bsky.feed.post", + did: "did:plc:alice", + handle: "alice.test", + pdsUrl: "https://pds.example.com", + collection: "app.bsky.feed.post", + rkey: null, + }); + listRecordsMock.mockResolvedValueOnce({ + cursor: "cursor-2", + records: [{ + uri: "at://did:plc:alice/app.bsky.feed.post/first", + cid: "cid-first", + value: { text: "First page" }, + }], + }).mockResolvedValueOnce({ + cursor: null, + records: [{ + uri: "at://did:plc:alice/app.bsky.feed.post/second", + cid: "cid-second", + value: { text: "Second page" }, + }], + }); + + renderPanel(); + + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); + fireEvent.input(input, { target: { value: "at://did:plc:alice/app.bsky.feed.post" } }); + fireEvent.submit(input.closest("form")!); + + expect(await screen.findByRole("button", { name: /first/u })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /load more\.\.\./iu })); + + await screen.findByRole("button", { name: /second/u }); + expect(listRecordsMock).toHaveBeenNthCalledWith(2, "did:plc:alice", "app.bsky.feed.post", "cursor-2"); + }); + + it("handles deep-link navigation events for PDS targets", async () => { + let navigationHandler: ((event: { payload: { target: Record } }) => void) | undefined; + + listenMock.mockImplementation((_event: string, callback: typeof navigationHandler) => { + navigationHandler = callback; + return Promise.resolve(() => {}); + }); + resolveInputMock.mockResolvedValue({ + input: "https://pds.example.com", + inputKind: "pdsUrl", + targetKind: "pds", + normalizedInput: "https://pds.example.com", + uri: null, + did: null, + handle: null, + pdsUrl: "https://pds.example.com", + collection: null, + rkey: null, + }); + describeServerMock.mockResolvedValue({ + pdsUrl: "https://pds.example.com", + server: { inviteCodeRequired: true, version: "0.4.0" }, + repos: [{ did: "did:plc:hosted", head: "head", rev: "rev-1", active: true, status: null }], + cursor: null, + }); + + renderPanel(); + + await waitFor(() => expect(listenMock).toHaveBeenCalledOnce()); + + navigationHandler?.({ + payload: { + target: { + input: "https://pds.example.com", + inputKind: "pdsUrl", + targetKind: "pds", + normalizedInput: "https://pds.example.com", + uri: null, + did: null, + handle: null, + pdsUrl: "https://pds.example.com", + collection: null, + rkey: null, + }, + }, + }); + + expect(resolveInputMock).toHaveBeenCalledWith("https://pds.example.com"); + expect(await screen.findByText("Hosted Repositories")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /did:plc:hosted/u })).toBeInTheDocument(); + }); +}); diff --git a/src/components/explorer/ExplorerPanel.tsx b/src/components/explorer/ExplorerPanel.tsx new file mode 100644 index 0000000..8571f21 --- /dev/null +++ b/src/components/explorer/ExplorerPanel.tsx @@ -0,0 +1,488 @@ +import { + describeRepo, + describeServer, + exportRepoCar, + getRecord, + listRecords, + queryLabels, + resolveInput, +} from "$/lib/api/explorer"; +import type { ExplorerNavigation, ExplorerTargetKind } from "$/lib/api/types/explorer"; +import { NAVIGATION_EVENT } from "$/lib/constants/events"; +import { listen } from "@tauri-apps/api/event"; +import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; +import { produce } from "solid-js/store"; +import { Motion, Presence } from "solid-motionone"; +import { createExplorerState } from "./explorer-state"; +import { ExplorerBreadcrumb } from "./ExplorerBreadcrumb"; +import { ExplorerUrlBar } from "./ExplorerUrlBar"; +import type { ExplorerViewLevel, ExplorerViewState } from "./types"; +import { CollectionView } from "./views/CollectionView"; +import { PdsView } from "./views/PdsView"; +import { RecordView } from "./views/RecordView"; +import { RepoView } from "./views/RepoView"; + +function resolveTargetLevel(kind: ExplorerTargetKind): ExplorerViewLevel { + return kind as ExplorerViewLevel; +} + +function resolveParentInput(view: ExplorerViewState): string | null { + switch (view.level) { + case "record": { + if (view.resolved?.did && view.resolved?.collection) { + return `at://${view.resolved.did}/${view.resolved.collection}`; + } + break; + } + case "collection": { + if (view.resolved?.did) { + return `at://${view.resolved.did}`; + } + break; + } + case "repo": { + if (view.resolved?.pdsUrl) { + return view.resolved.pdsUrl; + } + break; + } + } + return null; +} +function extractCollections(repoData: Record): Array<{ nsid: string; count: number | null }> { + const collections: Array<{ nsid: string; count: number | null }> = []; + const collectionsData = repoData.collections; + + if (Array.isArray(collectionsData)) { + for (const collection of collectionsData) { + if (typeof collection === "string") { + collections.push({ nsid: collection, count: null }); + } + } + } + + return collections.toSorted((left, right) => left.nsid.localeCompare(right.nsid)); +} + +export function ExplorerPanel() { + const explorer = createExplorerState(); + const [statusMessage, setStatusMessage] = createSignal<{ kind: "error" | "success"; text: string } | null>(null); + let resolveRequestId = 0; + + const canGoBack = createMemo(() => explorer.canGoBack()); + const canGoForward = createMemo(() => explorer.canGoForward()); + const breadcrumb = createMemo(() => explorer.getBreadcrumb()); + const canExport = createMemo(() => !!explorer.state.current?.resolved?.did); + + function setCurrentView(view: ExplorerViewState) { + explorer.setState("current", view); + } + + function updateCurrentView(updater: (draft: ExplorerViewState) => void) { + explorer.setState(produce((draft) => { + if (!draft.current) return; + updater(draft.current); + + if (draft.historyIndex >= 0) { + const currentHistory = draft.history[draft.historyIndex]; + if (currentHistory && currentHistory !== draft.current) { + updater(currentHistory); + } + } + })); + } + + async function handleResolveInput(input: string) { + if (!input.trim()) return; + const submittedInput = input.trim(); + const requestId = ++resolveRequestId; + + setStatusMessage(null); + explorer.setInputValue(submittedInput); + setCurrentView({ level: "repo", input: submittedInput, resolved: null, loading: true, error: null, data: null }); + + try { + const resolved = await resolveInput(submittedInput); + if (requestId !== resolveRequestId) return; + + const level = resolveTargetLevel(resolved.targetKind); + + const viewState = { level, input: submittedInput, resolved, loading: true, error: null, data: null }; + + setCurrentView(viewState); + explorer.setInputValue(resolved.normalizedInput); + + let finalViewState: ExplorerViewState = viewState; + switch (resolved.targetKind) { + case "pds": { + if (resolved.pdsUrl) { + const serverView = await describeServer(resolved.pdsUrl); + finalViewState = { + ...viewState, + loading: false, + pdsData: { repos: serverView.repos, server: serverView.server, cursor: serverView.cursor }, + }; + } + break; + } + case "repo": { + if (resolved.did) { + const repoData = await describeRepo(resolved.did); + const collections = extractCollections(repoData); + finalViewState = { + ...viewState, + loading: false, + repoData: { + collections, + did: resolved.did, + handle: resolved.handle || resolved.did, + pdsUrl: resolved.pdsUrl, + }, + }; + } + break; + } + case "collection": { + if (resolved.did && resolved.collection) { + const listData = await listRecords(resolved.did, resolved.collection); + finalViewState = { + ...viewState, + loading: false, + collectionData: { + records: (listData.records as Array>) || [], + cursor: (listData.cursor as string) || null, + did: resolved.did, + collection: resolved.collection, + loadingMore: false, + }, + }; + } + break; + } + case "record": { + if (resolved.did && resolved.collection && resolved.rkey) { + const [recordData, labels] = await Promise.all([ + getRecord(resolved.did, resolved.collection, resolved.rkey), + resolved.uri ? queryLabels(resolved.uri).catch(() => ({ labels: [] })) : Promise.resolve({ labels: [] }), + ]); + finalViewState = { + ...viewState, + loading: false, + recordData: { + record: (recordData.value as Record) || {}, + cid: (recordData.cid as string) || null, + uri: resolved.uri || "", + labels: (labels.labels as Array>) || [], + }, + }; + } + break; + } + } + + if (requestId !== resolveRequestId) return; + explorer.pushView(finalViewState); + } catch (error) { + if (requestId !== resolveRequestId) return; + setCurrentView({ + level: "repo", + input: submittedInput, + resolved: null, + loading: false, + error: String(error), + data: null, + }); + } + } + + function handleBack() { + if (explorer.goBack()) { + const current = explorer.state.current; + if (current) { + explorer.setInputValue(current.resolved?.normalizedInput || current.input); + } + } + } + + function handleForward() { + if (explorer.goForward()) { + const current = explorer.state.current; + if (current) { + explorer.setInputValue(current.resolved?.normalizedInput || current.input); + } + } + } + + function handleNavigateUp() { + const current = explorer.state.current; + if (!current?.resolved) return; + + const parentInput = resolveParentInput(current); + + if (parentInput) { + void handleResolveInput(parentInput); + } + } + + function handleBreadcrumbClick(level: ExplorerTargetKind) { + const current = explorer.state.current; + if (!current?.resolved) return; + + const resolved = current.resolved; + let targetInput: string | null = null; + + switch (level) { + case "pds": { + if (resolved.pdsUrl) targetInput = resolved.pdsUrl; + break; + } + case "repo": { + if (resolved.did) targetInput = `at://${resolved.did}`; + break; + } + case "collection": { + if (resolved.did && resolved.collection) { + targetInput = `at://${resolved.did}/${resolved.collection}`; + } + break; + } + case "record": { + if (resolved.uri) targetInput = resolved.uri; + break; + } + } + + if (targetInput) { + void handleResolveInput(targetInput); + } + } + + async function handleLoadMore() { + const current = explorer.state.current; + const collectionData = current?.collectionData; + if (!collectionData?.cursor || collectionData.loadingMore) return; + + updateCurrentView((draft) => { + if (draft.collectionData) { + draft.collectionData.loadingMore = true; + } + }); + + try { + const nextPage = await listRecords(collectionData.did, collectionData.collection, collectionData.cursor); + const nextRecords = (nextPage.records as Array>) || []; + const nextCursor = (nextPage.cursor as string) || null; + + updateCurrentView((draft) => { + if (!draft.collectionData) return; + draft.collectionData.records = [...draft.collectionData.records, ...nextRecords]; + draft.collectionData.cursor = nextCursor; + draft.collectionData.loadingMore = false; + }); + } catch (error) { + updateCurrentView((draft) => { + if (draft.collectionData) { + draft.collectionData.loadingMore = false; + } + }); + setStatusMessage({ kind: "error", text: String(error) }); + } + } + + async function handleExport() { + const did = explorer.state.current?.resolved?.did; + if (!did) return; + + try { + const result = await exportRepoCar(did); + setStatusMessage({ kind: "success", text: `Saved CAR export to ${result.path}` }); + } catch (error) { + setStatusMessage({ kind: "error", text: String(error) }); + } + } + + function handleRepoClick(did: string) { + void handleResolveInput(`at://${did}`); + } + + function handleCollectionClick(did: string, collection: string) { + void handleResolveInput(`at://${did}/${collection}`); + } + + function handleRecordClick(did: string, collection: string, rkey: string) { + void handleResolveInput(`at://${did}/${collection}/${rkey}`); + } + + function handleKeyDown(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key === "l") { + event.preventDefault(); + const input = document.querySelector("[data-explorer-input]") as HTMLInputElement; + input?.focus(); + input?.select(); + return; + } + + if ( + event.key === "Backspace" + && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) + ) { + event.preventDefault(); + handleNavigateUp(); + return; + } + + if ((event.metaKey || event.ctrlKey) && event.key === "[") { + event.preventDefault(); + handleBack(); + return; + } + + if ((event.metaKey || event.ctrlKey) && event.key === "]") { + event.preventDefault(); + handleForward(); + return; + } + } + + onMount(() => { + let unlisten: (() => void) | undefined; + + void listen(NAVIGATION_EVENT, (event) => { + const target = event.payload.target; + void handleResolveInput(target.uri ?? target.normalizedInput); + }).then((dispose) => { + unlisten = dispose; + }); + + document.addEventListener("keydown", handleKeyDown); + + onCleanup(() => { + unlisten?.(); + document.removeEventListener("keydown", handleKeyDown); + }); + }); + + const currentView = createMemo(() => explorer.state.current); + + return ( +
+ + + 0}> + + + + + {(message) => ( +
+
+ {message().text} +
+
+ )} +
+ +
+ + + {(view) => ( + + + +
+ {view.error} +
+
+ + + + + + + + + + + handleCollectionClick(view.repoData!.did, collection)} + onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} /> + + + + + handleRecordClick(view.collectionData!.did, view.collectionData!.collection, rkey)} /> + + + + + + + + + +
+
+ )} +
+
+
+
+ ); +} + +function EmptyPanel() { + return ( +
+
+

Enter an AT URI to explore

+

Try: at://did:plc:xyz/app.bsky.feed.post/123

+
+
+ ); +} + +function ExplorerSkeleton() { + return ( +
+
+
+
+ {() =>
} +
+
+ ); +} diff --git a/src/components/explorer/ExplorerUrlBar.tsx b/src/components/explorer/ExplorerUrlBar.tsx new file mode 100644 index 0000000..9dff3d7 --- /dev/null +++ b/src/components/explorer/ExplorerUrlBar.tsx @@ -0,0 +1,87 @@ +import { ArrowIcon, Icon } from "$/components/shared/Icon"; + +type ExplorerUrlBarProps = { + value: string; + canGoBack: boolean; + canGoForward: boolean; + canExport: boolean; + onInput: (value: string) => void; + onSubmit: (value: string) => void; + onBack: () => void; + onForward: () => void; + onExport: () => void; +}; + +function NavButton(props: { direction: "left" | "right"; disabled: boolean; onClick: () => void }) { + return ( + + ); +} + +function UrlInputForm(props: { value: string; onInput: (value: string) => void; onSubmit: (value: string) => void }) { + function handleSubmit(event: Event) { + event.preventDefault(); + props.onSubmit(props.value); + } + + return ( +
+
+ + + + props.onInput(event.currentTarget.value)} + class="flex-1 bg-transparent text-sm font-mono outline-none text-on-surface placeholder:text-on-surface-variant/50" + placeholder="at://did:... or @handle or https://pds..." /> + +
+ + ); +} + +export function ExplorerUrlBar(props: ExplorerUrlBarProps) { + return ( +
+
+
+ + +
+ + + + + + +
+
+ ); +} diff --git a/src/components/explorer/explorer-state.ts b/src/components/explorer/explorer-state.ts new file mode 100644 index 0000000..1ee09ac --- /dev/null +++ b/src/components/explorer/explorer-state.ts @@ -0,0 +1,111 @@ +import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; +import { createStore, produce } from "solid-js/store"; +import type { ExplorerState, ExplorerViewState } from "./types"; + +export function createExplorerState() { + const [state, setState] = createStore({ + inputValue: "", + current: null, + history: [], + historyIndex: -1, + }); + + function setInputValue(value: string) { + setState("inputValue", value); + } + + function pushView(viewState: ExplorerViewState) { + setState(produce((draft) => { + draft.history = draft.history.slice(0, draft.historyIndex + 1); + draft.history.push(viewState); + draft.historyIndex = draft.history.length - 1; + draft.current = viewState; + })); + } + + function goBack(): boolean { + if (state.historyIndex > 0) { + setState(produce((draft) => { + draft.historyIndex -= 1; + draft.current = draft.history[draft.historyIndex]; + })); + return true; + } + return false; + } + + function goForward(): boolean { + if (state.historyIndex < state.history.length - 1) { + setState(produce((draft) => { + draft.historyIndex += 1; + draft.current = draft.history[draft.historyIndex]; + })); + return true; + } + return false; + } + + function goUp(): boolean { + const current = state.current; + if (!current || !current.resolved) return false; + + const resolved = current.resolved; + if (resolved.targetKind === "record" && resolved.collection) { + return true; + } else if (resolved.targetKind === "collection") { + return true; + } else if (resolved.targetKind === "repo") { + return true; + } + return false; + } + + function canGoBack() { + return state.historyIndex > 0; + } + + function canGoForward() { + return state.historyIndex < state.history.length - 1; + } + + function getBreadcrumb(): Array<{ label: string; level: ExplorerTargetKind; active: boolean }> { + const current = state.current; + if (!current || !current.resolved) return []; + + const resolved = current.resolved; + const crumbs: Array<{ label: string; level: ExplorerTargetKind; active: boolean }> = []; + + if (resolved.pdsUrl) { + crumbs.push({ label: "PDS", level: "pds", active: resolved.targetKind === "pds" }); + } + + if (resolved.did) { + const handle = resolved.handle || resolved.did.slice(0, 20) + "..."; + crumbs.push({ label: handle, level: "repo", active: resolved.targetKind === "repo" }); + } + + if (resolved.collection) { + const nsidParts = resolved.collection.split("."); + const shortName = nsidParts.at(-1) || resolved.collection; + crumbs.push({ label: shortName, level: "collection", active: resolved.targetKind === "collection" }); + } + + if (resolved.rkey) { + crumbs.push({ + label: resolved.rkey.slice(0, 12) + "...", + level: "record", + active: resolved.targetKind === "record", + }); + } + + if (crumbs.length > 0) { + crumbs.at(-1)!.active = true; + } + + return crumbs; + } + + return { state, setState, setInputValue, pushView, goBack, goForward, goUp, canGoBack, canGoForward, getBreadcrumb }; +} + +export type ExplorerStore = ReturnType; diff --git a/src/components/explorer/types.ts b/src/components/explorer/types.ts new file mode 100644 index 0000000..b7b4bc0 --- /dev/null +++ b/src/components/explorer/types.ts @@ -0,0 +1,77 @@ +import type { ResolvedExplorerInput } from "$/lib/api/types/explorer"; + +export type ExplorerViewLevel = "pds" | "repo" | "collection" | "record"; + +type PDSData = { repos: Array; server: Record; cursor: string | null }; + +type PDSRepoData = { did: string; head: string; rev: string; active: boolean; status: string | null }; + +type RepoViewCollection = { nsid: string; count: number | null }; + +type RepoViewData = { collections: Array; handle: string; did: string; pdsUrl: string | null }; + +type CollectionViewData = { + records: Array>; + cursor: string | null; + did: string; + collection: string; + loadingMore: boolean; +}; + +type RecordViewData = { + record: Record; + cid: string | null; + uri: string; + labels: Array>; +}; + +export type ExplorerViewState = { + level: ExplorerViewLevel; + input: string; + resolved: ResolvedExplorerInput | null; + loading: boolean; + error: string | null; + data: unknown; + pdsData?: PDSData; + repoData?: RepoViewData; + collectionData?: CollectionViewData; + recordData?: RecordViewData; +}; + +export type ExplorerState = { + inputValue: string; + current: ExplorerViewState | null; + history: ExplorerViewState[]; + historyIndex: number; +}; + +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +function isJsonValue(value: unknown): value is JsonValue { + return (typeof value === "string" + || typeof value === "number" + || typeof value === "boolean" + || value === null + || (Array.isArray(value) && value.every(isJsonValue)) + || (typeof value === "object" && value !== null && Object.values(value).every(isJsonValue))); +} + +export const JsonValueAs = { + string(value: unknown): string | null { + return isJsonValue(value) && typeof value === "string" ? value : null; + }, + number(value: unknown): number | null { + return isJsonValue(value) && typeof value === "number" ? value : null; + }, + boolean(value: unknown): boolean | null { + return isJsonValue(value) && typeof value === "boolean" ? value : null; + }, + array(value: unknown): JsonValue[] | null { + return isJsonValue(value) && Array.isArray(value) ? value : null; + }, + object(value: unknown): Record | null { + return isJsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; + }, +}; diff --git a/src/components/explorer/views/CollectionView.tsx b/src/components/explorer/views/CollectionView.tsx new file mode 100644 index 0000000..20251bf --- /dev/null +++ b/src/components/explorer/views/CollectionView.tsx @@ -0,0 +1,97 @@ +import { ArrowIcon, Icon } from "$/components/shared/Icon"; +import { For, Show } from "solid-js"; + +interface CollectionViewProps { + did: string; + collection: string; + records: Array>; + cursor: string | null; + loadingMore: boolean; + onRecordClick: (rkey: string) => void; + onLoadMore: () => void; +} + +function extractRkey(uri: string): string { + const parts = uri.split("/"); + return parts.at(-1) ?? uri; +} + +function formatRecordPreview(record: Record): string { + if (record.text && typeof record.text === "string") { + return record.text.slice(0, 100) + (record.text.length > 100 ? "..." : ""); + } + const keys = Object.keys(record).filter(k => !k.startsWith("$")); + return keys.slice(0, 3).join(", ") + (keys.length > 3 ? "..." : ""); +} + +export function CollectionView(props: CollectionViewProps) { + const collectionName = () => { + const parts = props.collection.split("."); + return parts.at(-1) ?? props.collection; + }; + + return ( +
+
+
+
+ +
+
+

{collectionName()}

+

{props.collection}

+
+
+ +
+

Total Records

+

{props.records.length}

+
+
+ +
+
+

Records

+
+ +
+ + {(record) => { + const uri = (record.uri as string) || ""; + const rkey = extractRkey(uri); + const cid = (record.cid as string) || ""; + + return ( + + ); + }} + +
+ + +
+ +
+
+
+
+ ); +} diff --git a/src/components/explorer/views/PdsView.tsx b/src/components/explorer/views/PdsView.tsx new file mode 100644 index 0000000..b667d9f --- /dev/null +++ b/src/components/explorer/views/PdsView.tsx @@ -0,0 +1,67 @@ +import { ArrowIcon } from "$/components/shared/Icon"; +import { For, Show } from "solid-js"; + +interface PdsViewProps { + server: Record; + repos: Array<{ did: string; head: string; rev: string; active: boolean; status: string | null }>; + onRepoClick: (did: string) => void; +} + +export function PdsView(props: PdsViewProps) { + const serverVersion = () => (props.server?.version as string) || "Unknown"; + const inviteCodeRequired = () => (props.server?.inviteCodeRequired as boolean) || false; + + return ( +
+
+

Server Information

+
+
+

Version

+

{serverVersion()}

+
+
+

Invite Codes

+

{inviteCodeRequired() ? "Required" : "Not Required"}

+
+
+

Total Repos

+

{props.repos.length}

+
+
+
+ +
+
+

Hosted Repositories

+
+ +
+ + {(repo) => ( + + )} + +
+
+
+ ); +} diff --git a/src/components/explorer/views/RecordView.test.tsx b/src/components/explorer/views/RecordView.test.tsx new file mode 100644 index 0000000..4d36fc5 --- /dev/null +++ b/src/components/explorer/views/RecordView.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { RecordView } from "./RecordView"; + +describe("RecordView", () => { + it("renders falsey JSON values and moderation labels", () => { + render(() => ( + + )); + + expect(screen.getByText("\"empty\"")).toBeInTheDocument(); + expect(screen.getByText("\"\"")).toBeInTheDocument(); + expect(screen.getByText("false")).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + expect(screen.getByText("Moderation Labels")).toBeInTheDocument(); + expect(screen.getByText("!warn")).toBeInTheDocument(); + }); +}); diff --git a/src/components/explorer/views/RecordView.tsx b/src/components/explorer/views/RecordView.tsx new file mode 100644 index 0000000..3f291af --- /dev/null +++ b/src/components/explorer/views/RecordView.tsx @@ -0,0 +1,228 @@ +import { type JsonValue, JsonValueAs } from "$/components/explorer/types"; +import { ArrowIcon, Icon } from "$/components/shared/Icon"; +import { getStringProperty, isRecordLike, isString } from "$/lib/type-guards"; +import { createMemo, createSignal, For, type ParentProps, Show } from "solid-js"; +import { Motion } from "solid-motionone"; + +type RecordViewProps = { + record: Record; + cid: string | null; + uri: string; + labels: Array>; +}; + +function SyntaxHighlightedJson(props: { data: JsonValue; level?: number }) { + const indent = () => " ".repeat(props.level || 0); + const stringValue = () => JsonValueAs.string(props.data); + const numberValue = () => JsonValueAs.number(props.data); + const booleanValue = () => JsonValueAs.boolean(props.data); + const arrayValue = () => JsonValueAs.array(props.data); + const objectValue = () => JsonValueAs.object(props.data); + + return ( + + + null + + + {String(booleanValue())} + + + {String(numberValue())} + + + "{stringValue()}" + + + 0} fallback={[]}> + [ +
+ + {(item, index) => ( +
+ {indent()} + + , +
+ )} +
+
+ {indent()}] +
+
+ + 0} fallback={{`{}`}}> + {"{"} +
+ + {([key, value], index) => ( +
+ {indent()} + "{key}" + : + + , +
+ )} +
+
+ {indent()}{"}"} +
+
+
+ ); +} + +function CollapsibleSection(props: ParentProps & { title: string }) { + const [isOpen, setIsOpen] = createSignal(true); + + return ( +
+ + +
{props.children}
+
+
+ ); +} + +function SubjectPreview(props: { subject: Record | string }) { + const subject = createMemo(() => props.subject); + const stringSubject = createMemo(() => { + const s = subject(); + return (isString(s) ? s : null); + }); + + const objectSubject = createMemo(() => { + const s = subject(); + return (isRecordLike(s) ? s : null); + }); + + const uri = createMemo(() => { + const s = objectSubject(); + return s ? getStringProperty(s, "uri") : null; + }); + + const cid = createMemo(() => { + const s = objectSubject(); + return s ? getStringProperty(s, "cid") : null; + }); + + return ( +
+ {(value) =>

{value()}

}
+ {(value) =>

{value()}

}
+ {(value) =>

CID: {value()}

}
+
+ ); +} + +function KnownRecordPreview(props: { record: Record }) { + const kind = () => (props.record.$type as string) || ""; + const content = () => (isString(props.record.text) ? props.record.text : null); + const subject = () => { + const value = props.record.subject; + + if (isString(value) || isRecordLike(value)) { + return value; + } + return null; + }; + + return ( + <> + + +
+

{content()}

+
+
+
+ + + {(value) => ( + +
+ +
+
+ )} +
+ + ); +} + +export function RecordView(props: RecordViewProps) { + const recordType = () => (props.record.$type as string) || "Unknown"; + const createdAt = () => (props.record.createdAt as string) || null; + + return ( +
+
+
+
+ +
+
+

{recordType()}

+

{props.uri}

+
+
+ +
+ + {(value) => ( +
+

CID

+

{value()}

+
+ )} +
+ + {(date) => ( +
+

Created

+

{new Date(date()).toLocaleString()}

+
+ )} +
+
+
+ + +
+          
+        
+
+ + + + 0}> + +
+ + {(label) => ( +
+
+ + {String(label.val ?? "unknown")} + + Source: {String(label.src ?? "unknown")} +
+

{String(label.uri ?? props.uri)}

+
+ )} +
+
+
+
+
+ ); +} diff --git a/src/components/explorer/views/RepoView.tsx b/src/components/explorer/views/RepoView.tsx new file mode 100644 index 0000000..98fa2eb --- /dev/null +++ b/src/components/explorer/views/RepoView.tsx @@ -0,0 +1,69 @@ +import { ArrowIcon, Icon } from "$/components/shared/Icon"; +import { For } from "solid-js"; + +type RepoViewProps = { + did: string; + handle: string; + pdsUrl: string | null; + collections: Array<{ nsid: string; count: number | null }>; + onCollectionClick: (collection: string) => void; + onPdsClick: () => void; +}; + +export function RepoView(props: RepoViewProps) { + return ( +
+
+
+
+ +
+
+

{props.handle}

+

{props.did}

+
+
+ +
+
+

DID

+

{props.did}

+
+ +
+
+ +
+
+

Collections

+
+ +
+ + {(collection) => ( + + )} + +
+
+
+ ); +} diff --git a/src/components/shared/Icon.tsx b/src/components/shared/Icon.tsx index a483a5d..12c93a7 100644 --- a/src/components/shared/Icon.tsx +++ b/src/components/shared/Icon.tsx @@ -1,3 +1,4 @@ +import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; import { type JSX, Match, splitProps, Switch } from "solid-js"; export type IconKind = @@ -16,7 +17,9 @@ export type IconKind = | "at" | "hashtag" | "quote" - | "close"; + | "close" + | "folder" + | "file"; type IconProps = JSX.HTMLAttributes & { class?: string; @@ -82,6 +85,12 @@ export function Icon(props: IconProps) { + + + + + + ); @@ -107,3 +116,24 @@ export function ArrowIcon(props: { class?: string; direction: "up" | "down" | "l ); } + +export function ExplorerLevelIcon(props: { level: ExplorerTargetKind; class?: string }) { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/src/lib/api/explorer.ts b/src/lib/api/explorer.ts new file mode 100644 index 0000000..b30d084 --- /dev/null +++ b/src/lib/api/explorer.ts @@ -0,0 +1,30 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ExplorerServerView, RepoCarExport, ResolvedExplorerInput } from "./types/explorer"; + +export async function resolveInput(input: string): Promise { + return invoke("resolve_input", { input }); +} + +export async function describeServer(pdsUrl: string): Promise { + return invoke("describe_server", { pdsUrl }); +} + +export async function describeRepo(did: string): Promise> { + return invoke("describe_repo", { did }); +} + +export async function listRecords(did: string, collection: string, cursor?: string): Promise> { + return invoke("list_records", { did, collection, cursor }); +} + +export async function getRecord(did: string, collection: string, rkey: string): Promise> { + return invoke("get_record", { did, collection, rkey }); +} + +export async function exportRepoCar(did: string): Promise { + return invoke("export_repo_car", { did }); +} + +export async function queryLabels(uri: string): Promise> { + return invoke("query_labels", { uri }); +} diff --git a/src/lib/api/types/explorer.ts b/src/lib/api/types/explorer.ts new file mode 100644 index 0000000..bd1d2d1 --- /dev/null +++ b/src/lib/api/types/explorer.ts @@ -0,0 +1,29 @@ +export type ExplorerInputKind = "atUri" | "handle" | "did" | "pdsUrl"; + +export type ExplorerTargetKind = "pds" | "repo" | "collection" | "record"; + +export type ResolvedExplorerInput = { + input: string; + inputKind: ExplorerInputKind; + targetKind: ExplorerTargetKind; + normalizedInput: string; + uri: string | null; + did: string | null; + handle: string | null; + pdsUrl: string | null; + collection: string | null; + rkey: string | null; +}; + +export type ExplorerNavigation = { target: ResolvedExplorerInput }; + +export type ExplorerHostedRepo = { did: string; head: string; rev: string; active: boolean; status: string | null }; + +export type ExplorerServerView = { + pdsUrl: string; + server: Record; + repos: ExplorerHostedRepo[]; + cursor: string | null; +}; + +export type RepoCarExport = { did: string; path: string; bytesWritten: number }; diff --git a/src/lib/constants/events.ts b/src/lib/constants/events.ts index 7c0fc4c..cd60b56 100644 --- a/src/lib/constants/events.ts +++ b/src/lib/constants/events.ts @@ -3,3 +3,5 @@ export const POST_CREATED_EVENT = "composer:post-created"; export const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; export const NOTIFICATIONS_UNREAD_COUNT_EVENT = "notifications:unread-count"; + +export const NAVIGATION_EVENT = "navigation:explorer-resolved"; diff --git a/src/lib/feeds.ts b/src/lib/feeds.ts index db7bdb3..e2db911 100644 --- a/src/lib/feeds.ts +++ b/src/lib/feeds.ts @@ -1,3 +1,4 @@ +import { asArray, asRecord } from "./type-guards"; import type { BlockedPost, EmbedView, @@ -22,22 +23,10 @@ export const TIMELINE_ROUTE = "/timeline"; export const THREAD_ROUTE_BASE = "/timeline/thread"; -export function asRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - - return value as Record; -} - export function asPostRecord(value: unknown): PostRecord { return (asRecord(value) ?? {}) as PostRecord; } -function asArray(value: unknown) { - return Array.isArray(value) ? value : null; -} - function isProfileViewBasic(value: unknown): boolean { const record = asRecord(value); return !!record && typeof record.did === "string" && typeof record.handle === "string"; diff --git a/src/lib/type-guards.ts b/src/lib/type-guards.ts new file mode 100644 index 0000000..6d7a339 --- /dev/null +++ b/src/lib/type-guards.ts @@ -0,0 +1,30 @@ +/** + * @module type-guards + * A collection of common, reusable type guard functions + * for runtime type checking and type narrowing. + */ + +export function isRecordLike(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isString(value: unknown): value is string { + return typeof value === "string"; +} + +export function getStringProperty(value: Record, key: string): string | null { + const candidate = value[key]; + return typeof candidate === "string" ? candidate : null; +} + +export function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +export function asArray(value: unknown) { + return Array.isArray(value) ? value : null; +} diff --git a/src/router.tsx b/src/router.tsx index 38476fa..6ea621b 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -8,6 +8,7 @@ import { useParams, } from "@solidjs/router"; import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; +import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; import type { ActiveSession } from "./lib/types"; @@ -19,10 +20,9 @@ type AppRouterProps = { renderComposer: (session: ActiveSession) => JSX.Element; renderNotifications: (session: ActiveSession) => JSX.Element; renderShell: Component; - renderTimeline: ( - session: ActiveSession, - context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null }, - ) => JSX.Element; + renderTimeline: Component< + { session: ActiveSession; context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } } + >; session: ActiveSession | null; }; @@ -109,12 +109,7 @@ export function AppRouter(props: AppRouterProps) { const ExplorerRoute = () => ( - {() => ( - - )} + {() => } ); @@ -152,9 +147,12 @@ function TimelineRouteView( return ( {(session) => - props.renderTimeline(session, { - onThreadRouteChange: (uri) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), - threadUri: props.threadUri, + props.renderTimeline({ + session, + context: { + onThreadRouteChange: (uri) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), + threadUri: props.threadUri, + }, })} ); -- 2.51.2