diff --git a/.changeset/salty-wasps-bow.md b/.changeset/salty-wasps-bow.md new file mode 100644 index 0000000..3319f0a --- /dev/null +++ b/.changeset/salty-wasps-bow.md @@ -0,0 +1,5 @@ +--- +'intrepid-ibex': minor +--- + +expand repo browser capabilities with more information diff --git a/README.md b/README.md index 167dfb8..e17d0a5 100644 --- a/README.md +++ b/README.md @@ -9,17 +9,29 @@ A recreation of Ubuntu 8.10 as an ATProto browser. Inspired by ### Nautilus -The main ATProto browser. It lists repo collections, mirrors them in Places, and -opens records from the selected collection. +The main repo browser. It lists public collections, groups them by namespace, +filters collections, pages through records, searches cached records, and opens +records in gedit or media in Eye of GNOME. -### About +### gedit -A small “About This Computer” window with OS, account, DID, PDS, and project links. +A read-only record viewer. It opens JSON records from Nautilus with copy, word +wrap, syntax highlighting, and tabs for JSON, inferred schema details, and record +info such as AT URI, CID, raw PDS link, and external links. + +### Eye of GNOME + +A media viewer for repo blobs and record-attached media. It is used when Nautilus +finds image or media blob references in a selected record. -### GEdit +### Identity Inspector -A read-only JSON viewer for records. It opens from Nautilus, supports copy and word -wrap, and uses a native GTK-style window. +A public identity window for DID documents, aliases, services, verification +methods, rotation keys, handle checks, and PDS links. + +### About + +A small “About This Computer” window with OS, account, DID, PDS, and project links. ## Theme diff --git a/TODO.md b/TODO.md index 907aad4..391e283 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,7 @@ no authentication or server. - `/labels` -> Label Browser. - `/car` -> Archive Manager. - `/servers/:host` -> Network Servers. + - `/lexicons` and `/lexicons/:nsid` -> Web Browser. - Route loaders should stay browser-only and public/read-only. - DIDs remain canonical in URLs; handles are accepted as lookup/input affordances and resolved client-side. @@ -84,19 +85,42 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - [ ] **Network Servers** - Inspect public PDS host info, version, available domains, and repo list. - Link repos into Nautilus and open server firehose views in System Monitor. +- [ ] **Web Browser** + - GNOME-style browser for reading Lexicon schemas and resolution traces. + - Accept NSIDs, `at://` lexicon records, and HTTPS URLs in an address bar. + - Parse NSIDs into domain authority, authority domain, name segment, and optional + fragment. + - Vendor a small explicit snapshot of common `app.bsky.*` and `com.atproto.*` + lexicons for instant/offline display. + - Label vendored schemas as local snapshots with source and snapshot metadata; do not + present them as canonical truth. + - For live lookup, resolve the NSID authority domain through DNS from the browser, + likely via DNS-over-HTTPS rather than a backend resolver. + - Prefer DNS records that explicitly advertise a Lexicon document URL or lexicon + service endpoint for the authority domain. + - Show a resolution trace with each attempted DNS name, record type, answer, fetch URL, + status, and error details. + - Display Lexicon JSON and a readable schema view with definitions, primary type, + required fields, refs, unions, known values, and descriptions. + - Link gedit Schema tabs and Nautilus collection schema actions into the matching + browser view. + - Keep lookup public/read-only and browser-only; do not add a backend resolver + - Make unsupported NSIDs clear: ATProto does not define universal automated NSID + schema fetching or enumeration. - [ ] **About This Computer** - Show OS/theme info and current public repo identity metadata when available. ## Priority Order - [x] Add canonical `/repos/:did/...` routing. -- [ ] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs. +- [x] Deepen Nautilus and gedit with collection controls plus record JSON/schema/info tabs. - [x] Add Identity Inspector. - [ ] Add System Monitor with Jetstream first. - [ ] Add Network Map. - [ ] Add Archive Manager. - [ ] Add Eye of GNOME blob browsing and previews. - [ ] Add Label Browser. +- [ ] Add Web Browser for Lexicon viewing. - [ ] Add Log Viewer. - [ ] Add Network Servers. @@ -112,6 +136,7 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - first-run setup and handle typeahead states. - Nautilus collection selection, search, pagination, and record opening. - gedit JSON display, copy behavior, and record metadata tabs. + - Web Browser NSID parsing, vendored schema display, and resolution trace states. - window manager open, focus, minimize, maximize, and route-driven gedit opening. - Playwright E2E/smoke tests for critical flows: - `/` boots the desktop. @@ -139,4 +164,3 @@ Every new surface should feel like a GNOME 2 app that happens to speak `at://`. - Multi-repo switching from the panel without implying authentication. - Relay -- Web Browser for lexicon viewing (DNS resolution) diff --git a/src/lib/atproto/repo.svelte.ts b/src/lib/atproto/repo.svelte.ts index baaa9e8..25d9f9f 100644 --- a/src/lib/atproto/repo.svelte.ts +++ b/src/lib/atproto/repo.svelte.ts @@ -1,12 +1,13 @@ import { Client, ok, simpleFetchHandler } from '@atcute/client'; import type { ActorIdentifier, Nsid } from '@atcute/lexicons/syntax'; import type {} from '@atcute/atproto'; -import type { CachedRecord, CachedRecordInput } from '$lib/db'; +import type { CachedRecordInput } from '$lib/db'; import { errorMessage } from '$lib/utils/errors'; import { appLabelForCollection, collectionIconMatch } from './collection-icons'; import { listRecordPages, type RecordPage } from './pagination'; import { isRecordValue } from './types'; import type { AccountIdentity, CollectionSummary, RepoRecordSummary, UnknownRecord } from './types'; +import { truncate } from '$lib/utils/text'; class RepoBrowserState { collections = $state([]); @@ -18,6 +19,7 @@ class RepoBrowserState { canLoadMoreRecords = $state(false); isSearching = $state(false); searchQuery = $state(''); + recordPageSize = $state(25); error = $state(null); loadedDid = $state(null); selectedRecord = $state(null); @@ -69,7 +71,7 @@ class RepoBrowserState { try { this.records = []; - this.recordPages = listRecordPages({ identity, collection: collectionName, limit: 25 }); + this.recordPages = listRecordPages({ identity, collection: collectionName, limit: this.recordPageSize }); await this.loadNextRecordPage(identity, { throwOnError: true }); } catch (unknownError) { const loadedFromCache = await this.loadRecordsFromCache(identity, collectionName); @@ -154,7 +156,9 @@ class RepoBrowserState { repoDid: identity.did, collection: this.selectedCollection ?? undefined }); - this.records = results.map((record) => summarizeCachedRecord(record, identity.handle)); + this.records = results.map((record) => { + return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, identity.handle); + }); } catch (searchError) { this.records = []; this.error = errorMessage(searchError, 'Could not search records.'); @@ -183,7 +187,7 @@ class RepoBrowserState { this.collections = collectionNames.sort().map((name) => collectionSummaryForName(name, null)); this.selectedCollection = collectionName; - this.recordPages = listRecordPages({ identity, collection: collectionName, limit: 25 }); + this.recordPages = listRecordPages({ identity, collection: collectionName, limit: this.recordPageSize }); await this.loadNextRecordPage(identity, { throwOnError: false }); const summary = summarizeRecord(record, identity.handle); @@ -208,13 +212,20 @@ class RepoBrowserState { try { const { getDatabase, listCachedRecords } = await import('$lib/db'); const db = await getDatabase(); - const records = await listCachedRecords(db, { repoDid: identity.did, collection: collectionName, limit: 25 }); + const records = await listCachedRecords(db, { + repoDid: identity.did, + collection: collectionName, + limit: this.recordPageSize + }); if (records.length === 0) return false; this.recordPages = null; this.canLoadMoreRecords = false; - this.records = records.map((record) => summarizeCachedRecord(record, identity.handle)); + this.records = records.map((record) => { + return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, identity.handle); + }); + this.collections = this.collections.map((collection) => collection.name === collectionName ? { ...collection, loadedCount: records.length } : collection ); @@ -225,6 +236,16 @@ class RepoBrowserState { } } + async setRecordPageSize(identity: AccountIdentity, pageSize: number) { + if (this.recordPageSize === pageSize) return; + + this.recordPageSize = pageSize; + + if (this.selectedCollection && !this.searchQuery) { + await this.selectCollection(identity, this.selectedCollection); + } + } + reset() { this.collections = []; this.selectedCollection = null; @@ -236,6 +257,7 @@ class RepoBrowserState { this.canLoadMoreRecords = false; this.isSearching = false; this.searchQuery = ''; + this.recordPageSize = 25; this.error = null; this.loadedDid = null; this.recordPages = null; @@ -270,6 +292,7 @@ function summarizeRecord(record: UnknownRecord, handle: string): RepoRecordSumma const text = stringifyField(value.text) ?? stringifyField(value.name) ?? stringifyField(value.displayName); const type = stringifyField(value.$type) ?? collectionFromUri(record.uri); const createdAt = stringifyField(value.createdAt) ?? stringifyField(value.indexedAt); + // TODO: we should probably only show rkey const title = text ? truncate(text.replaceAll('\n', ' '), 54) : recordKeyFromUri(record.uri); const body = text ?? `Record type: ${type}`; @@ -289,10 +312,6 @@ function summarizeRecord(record: UnknownRecord, handle: string): RepoRecordSumma }; } -function summarizeCachedRecord(record: CachedRecord, handle: string): RepoRecordSummary { - return summarizeRecord({ uri: record.uri, cid: record.cid, value: record.value }, handle); -} - async function cacheLiveRecords( id: AccountIdentity, name: string, @@ -346,10 +365,6 @@ function stringifyField(value: unknown) { return typeof value === 'string' && value.length > 0 ? value : null; } -function truncate(value: string, maxLength: number) { - return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; -} - function collectionFromUri(uri: string) { return uri.split('/')[3] ?? 'unknown.collection'; } diff --git a/src/lib/components/CollectionBrowser.svelte b/src/lib/components/CollectionBrowser.svelte index cc316ed..316ab76 100644 --- a/src/lib/components/CollectionBrowser.svelte +++ b/src/lib/components/CollectionBrowser.svelte @@ -6,11 +6,31 @@ import { accountSetup } from '$lib/atproto/setup.svelte'; import { repoBrowser } from '$lib/atproto/repo.svelte'; import { blobPath, collectionPath, identityPath, recordPath } from '$lib/atproto/routes'; + import { isRecordValue } from '$lib/atproto/types'; import type { CollectionSummary, RepoRecordSummary } from '$lib/atproto/types'; import { SvelteMap } from 'svelte/reactivity'; + type PreviewField = 'summary' | 'text' | 'type' | 'createdAt' | 'uri' | 'cid'; + let searchQuery = $state(''); - const collectionGroups = $derived.by(() => groupCollections(repoBrowser.collections)); + let collectionFilter = $state(''); + let reverseRecords = $state(false); + let previewField = $state('summary'); + + const filteredCollections = $derived.by(() => { + const query = collectionFilter.trim().toLowerCase(); + if (!query) return repoBrowser.collections; + + return repoBrowser.collections.filter((collection) => { + const label = collection.appLabel?.toLowerCase() ?? ''; + return collection.name.toLowerCase().includes(query) || label.includes(query); + }); + }); + const collectionGroups = $derived.by(() => groupCollections(filteredCollections)); + const visibleRecords = $derived.by(() => { + if (!reverseRecords) return repoBrowser.records; + return [...repoBrowser.records].reverse(); + }); type RepoPathname = `/repos/${string}`; const navigateTo = goto as (url: string, options?: Parameters[1]) => ReturnType; @@ -69,6 +89,51 @@ navigate(identityPath(identity.did)); } + function updatePageSize(event: Event) { + const identity = accountSetup.identity; + const select = event.currentTarget; + + if (!(select instanceof HTMLSelectElement) || !identity) return; + + void repoBrowser.setRecordPageSize(identity, Number(select.value)); + } + + function updatePreviewField(event: Event) { + const select = event.currentTarget; + if (!(select instanceof HTMLSelectElement)) return; + + previewField = select.value as PreviewField; + } + + function previewTitle(record: RepoRecordSummary) { + if (previewField === 'summary') return record.title; + if (previewField === 'text') return stringRecordField(record, 'text') ?? record.title; + if (previewField === 'type') return stringRecordField(record, '$type') ?? record.collection; + if (previewField === 'createdAt') return stringRecordField(record, 'createdAt') ?? record.modified; + if (previewField === 'uri') return record.uri; + if (previewField === 'cid') return record.cid || 'No CID'; + + return record.title; + } + + function previewBody(record: RepoRecordSummary) { + if (previewField === 'summary') return record.body; + if (previewField === 'text') return record.body; + if (previewField === 'type') return `Collection: ${record.collection}`; + if (previewField === 'createdAt') return `Indexed as ${record.modified}`; + if (previewField === 'uri') return `${record.collection}/${record.rkey}`; + if (previewField === 'cid') return record.cid ? `Record CID: ${record.cid}` : 'Record returned without a CID.'; + + return record.body; + } + + function stringRecordField(record: RepoRecordSummary, key: string) { + if (!isRecordValue(record.value)) return null; + + const value = record.value[key]; + return typeof value === 'string' && value.length > 0 ? value : null; + } + function navigate(path: string) { void navigateTo(resolve(path as RepoPathname), { keepFocus: true, noScroll: true }); } @@ -108,9 +173,16 @@

{accountSetup.identity?.handle ?? 'at:// repo folders'}

+ +
{#if repoBrowser.isLoadingCollections}

Loading collections…

+ {:else if filteredCollections.length === 0} +

No collections match that filter.

{:else} {#each collectionGroups as group (group.namespace)}
@@ -168,6 +240,35 @@

+
+ + + +