BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: explorer panel for repo exploration

+1648 -44
+16 -16
docs/tasks/05-explorer.md
··· 4 4 5 5 ## Tasks 6 6 7 - - [ ] Create `src-tauri/src/explorer.rs` for business logic 7 + - [x] Create `src-tauri/src/explorer.rs` for business logic 8 8 - `src-tauri/src/commands/explorer.rs` - Tauri commands for AT data browsing 9 - - [ ] `resolve_input(input: String)` - detect if input is at:// URI, handle, DID, or PDS URL; resolve accordingly 10 - - [ ] `describe_server(pds_url: String)` - `com.atproto.server.describeServer` 11 - - [ ] `describe_repo(did: String)` - `com.atproto.repo.describeRepo` 12 - - [ ] `list_records(did: String, collection: String, cursor: Option<String>)` - `com.atproto.repo.listRecords` 13 - - [ ] `get_record(did: String, collection: String, rkey: String)` - `com.atproto.repo.getRecord` 14 - - [ ] `export_repo_car(did: String)` - `com.atproto.sync.getRepo`, save to file 15 - - [ ] `query_labels(uri: String)` - `com.atproto.label.queryLabels` 16 - - [ ] Wire deep-link handler: `at://` URI → parse → call `resolve_input` → emit navigation event 17 - - [ ] **Frontend**: explorer URL bar with input parsing, `Cmd+L` to focus 18 - - [ ] **Frontend**: PDS view - server info + hosted account list, skeleton loading 9 + - [x] `resolve_input(input: String)` - detect if input is at:// URI, handle, DID, or PDS URL; resolve accordingly 10 + - [x] `describe_server(pds_url: String)` - `com.atproto.server.describeServer` 11 + - [x] `describe_repo(did: String)` - `com.atproto.repo.describeRepo` 12 + - [x] `list_records(did: String, collection: String, cursor: Option<String>)` - `com.atproto.repo.listRecords` 13 + - [x] `get_record(did: String, collection: String, rkey: String)` - `com.atproto.repo.getRecord` 14 + - [x] `export_repo_car(did: String)` - `com.atproto.sync.getRepo`, save to file 15 + - [x] `query_labels(uri: String)` - `com.atproto.label.queryLabels` 16 + - [x] Wire deep-link handler: `at://` URI → parse → call `resolve_input` → emit navigation event 17 + - [x] **Frontend**: explorer URL bar with input parsing, `Cmd+L` to focus 18 + - [x] **Frontend**: PDS view - server info + hosted account list, skeleton loading 19 19 - [ ] **Frontend**: repo view - collection list with record counts 20 - - [ ] **Frontend**: collection view - paginated record list 21 - - [ ] **Frontend**: record view - syntax-highlighted JSON with collapsible sections, type-specific rendering 22 - - [ ] **Frontend**: breadcrumb navigation bar with `Motion` width animation on segment changes 23 - - [ ] **Frontend**: `Presence` crossfade transitions between explorer view levels 24 - - [ ] **Frontend**: keyboard shortcuts - `Backspace` up a level, `Cmd+[/]` back/forward 20 + - [x] **Frontend**: collection view - paginated record list 21 + - [x] **Frontend**: record view - syntax-highlighted JSON with collapsible sections, type-specific rendering 22 + - [x] **Frontend**: breadcrumb navigation bar with `Motion` width animation on segment changes 23 + - [x] **Frontend**: `Presence` crossfade transitions between explorer view levels 24 + - [x] **Frontend**: keyboard shortcuts - `Backspace` up a level, `Cmd+[/]` back/forward 25 25 - [ ] **Frontend**: Jetstream live-tail view with `Motion` slide-in for new records 26 26 27 27 ### Parking Lot
+37 -2
src-tauri/src/explorer.rs
··· 516 516 #[cfg(test)] 517 517 mod tests { 518 518 use super::{ 519 - canonical_at_uri, detect_input_kind, extract_pds_url_from_did_doc_json, normalize_handle, normalize_pds_url, 520 - repo_car_filename, sanitize_did_for_filename, ExplorerInputKind, 519 + build_resolved_at_uri, canonical_at_uri, detect_input_kind, extract_pds_url_from_did_doc_json, 520 + normalize_handle, normalize_pds_url, repo_car_filename, sanitize_did_for_filename, ExplorerInputKind, 521 + ExplorerTargetKind, 521 522 }; 522 523 use jacquard::types::aturi::AtUri; 523 524 ··· 619 620 ); 620 621 assert!(collection_uri.rkey().is_none()); 621 622 assert_eq!(record_uri.rkey().expect("rkey should exist").as_ref(), "abc123"); 623 + } 624 + 625 + #[test] 626 + fn build_resolved_at_uri_sets_expected_target_levels() { 627 + let repo = AtUri::new("at://did:plc:alice").expect("repo uri should parse"); 628 + let collection = AtUri::new("at://did:plc:alice/app.bsky.feed.post").expect("collection uri should parse"); 629 + let record = AtUri::new("at://did:plc:alice/app.bsky.feed.post/abc123").expect("record uri should parse"); 630 + 631 + assert_eq!( 632 + build_resolved_at_uri("at://did:plc:alice", "did:plc:alice", None, None, &repo).target_kind, 633 + ExplorerTargetKind::Repo 634 + ); 635 + assert_eq!( 636 + build_resolved_at_uri( 637 + "at://did:plc:alice/app.bsky.feed.post", 638 + "did:plc:alice", 639 + None, 640 + None, 641 + &collection 642 + ) 643 + .target_kind, 644 + ExplorerTargetKind::Collection 645 + ); 646 + assert_eq!( 647 + build_resolved_at_uri( 648 + "at://did:plc:alice/app.bsky.feed.post/abc123", 649 + "did:plc:alice", 650 + None, 651 + None, 652 + &record 653 + ) 654 + .target_kind, 655 + ExplorerTargetKind::Record 656 + ); 622 657 } 623 658 }
+44
src/components/explorer/ExplorerBreadcrumb.tsx
··· 1 + import { ArrowIcon, ExplorerLevelIcon } from "$/components/shared/Icon"; 2 + import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; 3 + import { For, Show } from "solid-js"; 4 + import { Motion } from "solid-motionone"; 5 + 6 + type BreadcrumbItem = { label: string; level: ExplorerTargetKind; active: boolean }; 7 + 8 + type ExplorerBreadcrumbProps = { items: BreadcrumbItem[]; onNavigate: (level: ExplorerTargetKind) => void }; 9 + 10 + export function ExplorerBreadcrumb(props: ExplorerBreadcrumbProps) { 11 + return ( 12 + <div class="flex items-center gap-1 px-6 py-3 text-sm border-b border-white/5"> 13 + <For each={props.items}> 14 + {(item, index) => ( 15 + <> 16 + <Show when={index() > 0}> 17 + <ArrowIcon direction="right" class="px-1 text-on-surface-variant shrink-0" /> 18 + </Show> 19 + 20 + <Show 21 + when={item.active} 22 + fallback={ 23 + <button 24 + onClick={() => props.onNavigate(item.level)} 25 + class="flex items-center gap-1.5 px-2 py-1 rounded-lg text-primary hover:bg-white/5 transition-colors"> 26 + <ExplorerLevelIcon level={item.level} class="text-xs" /> 27 + <span class="truncate max-w-37.5">{item.label}</span> 28 + </button> 29 + }> 30 + <Motion.div 31 + initial={{ width: 0, opacity: 0 }} 32 + animate={{ width: "auto", opacity: 1 }} 33 + transition={{ duration: 0.2 }} 34 + class="flex items-center gap-1.5 px-2 py-1 font-medium text-on-surface"> 35 + <ExplorerLevelIcon level={item.level} class="text-xs" /> 36 + <span class="truncate max-w-50">{item.label}</span> 37 + </Motion.div> 38 + </Show> 39 + </> 40 + )} 41 + </For> 42 + </div> 43 + ); 44 + }
+171
src/components/explorer/ExplorerPanel.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { ExplorerPanel } from "./ExplorerPanel"; 4 + 5 + const describeRepoMock = vi.hoisted(() => vi.fn()); 6 + const describeServerMock = vi.hoisted(() => vi.fn()); 7 + const exportRepoCarMock = vi.hoisted(() => vi.fn()); 8 + const getRecordMock = vi.hoisted(() => vi.fn()); 9 + const listRecordsMock = vi.hoisted(() => vi.fn()); 10 + const queryLabelsMock = vi.hoisted(() => vi.fn()); 11 + const resolveInputMock = vi.hoisted(() => vi.fn()); 12 + const listenMock = vi.hoisted(() => vi.fn()); 13 + 14 + vi.mock( 15 + "$/lib/api/explorer", 16 + () => ({ 17 + describeRepo: describeRepoMock, 18 + describeServer: describeServerMock, 19 + exportRepoCar: exportRepoCarMock, 20 + getRecord: getRecordMock, 21 + listRecords: listRecordsMock, 22 + queryLabels: queryLabelsMock, 23 + resolveInput: resolveInputMock, 24 + }), 25 + ); 26 + 27 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 28 + 29 + function renderPanel() { 30 + return render(() => <ExplorerPanel />); 31 + } 32 + 33 + describe("ExplorerPanel", () => { 34 + beforeEach(() => { 35 + describeRepoMock.mockReset(); 36 + describeServerMock.mockReset(); 37 + exportRepoCarMock.mockReset(); 38 + getRecordMock.mockReset(); 39 + listRecordsMock.mockReset(); 40 + queryLabelsMock.mockReset(); 41 + resolveInputMock.mockReset(); 42 + listenMock.mockReset(); 43 + 44 + exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); 45 + listenMock.mockResolvedValue(() => {}); 46 + queryLabelsMock.mockResolvedValue({ labels: [] }); 47 + }); 48 + 49 + it("accepts raw handle input and renders repo collections from describeRepo", async () => { 50 + resolveInputMock.mockResolvedValue({ 51 + input: "@alice.test", 52 + inputKind: "handle", 53 + targetKind: "repo", 54 + normalizedInput: "did:plc:alice", 55 + uri: "at://did:plc:alice", 56 + did: "did:plc:alice", 57 + handle: "alice.test", 58 + pdsUrl: "https://pds.example.com", 59 + collection: null, 60 + rkey: null, 61 + }); 62 + describeRepoMock.mockResolvedValue({ collections: ["app.bsky.feed.like", "app.bsky.feed.post"] }); 63 + 64 + renderPanel(); 65 + 66 + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); 67 + fireEvent.input(input, { target: { value: "@alice.test" } }); 68 + fireEvent.submit(input.closest("form")!); 69 + 70 + expect(resolveInputMock).toHaveBeenCalledWith("@alice.test"); 71 + expect(await screen.findByRole("button", { name: /app\.bsky\.feed\.like/u })).toBeInTheDocument(); 72 + expect(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })).toBeInTheDocument(); 73 + expect(screen.queryByText("0 records")).not.toBeInTheDocument(); 74 + expect(screen.getAllByText("Count unavailable")).toHaveLength(2); 75 + }); 76 + 77 + it("loads additional collection pages", async () => { 78 + resolveInputMock.mockResolvedValue({ 79 + input: "at://did:plc:alice/app.bsky.feed.post", 80 + inputKind: "atUri", 81 + targetKind: "collection", 82 + normalizedInput: "at://did:plc:alice/app.bsky.feed.post", 83 + uri: "at://did:plc:alice/app.bsky.feed.post", 84 + did: "did:plc:alice", 85 + handle: "alice.test", 86 + pdsUrl: "https://pds.example.com", 87 + collection: "app.bsky.feed.post", 88 + rkey: null, 89 + }); 90 + listRecordsMock.mockResolvedValueOnce({ 91 + cursor: "cursor-2", 92 + records: [{ 93 + uri: "at://did:plc:alice/app.bsky.feed.post/first", 94 + cid: "cid-first", 95 + value: { text: "First page" }, 96 + }], 97 + }).mockResolvedValueOnce({ 98 + cursor: null, 99 + records: [{ 100 + uri: "at://did:plc:alice/app.bsky.feed.post/second", 101 + cid: "cid-second", 102 + value: { text: "Second page" }, 103 + }], 104 + }); 105 + 106 + renderPanel(); 107 + 108 + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); 109 + fireEvent.input(input, { target: { value: "at://did:plc:alice/app.bsky.feed.post" } }); 110 + fireEvent.submit(input.closest("form")!); 111 + 112 + expect(await screen.findByRole("button", { name: /first/u })).toBeInTheDocument(); 113 + 114 + fireEvent.click(screen.getByRole("button", { name: /load more\.\.\./iu })); 115 + 116 + await screen.findByRole("button", { name: /second/u }); 117 + expect(listRecordsMock).toHaveBeenNthCalledWith(2, "did:plc:alice", "app.bsky.feed.post", "cursor-2"); 118 + }); 119 + 120 + it("handles deep-link navigation events for PDS targets", async () => { 121 + let navigationHandler: ((event: { payload: { target: Record<string, unknown> } }) => void) | undefined; 122 + 123 + listenMock.mockImplementation((_event: string, callback: typeof navigationHandler) => { 124 + navigationHandler = callback; 125 + return Promise.resolve(() => {}); 126 + }); 127 + resolveInputMock.mockResolvedValue({ 128 + input: "https://pds.example.com", 129 + inputKind: "pdsUrl", 130 + targetKind: "pds", 131 + normalizedInput: "https://pds.example.com", 132 + uri: null, 133 + did: null, 134 + handle: null, 135 + pdsUrl: "https://pds.example.com", 136 + collection: null, 137 + rkey: null, 138 + }); 139 + describeServerMock.mockResolvedValue({ 140 + pdsUrl: "https://pds.example.com", 141 + server: { inviteCodeRequired: true, version: "0.4.0" }, 142 + repos: [{ did: "did:plc:hosted", head: "head", rev: "rev-1", active: true, status: null }], 143 + cursor: null, 144 + }); 145 + 146 + renderPanel(); 147 + 148 + await waitFor(() => expect(listenMock).toHaveBeenCalledOnce()); 149 + 150 + navigationHandler?.({ 151 + payload: { 152 + target: { 153 + input: "https://pds.example.com", 154 + inputKind: "pdsUrl", 155 + targetKind: "pds", 156 + normalizedInput: "https://pds.example.com", 157 + uri: null, 158 + did: null, 159 + handle: null, 160 + pdsUrl: "https://pds.example.com", 161 + collection: null, 162 + rkey: null, 163 + }, 164 + }, 165 + }); 166 + 167 + expect(resolveInputMock).toHaveBeenCalledWith("https://pds.example.com"); 168 + expect(await screen.findByText("Hosted Repositories")).toBeInTheDocument(); 169 + expect(screen.getByRole("button", { name: /did:plc:hosted/u })).toBeInTheDocument(); 170 + }); 171 + });
+488
src/components/explorer/ExplorerPanel.tsx
··· 1 + import { 2 + describeRepo, 3 + describeServer, 4 + exportRepoCar, 5 + getRecord, 6 + listRecords, 7 + queryLabels, 8 + resolveInput, 9 + } from "$/lib/api/explorer"; 10 + import type { ExplorerNavigation, ExplorerTargetKind } from "$/lib/api/types/explorer"; 11 + import { NAVIGATION_EVENT } from "$/lib/constants/events"; 12 + import { listen } from "@tauri-apps/api/event"; 13 + import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 14 + import { produce } from "solid-js/store"; 15 + import { Motion, Presence } from "solid-motionone"; 16 + import { createExplorerState } from "./explorer-state"; 17 + import { ExplorerBreadcrumb } from "./ExplorerBreadcrumb"; 18 + import { ExplorerUrlBar } from "./ExplorerUrlBar"; 19 + import type { ExplorerViewLevel, ExplorerViewState } from "./types"; 20 + import { CollectionView } from "./views/CollectionView"; 21 + import { PdsView } from "./views/PdsView"; 22 + import { RecordView } from "./views/RecordView"; 23 + import { RepoView } from "./views/RepoView"; 24 + 25 + function resolveTargetLevel(kind: ExplorerTargetKind): ExplorerViewLevel { 26 + return kind as ExplorerViewLevel; 27 + } 28 + 29 + function resolveParentInput(view: ExplorerViewState): string | null { 30 + switch (view.level) { 31 + case "record": { 32 + if (view.resolved?.did && view.resolved?.collection) { 33 + return `at://${view.resolved.did}/${view.resolved.collection}`; 34 + } 35 + break; 36 + } 37 + case "collection": { 38 + if (view.resolved?.did) { 39 + return `at://${view.resolved.did}`; 40 + } 41 + break; 42 + } 43 + case "repo": { 44 + if (view.resolved?.pdsUrl) { 45 + return view.resolved.pdsUrl; 46 + } 47 + break; 48 + } 49 + } 50 + return null; 51 + } 52 + function extractCollections(repoData: Record<string, unknown>): Array<{ nsid: string; count: number | null }> { 53 + const collections: Array<{ nsid: string; count: number | null }> = []; 54 + const collectionsData = repoData.collections; 55 + 56 + if (Array.isArray(collectionsData)) { 57 + for (const collection of collectionsData) { 58 + if (typeof collection === "string") { 59 + collections.push({ nsid: collection, count: null }); 60 + } 61 + } 62 + } 63 + 64 + return collections.toSorted((left, right) => left.nsid.localeCompare(right.nsid)); 65 + } 66 + 67 + export function ExplorerPanel() { 68 + const explorer = createExplorerState(); 69 + const [statusMessage, setStatusMessage] = createSignal<{ kind: "error" | "success"; text: string } | null>(null); 70 + let resolveRequestId = 0; 71 + 72 + const canGoBack = createMemo(() => explorer.canGoBack()); 73 + const canGoForward = createMemo(() => explorer.canGoForward()); 74 + const breadcrumb = createMemo(() => explorer.getBreadcrumb()); 75 + const canExport = createMemo(() => !!explorer.state.current?.resolved?.did); 76 + 77 + function setCurrentView(view: ExplorerViewState) { 78 + explorer.setState("current", view); 79 + } 80 + 81 + function updateCurrentView(updater: (draft: ExplorerViewState) => void) { 82 + explorer.setState(produce((draft) => { 83 + if (!draft.current) return; 84 + updater(draft.current); 85 + 86 + if (draft.historyIndex >= 0) { 87 + const currentHistory = draft.history[draft.historyIndex]; 88 + if (currentHistory && currentHistory !== draft.current) { 89 + updater(currentHistory); 90 + } 91 + } 92 + })); 93 + } 94 + 95 + async function handleResolveInput(input: string) { 96 + if (!input.trim()) return; 97 + const submittedInput = input.trim(); 98 + const requestId = ++resolveRequestId; 99 + 100 + setStatusMessage(null); 101 + explorer.setInputValue(submittedInput); 102 + setCurrentView({ level: "repo", input: submittedInput, resolved: null, loading: true, error: null, data: null }); 103 + 104 + try { 105 + const resolved = await resolveInput(submittedInput); 106 + if (requestId !== resolveRequestId) return; 107 + 108 + const level = resolveTargetLevel(resolved.targetKind); 109 + 110 + const viewState = { level, input: submittedInput, resolved, loading: true, error: null, data: null }; 111 + 112 + setCurrentView(viewState); 113 + explorer.setInputValue(resolved.normalizedInput); 114 + 115 + let finalViewState: ExplorerViewState = viewState; 116 + switch (resolved.targetKind) { 117 + case "pds": { 118 + if (resolved.pdsUrl) { 119 + const serverView = await describeServer(resolved.pdsUrl); 120 + finalViewState = { 121 + ...viewState, 122 + loading: false, 123 + pdsData: { repos: serverView.repos, server: serverView.server, cursor: serverView.cursor }, 124 + }; 125 + } 126 + break; 127 + } 128 + case "repo": { 129 + if (resolved.did) { 130 + const repoData = await describeRepo(resolved.did); 131 + const collections = extractCollections(repoData); 132 + finalViewState = { 133 + ...viewState, 134 + loading: false, 135 + repoData: { 136 + collections, 137 + did: resolved.did, 138 + handle: resolved.handle || resolved.did, 139 + pdsUrl: resolved.pdsUrl, 140 + }, 141 + }; 142 + } 143 + break; 144 + } 145 + case "collection": { 146 + if (resolved.did && resolved.collection) { 147 + const listData = await listRecords(resolved.did, resolved.collection); 148 + finalViewState = { 149 + ...viewState, 150 + loading: false, 151 + collectionData: { 152 + records: (listData.records as Array<Record<string, unknown>>) || [], 153 + cursor: (listData.cursor as string) || null, 154 + did: resolved.did, 155 + collection: resolved.collection, 156 + loadingMore: false, 157 + }, 158 + }; 159 + } 160 + break; 161 + } 162 + case "record": { 163 + if (resolved.did && resolved.collection && resolved.rkey) { 164 + const [recordData, labels] = await Promise.all([ 165 + getRecord(resolved.did, resolved.collection, resolved.rkey), 166 + resolved.uri ? queryLabels(resolved.uri).catch(() => ({ labels: [] })) : Promise.resolve({ labels: [] }), 167 + ]); 168 + finalViewState = { 169 + ...viewState, 170 + loading: false, 171 + recordData: { 172 + record: (recordData.value as Record<string, unknown>) || {}, 173 + cid: (recordData.cid as string) || null, 174 + uri: resolved.uri || "", 175 + labels: (labels.labels as Array<Record<string, unknown>>) || [], 176 + }, 177 + }; 178 + } 179 + break; 180 + } 181 + } 182 + 183 + if (requestId !== resolveRequestId) return; 184 + explorer.pushView(finalViewState); 185 + } catch (error) { 186 + if (requestId !== resolveRequestId) return; 187 + setCurrentView({ 188 + level: "repo", 189 + input: submittedInput, 190 + resolved: null, 191 + loading: false, 192 + error: String(error), 193 + data: null, 194 + }); 195 + } 196 + } 197 + 198 + function handleBack() { 199 + if (explorer.goBack()) { 200 + const current = explorer.state.current; 201 + if (current) { 202 + explorer.setInputValue(current.resolved?.normalizedInput || current.input); 203 + } 204 + } 205 + } 206 + 207 + function handleForward() { 208 + if (explorer.goForward()) { 209 + const current = explorer.state.current; 210 + if (current) { 211 + explorer.setInputValue(current.resolved?.normalizedInput || current.input); 212 + } 213 + } 214 + } 215 + 216 + function handleNavigateUp() { 217 + const current = explorer.state.current; 218 + if (!current?.resolved) return; 219 + 220 + const parentInput = resolveParentInput(current); 221 + 222 + if (parentInput) { 223 + void handleResolveInput(parentInput); 224 + } 225 + } 226 + 227 + function handleBreadcrumbClick(level: ExplorerTargetKind) { 228 + const current = explorer.state.current; 229 + if (!current?.resolved) return; 230 + 231 + const resolved = current.resolved; 232 + let targetInput: string | null = null; 233 + 234 + switch (level) { 235 + case "pds": { 236 + if (resolved.pdsUrl) targetInput = resolved.pdsUrl; 237 + break; 238 + } 239 + case "repo": { 240 + if (resolved.did) targetInput = `at://${resolved.did}`; 241 + break; 242 + } 243 + case "collection": { 244 + if (resolved.did && resolved.collection) { 245 + targetInput = `at://${resolved.did}/${resolved.collection}`; 246 + } 247 + break; 248 + } 249 + case "record": { 250 + if (resolved.uri) targetInput = resolved.uri; 251 + break; 252 + } 253 + } 254 + 255 + if (targetInput) { 256 + void handleResolveInput(targetInput); 257 + } 258 + } 259 + 260 + async function handleLoadMore() { 261 + const current = explorer.state.current; 262 + const collectionData = current?.collectionData; 263 + if (!collectionData?.cursor || collectionData.loadingMore) return; 264 + 265 + updateCurrentView((draft) => { 266 + if (draft.collectionData) { 267 + draft.collectionData.loadingMore = true; 268 + } 269 + }); 270 + 271 + try { 272 + const nextPage = await listRecords(collectionData.did, collectionData.collection, collectionData.cursor); 273 + const nextRecords = (nextPage.records as Array<Record<string, unknown>>) || []; 274 + const nextCursor = (nextPage.cursor as string) || null; 275 + 276 + updateCurrentView((draft) => { 277 + if (!draft.collectionData) return; 278 + draft.collectionData.records = [...draft.collectionData.records, ...nextRecords]; 279 + draft.collectionData.cursor = nextCursor; 280 + draft.collectionData.loadingMore = false; 281 + }); 282 + } catch (error) { 283 + updateCurrentView((draft) => { 284 + if (draft.collectionData) { 285 + draft.collectionData.loadingMore = false; 286 + } 287 + }); 288 + setStatusMessage({ kind: "error", text: String(error) }); 289 + } 290 + } 291 + 292 + async function handleExport() { 293 + const did = explorer.state.current?.resolved?.did; 294 + if (!did) return; 295 + 296 + try { 297 + const result = await exportRepoCar(did); 298 + setStatusMessage({ kind: "success", text: `Saved CAR export to ${result.path}` }); 299 + } catch (error) { 300 + setStatusMessage({ kind: "error", text: String(error) }); 301 + } 302 + } 303 + 304 + function handleRepoClick(did: string) { 305 + void handleResolveInput(`at://${did}`); 306 + } 307 + 308 + function handleCollectionClick(did: string, collection: string) { 309 + void handleResolveInput(`at://${did}/${collection}`); 310 + } 311 + 312 + function handleRecordClick(did: string, collection: string, rkey: string) { 313 + void handleResolveInput(`at://${did}/${collection}/${rkey}`); 314 + } 315 + 316 + function handleKeyDown(event: KeyboardEvent) { 317 + if ((event.metaKey || event.ctrlKey) && event.key === "l") { 318 + event.preventDefault(); 319 + const input = document.querySelector("[data-explorer-input]") as HTMLInputElement; 320 + input?.focus(); 321 + input?.select(); 322 + return; 323 + } 324 + 325 + if ( 326 + event.key === "Backspace" 327 + && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) 328 + ) { 329 + event.preventDefault(); 330 + handleNavigateUp(); 331 + return; 332 + } 333 + 334 + if ((event.metaKey || event.ctrlKey) && event.key === "[") { 335 + event.preventDefault(); 336 + handleBack(); 337 + return; 338 + } 339 + 340 + if ((event.metaKey || event.ctrlKey) && event.key === "]") { 341 + event.preventDefault(); 342 + handleForward(); 343 + return; 344 + } 345 + } 346 + 347 + onMount(() => { 348 + let unlisten: (() => void) | undefined; 349 + 350 + void listen<ExplorerNavigation>(NAVIGATION_EVENT, (event) => { 351 + const target = event.payload.target; 352 + void handleResolveInput(target.uri ?? target.normalizedInput); 353 + }).then((dispose) => { 354 + unlisten = dispose; 355 + }); 356 + 357 + document.addEventListener("keydown", handleKeyDown); 358 + 359 + onCleanup(() => { 360 + unlisten?.(); 361 + document.removeEventListener("keydown", handleKeyDown); 362 + }); 363 + }); 364 + 365 + const currentView = createMemo(() => explorer.state.current); 366 + 367 + return ( 368 + <div class="flex h-full flex-col overflow-hidden"> 369 + <ExplorerUrlBar 370 + value={explorer.state.inputValue} 371 + canGoBack={canGoBack()} 372 + canGoForward={canGoForward()} 373 + canExport={canExport()} 374 + onInput={explorer.setInputValue} 375 + onSubmit={handleResolveInput} 376 + onBack={handleBack} 377 + onForward={handleForward} 378 + onExport={handleExport} /> 379 + 380 + <Show when={breadcrumb().length > 0}> 381 + <ExplorerBreadcrumb items={breadcrumb()} onNavigate={handleBreadcrumbClick} /> 382 + </Show> 383 + 384 + <Show when={statusMessage()}> 385 + {(message) => ( 386 + <div class="px-6 pt-4"> 387 + <div 388 + class="rounded-2xl px-4 py-3 text-sm shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)]" 389 + classList={{ 390 + "bg-[rgba(138,31,31,0.2)] text-error": message().kind === "error", 391 + "bg-[rgba(28,80,49,0.28)] text-on-surface": message().kind === "success", 392 + }}> 393 + {message().text} 394 + </div> 395 + </div> 396 + )} 397 + </Show> 398 + 399 + <div class="flex-1 overflow-hidden"> 400 + <Presence exitBeforeEnter> 401 + <Show when={currentView()} keyed> 402 + {(view) => ( 403 + <Motion.div 404 + initial={{ opacity: 0, y: 8 }} 405 + animate={{ opacity: 1, y: 0 }} 406 + exit={{ opacity: 0, y: -8 }} 407 + transition={{ duration: 0.2 }} 408 + class="h-full overflow-auto p-6"> 409 + <Switch> 410 + <Match when={view.error}> 411 + <div class="rounded-3xl bg-[rgba(138,31,31,0.2)] p-4 text-sm text-error shadow-[inset_0_0_0_1px_rgba(255,128,128,0.2)]"> 412 + {view.error} 413 + </div> 414 + </Match> 415 + 416 + <Match when={view.loading}> 417 + <ExplorerSkeleton /> 418 + </Match> 419 + 420 + <Match when={view.level === "pds" && view.pdsData}> 421 + <PdsView server={view.pdsData!.server} repos={view.pdsData!.repos} onRepoClick={handleRepoClick} /> 422 + </Match> 423 + 424 + <Match when={view.level === "repo" && view.repoData}> 425 + <RepoView 426 + did={view.repoData!.did} 427 + handle={view.repoData!.handle} 428 + pdsUrl={view.repoData!.pdsUrl} 429 + collections={view.repoData!.collections} 430 + onCollectionClick={(collection: string) => handleCollectionClick(view.repoData!.did, collection)} 431 + onPdsClick={() => view.repoData?.pdsUrl && void handleResolveInput(view.repoData.pdsUrl)} /> 432 + </Match> 433 + 434 + <Match when={view.level === "collection" && view.collectionData}> 435 + <CollectionView 436 + did={view.collectionData!.did} 437 + collection={view.collectionData!.collection} 438 + records={view.collectionData!.records} 439 + cursor={view.collectionData!.cursor} 440 + loadingMore={view.collectionData!.loadingMore} 441 + onLoadMore={handleLoadMore} 442 + onRecordClick={(rkey) => 443 + handleRecordClick(view.collectionData!.did, view.collectionData!.collection, rkey)} /> 444 + </Match> 445 + 446 + <Match when={view.level === "record" && view.recordData}> 447 + <RecordView 448 + record={view.recordData!.record} 449 + cid={view.recordData!.cid} 450 + uri={view.recordData!.uri} 451 + labels={view.recordData!.labels} /> 452 + </Match> 453 + 454 + <Match when={!view.loading && !view.error}> 455 + <EmptyPanel /> 456 + </Match> 457 + </Switch> 458 + </Motion.div> 459 + )} 460 + </Show> 461 + </Presence> 462 + </div> 463 + </div> 464 + ); 465 + } 466 + 467 + function EmptyPanel() { 468 + return ( 469 + <div class="grid min-h-96 place-items-center"> 470 + <div class="text-center"> 471 + <p class="text-lg font-medium text-on-surface">Enter an AT URI to explore</p> 472 + <p class="text-sm text-on-surface-variant mt-2">Try: at://did:plc:xyz/app.bsky.feed.post/123</p> 473 + </div> 474 + </div> 475 + ); 476 + } 477 + 478 + function ExplorerSkeleton() { 479 + return ( 480 + <div class="grid gap-4 animate-pulse"> 481 + <div class="h-8 w-1/3 rounded-lg bg-white/5" /> 482 + <div class="h-4 w-1/4 rounded bg-white/5" /> 483 + <div class="grid gap-2 mt-4"> 484 + <For each={[1, 2, 3, 4, 5]}>{() => <div class="h-16 rounded-xl bg-white/5" />}</For> 485 + </div> 486 + </div> 487 + ); 488 + }
+87
src/components/explorer/ExplorerUrlBar.tsx
··· 1 + import { ArrowIcon, Icon } from "$/components/shared/Icon"; 2 + 3 + type ExplorerUrlBarProps = { 4 + value: string; 5 + canGoBack: boolean; 6 + canGoForward: boolean; 7 + canExport: boolean; 8 + onInput: (value: string) => void; 9 + onSubmit: (value: string) => void; 10 + onBack: () => void; 11 + onForward: () => void; 12 + onExport: () => void; 13 + }; 14 + 15 + function NavButton(props: { direction: "left" | "right"; disabled: boolean; onClick: () => void }) { 16 + return ( 17 + <button 18 + onClick={() => props.onClick()} 19 + disabled={props.disabled} 20 + class="p-2 rounded-lg text-on-surface-variant hover:text-on-surface hover:bg-white/5 transition-all disabled:opacity-30 disabled:cursor-not-allowed" 21 + aria-label={props.direction === "left" ? "Back" : "Forward"} 22 + title={props.direction === "left" ? "Back" : "Forward"}> 23 + <ArrowIcon direction={props.direction} /> 24 + </button> 25 + ); 26 + } 27 + 28 + function UrlInputForm(props: { value: string; onInput: (value: string) => void; onSubmit: (value: string) => void }) { 29 + function handleSubmit(event: Event) { 30 + event.preventDefault(); 31 + props.onSubmit(props.value); 32 + } 33 + 34 + return ( 35 + <form onSubmit={handleSubmit} class="flex-1 relative"> 36 + <div class="flex items-center gap-3 px-4 py-2 rounded-xl bg-black/40 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]"> 37 + <span class="flex items-center text-primary/80"> 38 + <i class="i-ri-compass-discover-line" /> 39 + </span> 40 + <input 41 + data-explorer-input 42 + type="text" 43 + value={props.value} 44 + onInput={(event) => props.onInput(event.currentTarget.value)} 45 + class="flex-1 bg-transparent text-sm font-mono outline-none text-on-surface placeholder:text-on-surface-variant/50" 46 + placeholder="at://did:... or @handle or https://pds..." /> 47 + <button 48 + type="submit" 49 + class="p-1.5 rounded-lg text-on-surface-variant hover:text-on-surface hover:bg-white/5 transition-all"> 50 + <Icon kind="search" /> 51 + </button> 52 + </div> 53 + </form> 54 + ); 55 + } 56 + 57 + export function ExplorerUrlBar(props: ExplorerUrlBarProps) { 58 + return ( 59 + <header class="sticky top-0 z-40 border-b border-white/5 bg-surface-container/80 backdrop-blur-xl"> 60 + <div class="px-6 py-4 flex items-center gap-3"> 61 + <div class="flex gap-1"> 62 + <NavButton direction="left" disabled={!props.canGoBack} onClick={props.onBack} /> 63 + <NavButton direction="right" disabled={!props.canGoForward} onClick={props.onForward} /> 64 + </div> 65 + 66 + <UrlInputForm value={props.value} onInput={props.onInput} onSubmit={props.onSubmit} /> 67 + 68 + <button 69 + onClick={() => props.onSubmit(props.value)} 70 + class="p-2 rounded-lg text-on-surface-variant hover:text-on-surface hover:bg-white/5 transition-all" 71 + aria-label="Reload" 72 + title="Reload"> 73 + <Icon kind="refresh" /> 74 + </button> 75 + 76 + <button 77 + onClick={() => props.onExport()} 78 + disabled={!props.canExport} 79 + class="p-2 rounded-lg text-on-surface-variant hover:text-on-surface hover:bg-white/5 transition-all disabled:cursor-not-allowed disabled:opacity-30" 80 + aria-label="Download CAR" 81 + title="Download CAR"> 82 + <Icon iconClass="i-ri-download-2-line" /> 83 + </button> 84 + </div> 85 + </header> 86 + ); 87 + }
+111
src/components/explorer/explorer-state.ts
··· 1 + import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; 2 + import { createStore, produce } from "solid-js/store"; 3 + import type { ExplorerState, ExplorerViewState } from "./types"; 4 + 5 + export function createExplorerState() { 6 + const [state, setState] = createStore<ExplorerState>({ 7 + inputValue: "", 8 + current: null, 9 + history: [], 10 + historyIndex: -1, 11 + }); 12 + 13 + function setInputValue(value: string) { 14 + setState("inputValue", value); 15 + } 16 + 17 + function pushView(viewState: ExplorerViewState) { 18 + setState(produce((draft) => { 19 + draft.history = draft.history.slice(0, draft.historyIndex + 1); 20 + draft.history.push(viewState); 21 + draft.historyIndex = draft.history.length - 1; 22 + draft.current = viewState; 23 + })); 24 + } 25 + 26 + function goBack(): boolean { 27 + if (state.historyIndex > 0) { 28 + setState(produce((draft) => { 29 + draft.historyIndex -= 1; 30 + draft.current = draft.history[draft.historyIndex]; 31 + })); 32 + return true; 33 + } 34 + return false; 35 + } 36 + 37 + function goForward(): boolean { 38 + if (state.historyIndex < state.history.length - 1) { 39 + setState(produce((draft) => { 40 + draft.historyIndex += 1; 41 + draft.current = draft.history[draft.historyIndex]; 42 + })); 43 + return true; 44 + } 45 + return false; 46 + } 47 + 48 + function goUp(): boolean { 49 + const current = state.current; 50 + if (!current || !current.resolved) return false; 51 + 52 + const resolved = current.resolved; 53 + if (resolved.targetKind === "record" && resolved.collection) { 54 + return true; 55 + } else if (resolved.targetKind === "collection") { 56 + return true; 57 + } else if (resolved.targetKind === "repo") { 58 + return true; 59 + } 60 + return false; 61 + } 62 + 63 + function canGoBack() { 64 + return state.historyIndex > 0; 65 + } 66 + 67 + function canGoForward() { 68 + return state.historyIndex < state.history.length - 1; 69 + } 70 + 71 + function getBreadcrumb(): Array<{ label: string; level: ExplorerTargetKind; active: boolean }> { 72 + const current = state.current; 73 + if (!current || !current.resolved) return []; 74 + 75 + const resolved = current.resolved; 76 + const crumbs: Array<{ label: string; level: ExplorerTargetKind; active: boolean }> = []; 77 + 78 + if (resolved.pdsUrl) { 79 + crumbs.push({ label: "PDS", level: "pds", active: resolved.targetKind === "pds" }); 80 + } 81 + 82 + if (resolved.did) { 83 + const handle = resolved.handle || resolved.did.slice(0, 20) + "..."; 84 + crumbs.push({ label: handle, level: "repo", active: resolved.targetKind === "repo" }); 85 + } 86 + 87 + if (resolved.collection) { 88 + const nsidParts = resolved.collection.split("."); 89 + const shortName = nsidParts.at(-1) || resolved.collection; 90 + crumbs.push({ label: shortName, level: "collection", active: resolved.targetKind === "collection" }); 91 + } 92 + 93 + if (resolved.rkey) { 94 + crumbs.push({ 95 + label: resolved.rkey.slice(0, 12) + "...", 96 + level: "record", 97 + active: resolved.targetKind === "record", 98 + }); 99 + } 100 + 101 + if (crumbs.length > 0) { 102 + crumbs.at(-1)!.active = true; 103 + } 104 + 105 + return crumbs; 106 + } 107 + 108 + return { state, setState, setInputValue, pushView, goBack, goForward, goUp, canGoBack, canGoForward, getBreadcrumb }; 109 + } 110 + 111 + export type ExplorerStore = ReturnType<typeof createExplorerState>;
+77
src/components/explorer/types.ts
··· 1 + import type { ResolvedExplorerInput } from "$/lib/api/types/explorer"; 2 + 3 + export type ExplorerViewLevel = "pds" | "repo" | "collection" | "record"; 4 + 5 + type PDSData = { repos: Array<PDSRepoData>; server: Record<string, unknown>; cursor: string | null }; 6 + 7 + type PDSRepoData = { did: string; head: string; rev: string; active: boolean; status: string | null }; 8 + 9 + type RepoViewCollection = { nsid: string; count: number | null }; 10 + 11 + type RepoViewData = { collections: Array<RepoViewCollection>; handle: string; did: string; pdsUrl: string | null }; 12 + 13 + type CollectionViewData = { 14 + records: Array<Record<string, unknown>>; 15 + cursor: string | null; 16 + did: string; 17 + collection: string; 18 + loadingMore: boolean; 19 + }; 20 + 21 + type RecordViewData = { 22 + record: Record<string, unknown>; 23 + cid: string | null; 24 + uri: string; 25 + labels: Array<Record<string, unknown>>; 26 + }; 27 + 28 + export type ExplorerViewState = { 29 + level: ExplorerViewLevel; 30 + input: string; 31 + resolved: ResolvedExplorerInput | null; 32 + loading: boolean; 33 + error: string | null; 34 + data: unknown; 35 + pdsData?: PDSData; 36 + repoData?: RepoViewData; 37 + collectionData?: CollectionViewData; 38 + recordData?: RecordViewData; 39 + }; 40 + 41 + export type ExplorerState = { 42 + inputValue: string; 43 + current: ExplorerViewState | null; 44 + history: ExplorerViewState[]; 45 + historyIndex: number; 46 + }; 47 + 48 + export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; 49 + 50 + function isJsonValue(value: unknown): value is JsonValue { 51 + return (typeof value === "string" 52 + || typeof value === "number" 53 + || typeof value === "boolean" 54 + || value === null 55 + || (Array.isArray(value) && value.every(isJsonValue)) 56 + || (typeof value === "object" && value !== null && Object.values(value).every(isJsonValue))); 57 + } 58 + 59 + export const JsonValueAs = { 60 + string(value: unknown): string | null { 61 + return isJsonValue(value) && typeof value === "string" ? value : null; 62 + }, 63 + number(value: unknown): number | null { 64 + return isJsonValue(value) && typeof value === "number" ? value : null; 65 + }, 66 + boolean(value: unknown): boolean | null { 67 + return isJsonValue(value) && typeof value === "boolean" ? value : null; 68 + }, 69 + array(value: unknown): JsonValue[] | null { 70 + return isJsonValue(value) && Array.isArray(value) ? value : null; 71 + }, 72 + object(value: unknown): Record<string, JsonValue> | null { 73 + return isJsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value) 74 + ? (value as Record<string, JsonValue>) 75 + : null; 76 + }, 77 + };
+97
src/components/explorer/views/CollectionView.tsx
··· 1 + import { ArrowIcon, Icon } from "$/components/shared/Icon"; 2 + import { For, Show } from "solid-js"; 3 + 4 + interface CollectionViewProps { 5 + did: string; 6 + collection: string; 7 + records: Array<Record<string, unknown>>; 8 + cursor: string | null; 9 + loadingMore: boolean; 10 + onRecordClick: (rkey: string) => void; 11 + onLoadMore: () => void; 12 + } 13 + 14 + function extractRkey(uri: string): string { 15 + const parts = uri.split("/"); 16 + return parts.at(-1) ?? uri; 17 + } 18 + 19 + function formatRecordPreview(record: Record<string, unknown>): string { 20 + if (record.text && typeof record.text === "string") { 21 + return record.text.slice(0, 100) + (record.text.length > 100 ? "..." : ""); 22 + } 23 + const keys = Object.keys(record).filter(k => !k.startsWith("$")); 24 + return keys.slice(0, 3).join(", ") + (keys.length > 3 ? "..." : ""); 25 + } 26 + 27 + export function CollectionView(props: CollectionViewProps) { 28 + const collectionName = () => { 29 + const parts = props.collection.split("."); 30 + return parts.at(-1) ?? props.collection; 31 + }; 32 + 33 + return ( 34 + <div class="grid gap-6"> 35 + <section class="rounded-2xl border border-white/5 p-6"> 36 + <div class="flex items-center gap-3 mb-4"> 37 + <div class="w-12 h-12 rounded-xl flex items-center justify-center bg-primary/15"> 38 + <Icon kind="folder" class="text-primary text-xl" /> 39 + </div> 40 + <div> 41 + <h1 class="text-lg font-medium">{collectionName()}</h1> 42 + <p class="text-xs font-mono text-on-surface-variant">{props.collection}</p> 43 + </div> 44 + </div> 45 + 46 + <div class="p-3 rounded-xl bg-white/5"> 47 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">Total Records</p> 48 + <p class="text-sm">{props.records.length}</p> 49 + </div> 50 + </section> 51 + 52 + <section class="rounded-2xl border border-white/5 overflow-hidden"> 53 + <div class="px-6 py-4 border-b border-white/5 bg-white/5"> 54 + <h2 class="text-lg font-medium">Records</h2> 55 + </div> 56 + 57 + <div class="divide-y divide-white/5"> 58 + <For each={props.records}> 59 + {(record) => { 60 + const uri = (record.uri as string) || ""; 61 + const rkey = extractRkey(uri); 62 + const cid = (record.cid as string) || ""; 63 + 64 + return ( 65 + <button 66 + onClick={() => props.onRecordClick(rkey)} 67 + class="w-full p-4 text-left hover:bg-white/5 transition-colors"> 68 + <div class="flex items-start justify-between gap-4"> 69 + <div class="flex-1 min-w-0"> 70 + <p class="text-sm font-mono text-primary truncate">{rkey}</p> 71 + <p class="text-xs text-on-surface-variant mt-1 truncate">CID: {cid.slice(0, 24)}...</p> 72 + <p class="text-xs text-on-surface-variant mt-2"> 73 + {formatRecordPreview((record.value as Record<string, unknown>) || {})} 74 + </p> 75 + </div> 76 + <ArrowIcon direction="right" class="text-on-surface-variant shrink-0" /> 77 + </div> 78 + </button> 79 + ); 80 + }} 81 + </For> 82 + </div> 83 + 84 + <Show when={props.cursor}> 85 + <div class="px-6 py-4 border-t border-white/5 bg-white/5"> 86 + <button 87 + onClick={() => props.onLoadMore()} 88 + disabled={props.loadingMore} 89 + class="text-sm text-primary hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-60"> 90 + {props.loadingMore ? "Loading more..." : "Load more..."} 91 + </button> 92 + </div> 93 + </Show> 94 + </section> 95 + </div> 96 + ); 97 + }
+67
src/components/explorer/views/PdsView.tsx
··· 1 + import { ArrowIcon } from "$/components/shared/Icon"; 2 + import { For, Show } from "solid-js"; 3 + 4 + interface PdsViewProps { 5 + server: Record<string, unknown>; 6 + repos: Array<{ did: string; head: string; rev: string; active: boolean; status: string | null }>; 7 + onRepoClick: (did: string) => void; 8 + } 9 + 10 + export function PdsView(props: PdsViewProps) { 11 + const serverVersion = () => (props.server?.version as string) || "Unknown"; 12 + const inviteCodeRequired = () => (props.server?.inviteCodeRequired as boolean) || false; 13 + 14 + return ( 15 + <div class="grid gap-6"> 16 + <section class="rounded-2xl border border-white/5 p-6"> 17 + <h2 class="text-lg font-medium mb-4">Server Information</h2> 18 + <div class="grid grid-cols-3 gap-4"> 19 + <div class="p-3 rounded-xl bg-white/5"> 20 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">Version</p> 21 + <p class="text-sm font-mono">{serverVersion()}</p> 22 + </div> 23 + <div class="p-3 rounded-xl bg-white/5"> 24 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">Invite Codes</p> 25 + <p class="text-sm">{inviteCodeRequired() ? "Required" : "Not Required"}</p> 26 + </div> 27 + <div class="p-3 rounded-xl bg-white/5"> 28 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">Total Repos</p> 29 + <p class="text-sm">{props.repos.length}</p> 30 + </div> 31 + </div> 32 + </section> 33 + 34 + <section class="rounded-2xl border border-white/5 overflow-hidden"> 35 + <div class="px-6 py-4 border-b border-white/5 bg-white/5"> 36 + <h2 class="text-lg font-medium">Hosted Repositories</h2> 37 + </div> 38 + 39 + <div class="divide-y divide-white/5"> 40 + <For each={props.repos}> 41 + {(repo) => ( 42 + <button 43 + onClick={() => props.onRepoClick(repo.did)} 44 + class="w-full flex items-center gap-4 p-4 text-left hover:bg-white/5 transition-colors"> 45 + <div class="flex-1 min-w-0"> 46 + <p class="text-sm font-mono truncate">{repo.did}</p> 47 + <p class="text-xs text-on-surface-variant mt-0.5">Rev: {repo.rev.slice(0, 16)}...</p> 48 + </div> 49 + <div class="flex items-center gap-2"> 50 + <Show when={!repo.active}> 51 + <span class="px-2 py-0.5 rounded text-xs bg-red-500/20 text-red-400">Inactive</span> 52 + </Show> 53 + <Show when={repo.status}> 54 + {status => ( 55 + <span class="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">{status()}</span> 56 + )} 57 + </Show> 58 + <ArrowIcon direction="right" class="text-on-surface-variant shrink-0" /> 59 + </div> 60 + </button> 61 + )} 62 + </For> 63 + </div> 64 + </section> 65 + </div> 66 + ); 67 + }
+22
src/components/explorer/views/RecordView.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { RecordView } from "./RecordView"; 4 + 5 + describe("RecordView", () => { 6 + it("renders falsey JSON values and moderation labels", () => { 7 + render(() => ( 8 + <RecordView 9 + record={{ $type: "app.test.record", empty: "", flagged: false, nested: { count: 0 } }} 10 + cid={null} 11 + uri="at://did:plc:alice/app.test.record/123" 12 + labels={[{ src: "did:plc:labeler", uri: "at://did:plc:alice/app.test.record/123", val: "!warn" }]} /> 13 + )); 14 + 15 + expect(screen.getByText("\"empty\"")).toBeInTheDocument(); 16 + expect(screen.getByText("\"\"")).toBeInTheDocument(); 17 + expect(screen.getByText("false")).toBeInTheDocument(); 18 + expect(screen.getByText("0")).toBeInTheDocument(); 19 + expect(screen.getByText("Moderation Labels")).toBeInTheDocument(); 20 + expect(screen.getByText("!warn")).toBeInTheDocument(); 21 + }); 22 + });
+228
src/components/explorer/views/RecordView.tsx
··· 1 + import { type JsonValue, JsonValueAs } from "$/components/explorer/types"; 2 + import { ArrowIcon, Icon } from "$/components/shared/Icon"; 3 + import { getStringProperty, isRecordLike, isString } from "$/lib/type-guards"; 4 + import { createMemo, createSignal, For, type ParentProps, Show } from "solid-js"; 5 + import { Motion } from "solid-motionone"; 6 + 7 + type RecordViewProps = { 8 + record: Record<string, unknown>; 9 + cid: string | null; 10 + uri: string; 11 + labels: Array<Record<string, unknown>>; 12 + }; 13 + 14 + function SyntaxHighlightedJson(props: { data: JsonValue; level?: number }) { 15 + const indent = () => " ".repeat(props.level || 0); 16 + const stringValue = () => JsonValueAs.string(props.data); 17 + const numberValue = () => JsonValueAs.number(props.data); 18 + const booleanValue = () => JsonValueAs.boolean(props.data); 19 + const arrayValue = () => JsonValueAs.array(props.data); 20 + const objectValue = () => JsonValueAs.object(props.data); 21 + 22 + return ( 23 + <span> 24 + <Show when={props.data === null}> 25 + <span class="text-error">null</span> 26 + </Show> 27 + <Show when={booleanValue() !== null}> 28 + <span class="text-error">{String(booleanValue())}</span> 29 + </Show> 30 + <Show when={numberValue() !== null}> 31 + <span class="text-blue-400">{String(numberValue())}</span> 32 + </Show> 33 + <Show when={stringValue() !== null}> 34 + <span class="text-green-400">"{stringValue()}"</span> 35 + </Show> 36 + <Show when={arrayValue()}> 37 + <Show when={arrayValue()!.length > 0} fallback={<span>[]</span>}> 38 + <span>[</span> 39 + <div class="pl-4"> 40 + <For each={arrayValue()!}> 41 + {(item, index) => ( 42 + <div> 43 + {indent()} 44 + <SyntaxHighlightedJson data={item} level={(props.level || 0) + 1} /> 45 + <Show when={index() < arrayValue()!.length - 1}>,</Show> 46 + </div> 47 + )} 48 + </For> 49 + </div> 50 + <span>{indent()}]</span> 51 + </Show> 52 + </Show> 53 + <Show when={objectValue()}> 54 + <Show when={Object.keys(objectValue()!).length > 0} fallback={<span>{`{}`}</span>}> 55 + <span>{"{"}</span> 56 + <div class="pl-4"> 57 + <For each={Object.entries(objectValue()!)}> 58 + {([key, value], index) => ( 59 + <div> 60 + {indent()} 61 + <span class="text-primary">"{key}"</span> 62 + <span>:</span> 63 + <SyntaxHighlightedJson data={value} level={(props.level || 0) + 1} /> 64 + <Show when={index() < Object.keys(objectValue()!).length - 1}>,</Show> 65 + </div> 66 + )} 67 + </For> 68 + </div> 69 + <span>{indent()}{"}"}</span> 70 + </Show> 71 + </Show> 72 + </span> 73 + ); 74 + } 75 + 76 + function CollapsibleSection(props: ParentProps & { title: string }) { 77 + const [isOpen, setIsOpen] = createSignal(true); 78 + 79 + return ( 80 + <div class="rounded-xl border border-white/10 overflow-hidden"> 81 + <button 82 + onClick={() => setIsOpen(!isOpen())} 83 + class="w-full flex items-center justify-between px-4 py-3 bg-black/30 hover:bg-black/40 transition-colors"> 84 + <span class="text-sm font-medium">{props.title}</span> 85 + <Motion.span animate={{ rotate: isOpen() ? 0 : -90 }} transition={{ duration: 0.15 }}> 86 + <ArrowIcon direction="down" /> 87 + </Motion.span> 88 + </button> 89 + <Show when={isOpen()}> 90 + <div class="p-4 overflow-x-auto">{props.children}</div> 91 + </Show> 92 + </div> 93 + ); 94 + } 95 + 96 + function SubjectPreview(props: { subject: Record<string, unknown> | string }) { 97 + const subject = createMemo(() => props.subject); 98 + const stringSubject = createMemo(() => { 99 + const s = subject(); 100 + return (isString(s) ? s : null); 101 + }); 102 + 103 + const objectSubject = createMemo(() => { 104 + const s = subject(); 105 + return (isRecordLike(s) ? s : null); 106 + }); 107 + 108 + const uri = createMemo(() => { 109 + const s = objectSubject(); 110 + return s ? getStringProperty(s, "uri") : null; 111 + }); 112 + 113 + const cid = createMemo(() => { 114 + const s = objectSubject(); 115 + return s ? getStringProperty(s, "cid") : null; 116 + }); 117 + 118 + return ( 119 + <div class="grid gap-2"> 120 + <Show when={stringSubject()}>{(value) => <p class="text-sm font-mono text-primary">{value()}</p>}</Show> 121 + <Show when={uri()}>{(value) => <p class="text-sm font-mono text-primary break-all">{value()}</p>}</Show> 122 + <Show when={cid()}>{(value) => <p class="text-xs font-mono text-on-surface-variant">CID: {value()}</p>}</Show> 123 + </div> 124 + ); 125 + } 126 + 127 + function KnownRecordPreview(props: { record: Record<string, unknown> }) { 128 + const kind = () => (props.record.$type as string) || ""; 129 + const content = () => (isString(props.record.text) ? props.record.text : null); 130 + const subject = () => { 131 + const value = props.record.subject; 132 + 133 + if (isString(value) || isRecordLike(value)) { 134 + return value; 135 + } 136 + return null; 137 + }; 138 + 139 + return ( 140 + <> 141 + <Show when={kind() === "app.bsky.feed.post" && content()}> 142 + <CollapsibleSection title="Post Preview"> 143 + <div class="p-4 rounded-xl bg-black/30"> 144 + <p class="text-sm leading-relaxed text-on-secondary-container">{content()}</p> 145 + </div> 146 + </CollapsibleSection> 147 + </Show> 148 + 149 + <Show when={kind() !== "app.bsky.feed.post" && subject()}> 150 + {(value) => ( 151 + <CollapsibleSection title="Subject"> 152 + <div class="p-4 rounded-xl bg-black/30"> 153 + <SubjectPreview subject={value()} /> 154 + </div> 155 + </CollapsibleSection> 156 + )} 157 + </Show> 158 + </> 159 + ); 160 + } 161 + 162 + export function RecordView(props: RecordViewProps) { 163 + const recordType = () => (props.record.$type as string) || "Unknown"; 164 + const createdAt = () => (props.record.createdAt as string) || null; 165 + 166 + return ( 167 + <div class="grid gap-6 max-w-4xl"> 168 + <section class="rounded-2xl border border-white/5 p-6"> 169 + <div class="flex items-center gap-3 mb-4"> 170 + <div class="w-12 h-12 rounded-xl flex items-center justify-center bg-primary/15"> 171 + <Icon kind="file" class="text-primary text-xl" /> 172 + </div> 173 + <div> 174 + <h1 class="text-lg font-medium">{recordType()}</h1> 175 + <p class="text-xs font-mono text-on-surface-variant truncate max-w-md">{props.uri}</p> 176 + </div> 177 + </div> 178 + 179 + <div class="grid grid-cols-2 gap-4"> 180 + <Show when={props.cid}> 181 + {(value) => ( 182 + <div class="p-3 rounded-xl bg-white/5"> 183 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">CID</p> 184 + <p class="text-xs font-mono truncate">{value()}</p> 185 + </div> 186 + )} 187 + </Show> 188 + <Show when={createdAt()}> 189 + {(date) => ( 190 + <div class="p-3 rounded-xl bg-white/5"> 191 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">Created</p> 192 + <p class="text-xs">{new Date(date()).toLocaleString()}</p> 193 + </div> 194 + )} 195 + </Show> 196 + </div> 197 + </section> 198 + 199 + <CollapsibleSection title="Record Data"> 200 + <pre class="text-sm font-mono leading-relaxed"> 201 + <SyntaxHighlightedJson data={props.record as JsonValue} /> 202 + </pre> 203 + </CollapsibleSection> 204 + 205 + <KnownRecordPreview record={props.record} /> 206 + 207 + <Show when={props.labels.length > 0}> 208 + <CollapsibleSection title="Moderation Labels"> 209 + <div class="grid gap-3"> 210 + <For each={props.labels}> 211 + {(label) => ( 212 + <div class="rounded-xl bg-black/30 p-4"> 213 + <div class="flex flex-wrap items-center gap-2"> 214 + <span class="rounded-full bg-primary/15 px-3 py-1 text-xs font-medium text-primary"> 215 + {String(label.val ?? "unknown")} 216 + </span> 217 + <span class="text-xs text-on-surface-variant">Source: {String(label.src ?? "unknown")}</span> 218 + </div> 219 + <p class="mt-2 text-xs text-on-surface-variant break-all">{String(label.uri ?? props.uri)}</p> 220 + </div> 221 + )} 222 + </For> 223 + </div> 224 + </CollapsibleSection> 225 + </Show> 226 + </div> 227 + ); 228 + }
+69
src/components/explorer/views/RepoView.tsx
··· 1 + import { ArrowIcon, Icon } from "$/components/shared/Icon"; 2 + import { For } from "solid-js"; 3 + 4 + type RepoViewProps = { 5 + did: string; 6 + handle: string; 7 + pdsUrl: string | null; 8 + collections: Array<{ nsid: string; count: number | null }>; 9 + onCollectionClick: (collection: string) => void; 10 + onPdsClick: () => void; 11 + }; 12 + 13 + export function RepoView(props: RepoViewProps) { 14 + return ( 15 + <div class="grid gap-6"> 16 + <section class="rounded-2xl border border-white/5 p-6"> 17 + <div class="flex items-center gap-3 mb-4"> 18 + <div class="w-12 h-12 rounded-xl flex items-center justify-center bg-primary/15"> 19 + <Icon kind="user" class="text-primary text-xl" /> 20 + </div> 21 + <div> 22 + <h1 class="text-lg font-medium">{props.handle}</h1> 23 + <p class="text-xs font-mono text-on-surface-variant">{props.did}</p> 24 + </div> 25 + </div> 26 + 27 + <div class="grid grid-cols-2 gap-4"> 28 + <div class="p-3 rounded-xl bg-white/5"> 29 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">DID</p> 30 + <p class="text-xs font-mono truncate">{props.did}</p> 31 + </div> 32 + <button 33 + onClick={() => props.onPdsClick()} 34 + class="p-3 rounded-xl bg-white/5 text-left hover:bg-white/8 transition-colors"> 35 + <p class="text-xs uppercase tracking-wider mb-1 text-on-surface-variant">PDS</p> 36 + <p class="text-xs font-mono truncate text-primary">{props.pdsUrl || "Unknown"}</p> 37 + </button> 38 + </div> 39 + </section> 40 + 41 + <section class="rounded-2xl border border-white/5 overflow-hidden"> 42 + <div class="px-6 py-4 border-b border-white/5 bg-white/5"> 43 + <h2 class="text-lg font-medium">Collections</h2> 44 + </div> 45 + 46 + <div class="divide-y divide-white/5"> 47 + <For each={props.collections}> 48 + {(collection) => ( 49 + <button 50 + onClick={() => props.onCollectionClick(collection.nsid)} 51 + class="w-full flex items-center justify-between p-4 text-left hover:bg-white/5 transition-colors"> 52 + <div class="flex items-center gap-3"> 53 + <Icon kind="folder" class="text-on-surface-variant" /> 54 + <span class="text-sm">{collection.nsid}</span> 55 + </div> 56 + <div class="flex items-center gap-3"> 57 + <span class="text-xs text-on-surface-variant"> 58 + {collection.count === null ? "Count unavailable" : `${collection.count} records`} 59 + </span> 60 + <ArrowIcon direction="right" class="text-on-surface-variant" /> 61 + </div> 62 + </button> 63 + )} 64 + </For> 65 + </div> 66 + </section> 67 + </div> 68 + ); 69 + }
+31 -1
src/components/shared/Icon.tsx
··· 1 + import type { ExplorerTargetKind } from "$/lib/api/types/explorer"; 1 2 import { type JSX, Match, splitProps, Switch } from "solid-js"; 2 3 3 4 export type IconKind = ··· 16 17 | "at" 17 18 | "hashtag" 18 19 | "quote" 19 - | "close"; 20 + | "close" 21 + | "folder" 22 + | "file"; 20 23 21 24 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 22 25 class?: string; ··· 82 85 <Match when={local.kind === "quote"}> 83 86 <i class="i-ri-chat-quote-line" /> 84 87 </Match> 88 + <Match when={local.kind === "folder"}> 89 + <i class="i-ri-folder-line" /> 90 + </Match> 91 + <Match when={local.kind === "file"}> 92 + <i class="i-ri-file-line" /> 93 + </Match> 85 94 </Switch> 86 95 </span> 87 96 ); ··· 107 116 </span> 108 117 ); 109 118 } 119 + 120 + export function ExplorerLevelIcon(props: { level: ExplorerTargetKind; class?: string }) { 121 + return ( 122 + <span class="flex items-center justify-center" classList={{ [props.class ?? ""]: !!props.class }}> 123 + <Switch> 124 + <Match when={props.level === "pds"}> 125 + <i class="i-ri-server-line" /> 126 + </Match> 127 + <Match when={props.level === "repo"}> 128 + <i class="i-ri-user-line" /> 129 + </Match> 130 + <Match when={props.level === "collection"}> 131 + <i class="i-ri-folder-line" /> 132 + </Match> 133 + <Match when={props.level === "record"}> 134 + <i class="i-ri-file-line" /> 135 + </Match> 136 + </Switch> 137 + </span> 138 + ); 139 + }
+30
src/lib/api/explorer.ts
··· 1 + import { invoke } from "@tauri-apps/api/core"; 2 + import type { ExplorerServerView, RepoCarExport, ResolvedExplorerInput } from "./types/explorer"; 3 + 4 + export async function resolveInput(input: string): Promise<ResolvedExplorerInput> { 5 + return invoke("resolve_input", { input }); 6 + } 7 + 8 + export async function describeServer(pdsUrl: string): Promise<ExplorerServerView> { 9 + return invoke("describe_server", { pdsUrl }); 10 + } 11 + 12 + export async function describeRepo(did: string): Promise<Record<string, unknown>> { 13 + return invoke("describe_repo", { did }); 14 + } 15 + 16 + export async function listRecords(did: string, collection: string, cursor?: string): Promise<Record<string, unknown>> { 17 + return invoke("list_records", { did, collection, cursor }); 18 + } 19 + 20 + export async function getRecord(did: string, collection: string, rkey: string): Promise<Record<string, unknown>> { 21 + return invoke("get_record", { did, collection, rkey }); 22 + } 23 + 24 + export async function exportRepoCar(did: string): Promise<RepoCarExport> { 25 + return invoke("export_repo_car", { did }); 26 + } 27 + 28 + export async function queryLabels(uri: string): Promise<Record<string, unknown>> { 29 + return invoke("query_labels", { uri }); 30 + }
+29
src/lib/api/types/explorer.ts
··· 1 + export type ExplorerInputKind = "atUri" | "handle" | "did" | "pdsUrl"; 2 + 3 + export type ExplorerTargetKind = "pds" | "repo" | "collection" | "record"; 4 + 5 + export type ResolvedExplorerInput = { 6 + input: string; 7 + inputKind: ExplorerInputKind; 8 + targetKind: ExplorerTargetKind; 9 + normalizedInput: string; 10 + uri: string | null; 11 + did: string | null; 12 + handle: string | null; 13 + pdsUrl: string | null; 14 + collection: string | null; 15 + rkey: string | null; 16 + }; 17 + 18 + export type ExplorerNavigation = { target: ResolvedExplorerInput }; 19 + 20 + export type ExplorerHostedRepo = { did: string; head: string; rev: string; active: boolean; status: string | null }; 21 + 22 + export type ExplorerServerView = { 23 + pdsUrl: string; 24 + server: Record<string, unknown>; 25 + repos: ExplorerHostedRepo[]; 26 + cursor: string | null; 27 + }; 28 + 29 + export type RepoCarExport = { did: string; path: string; bytesWritten: number };
+2
src/lib/constants/events.ts
··· 3 3 export const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; 4 4 5 5 export const NOTIFICATIONS_UNREAD_COUNT_EVENT = "notifications:unread-count"; 6 + 7 + export const NAVIGATION_EVENT = "navigation:explorer-resolved";
+1 -12
src/lib/feeds.ts
··· 1 + import { asArray, asRecord } from "./type-guards"; 1 2 import type { 2 3 BlockedPost, 3 4 EmbedView, ··· 22 23 23 24 export const THREAD_ROUTE_BASE = "/timeline/thread"; 24 25 25 - export function asRecord(value: unknown): Record<string, unknown> | null { 26 - if (!value || typeof value !== "object" || Array.isArray(value)) { 27 - return null; 28 - } 29 - 30 - return value as Record<string, unknown>; 31 - } 32 - 33 26 export function asPostRecord(value: unknown): PostRecord { 34 27 return (asRecord(value) ?? {}) as PostRecord; 35 - } 36 - 37 - function asArray(value: unknown) { 38 - return Array.isArray(value) ? value : null; 39 28 } 40 29 41 30 function isProfileViewBasic(value: unknown): boolean {
+30
src/lib/type-guards.ts
··· 1 + /** 2 + * @module type-guards 3 + * A collection of common, reusable type guard functions 4 + * for runtime type checking and type narrowing. 5 + */ 6 + 7 + export function isRecordLike(value: unknown): value is Record<string, unknown> { 8 + return typeof value === "object" && value !== null && !Array.isArray(value); 9 + } 10 + 11 + export function isString(value: unknown): value is string { 12 + return typeof value === "string"; 13 + } 14 + 15 + export function getStringProperty(value: Record<string, unknown>, key: string): string | null { 16 + const candidate = value[key]; 17 + return typeof candidate === "string" ? candidate : null; 18 + } 19 + 20 + export function asRecord(value: unknown): Record<string, unknown> | null { 21 + if (!value || typeof value !== "object" || Array.isArray(value)) { 22 + return null; 23 + } 24 + 25 + return value as Record<string, unknown>; 26 + } 27 + 28 + export function asArray(value: unknown) { 29 + return Array.isArray(value) ? value : null; 30 + }
+11 -13
src/router.tsx
··· 8 8 useParams, 9 9 } from "@solidjs/router"; 10 10 import { type Component, createEffect, type JSX, type ParentProps, Show } from "solid-js"; 11 + import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 11 12 import { buildThreadRoute, decodeThreadRouteUri, TIMELINE_ROUTE } from "./lib/feeds"; 12 13 import type { ActiveSession } from "./lib/types"; 13 14 ··· 19 20 renderComposer: (session: ActiveSession) => JSX.Element; 20 21 renderNotifications: (session: ActiveSession) => JSX.Element; 21 22 renderShell: Component<ParentProps>; 22 - renderTimeline: ( 23 - session: ActiveSession, 24 - context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null }, 25 - ) => JSX.Element; 23 + renderTimeline: Component< 24 + { session: ActiveSession; context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null } } 25 + >; 26 26 session: ActiveSession | null; 27 27 }; 28 28 ··· 109 109 110 110 const ExplorerRoute = () => ( 111 111 <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 112 - {() => ( 113 - <FeaturePlaceholder 114 - eyebrow="AT Explorer" 115 - title="Explorer routing is ready." 116 - description="Deep-linked explorer screens can now mount as protected routes once the record and repository views are implemented." /> 117 - )} 112 + {() => <ExplorerPanel />} 118 113 </ProtectedRouteView> 119 114 ); 120 115 ··· 152 147 return ( 153 148 <ProtectedRouteView bootstrapping={props.bootstrapping} session={props.session}> 154 149 {(session) => 155 - props.renderTimeline(session, { 156 - onThreadRouteChange: (uri) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), 157 - threadUri: props.threadUri, 150 + props.renderTimeline({ 151 + session, 152 + context: { 153 + onThreadRouteChange: (uri) => navigate(uri ? buildThreadRoute(uri) : TIMELINE_ROUTE), 154 + threadUri: props.threadUri, 155 + }, 158 156 })} 159 157 </ProtectedRouteView> 160 158 );