a love letter to tangled (android, iOS, and a search API)

feat: constellation frontend integration

+1476 -391
+59
apps/twisted/src/core/browse-history/index.ts
··· 1 + const REPOS_KEY = "twisted-recent-repos"; 2 + const PROFILES_KEY = "twisted-recent-profiles"; 3 + const MAX_ITEMS = 10; 4 + 5 + export type RecentRepo = { 6 + ownerHandle: string; 7 + name: string; 8 + description?: string; 9 + primaryLanguage?: string; 10 + stars?: number; 11 + visitedAt: number; 12 + }; 13 + 14 + export type RecentProfile = { handle: string; displayName?: string; bio?: string; visitedAt: number }; 15 + 16 + function read<T>(key: string): T[] { 17 + try { 18 + const raw = window.localStorage.getItem(key); 19 + if (!raw) return []; 20 + return JSON.parse(raw) as T[]; 21 + } catch { 22 + return []; 23 + } 24 + } 25 + 26 + function write<T>(key: string, items: T[]): void { 27 + try { 28 + window.localStorage.setItem(key, JSON.stringify(items)); 29 + } catch (error) { 30 + console.warn("Failed to write browse history to localStorage", { error }); 31 + } 32 + } 33 + 34 + export function getRecentRepos(): RecentRepo[] { 35 + return read<RecentRepo>(REPOS_KEY); 36 + } 37 + 38 + export function trackRepoVisit(repo: Omit<RecentRepo, "visitedAt">): void { 39 + const existing = read<RecentRepo>(REPOS_KEY).filter( 40 + (r) => !(r.ownerHandle === repo.ownerHandle && r.name === repo.name), 41 + ); 42 + existing.unshift({ ...repo, visitedAt: Date.now() }); 43 + write(REPOS_KEY, existing.slice(0, MAX_ITEMS)); 44 + } 45 + 46 + export function getRecentProfiles(): RecentProfile[] { 47 + return read<RecentProfile>(PROFILES_KEY); 48 + } 49 + 50 + export function trackProfileVisit(profile: Omit<RecentProfile, "visitedAt">): void { 51 + const existing = read<RecentProfile>(PROFILES_KEY).filter((p) => p.handle !== profile.handle); 52 + existing.unshift({ ...profile, visitedAt: Date.now() }); 53 + write(PROFILES_KEY, existing.slice(0, MAX_ITEMS)); 54 + } 55 + 56 + export function clearBrowseHistory(): void { 57 + write(REPOS_KEY, []); 58 + write(PROFILES_KEY, []); 59 + }
+1 -1
apps/twisted/src/core/config/project.ts
··· 1 - const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? ""; 1 + const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? "http://localhost:8080/"; 2 2 3 3 export const twisterApiBaseUrl = rawTwisterApiBaseUrl.replace(/\/+$/, ""); 4 4 export const hasTwisterApi = twisterApiBaseUrl.length > 0;
+43
apps/twisted/src/core/search-history/index.ts
··· 1 + const STORAGE_KEY = "twisted-search-history"; 2 + const MAX_ITEMS = 10; 3 + 4 + export type SearchHistoryEntry = { query: string; timestamp: number }; 5 + 6 + function readHistory(): SearchHistoryEntry[] { 7 + try { 8 + const raw = window.localStorage.getItem(STORAGE_KEY); 9 + if (!raw) return []; 10 + return JSON.parse(raw) as SearchHistoryEntry[]; 11 + } catch { 12 + return []; 13 + } 14 + } 15 + 16 + function writeHistory(entries: SearchHistoryEntry[]): void { 17 + try { 18 + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries)); 19 + } catch (error) { 20 + console.warn("Failed to write search history to localStorage", { error }); 21 + } 22 + } 23 + 24 + export function getSearchHistory(): SearchHistoryEntry[] { 25 + return readHistory(); 26 + } 27 + 28 + export function addToSearchHistory(query: string): void { 29 + const trimmed = query.trim(); 30 + if (!trimmed) return; 31 + 32 + const entries = readHistory().filter((e) => e.query !== trimmed); 33 + entries.unshift({ query: trimmed, timestamp: Date.now() }); 34 + writeHistory(entries.slice(0, MAX_ITEMS)); 35 + } 36 + 37 + export function removeFromSearchHistory(query: string): void { 38 + writeHistory(readHistory().filter((e) => e.query !== query)); 39 + } 40 + 41 + export function clearSearchHistory(): void { 42 + writeHistory([]); 43 + }
+3 -2
apps/twisted/src/domain/models/activity.ts
··· 1 - type ItemKind = 1 + type ActivityItemKind = 2 2 | "repo_created" 3 3 | "repo_starred" 4 4 | "user_followed" ··· 9 9 10 10 export type ActivityItem = { 11 11 id: string; 12 - kind: ItemKind; 12 + kind: ActivityItemKind; 13 13 actorDid: string; 14 14 actorHandle: string; 15 15 targetUri?: string; 16 16 targetName?: string; 17 + targetOwnerDid?: string; 17 18 createdAt: string; 18 19 };
+299 -5
apps/twisted/src/features/activity/ActivityPage.vue
··· 3 3 <ion-header :translucent="true"> 4 4 <ion-toolbar> 5 5 <ion-title>Activity</ion-title> 6 + <ion-buttons slot="end"> 7 + <ion-button fill="clear" size="small" @click="clearFeed" :disabled="items.length === 0"> 8 + <ion-icon slot="icon-only" :icon="trashOutline" /> 9 + </ion-button> 10 + </ion-buttons> 6 11 </ion-toolbar> 7 12 </ion-header> 8 13 ··· 13 18 </ion-toolbar> 14 19 </ion-header> 15 20 21 + <!-- Connection status --> 22 + <div class="status-bar" :class="statusClass"> 23 + <ion-icon :icon="statusIcon" class="status-icon" /> 24 + <span class="status-text">{{ statusText }}</span> 25 + </div> 26 + 27 + <!-- Filter segment --> 28 + <ion-segment v-model="activeFilter" class="filter-segment"> 29 + <ion-segment-button value="all">All</ion-segment-button> 30 + <ion-segment-button value="repo_starred">Stars</ion-segment-button> 31 + <ion-segment-button value="user_followed">Follows</ion-segment-button> 32 + <ion-segment-button value="issue_opened">Issues</ion-segment-button> 33 + <ion-segment-button value="pr_opened">PRs</ion-segment-button> 34 + </ion-segment> 35 + 36 + <!-- Pull to refresh --> 37 + <ion-refresher slot="fixed" @ionRefresh="handleRefresh($event)"> 38 + <ion-refresher-content pulling-text="Pull to refresh feed" refreshing-spinner="crescent" /> 39 + </ion-refresher> 40 + 41 + <!-- Loading state while waiting for first event --> 42 + <template v-if="status === 'connecting' && filteredItems.length === 0"> 43 + <SkeletonLoader v-for="n in 6" :key="n" variant="list-item" /> 44 + </template> 45 + 46 + <!-- Empty: disconnected with no items --> 16 47 <EmptyState 48 + v-else-if="status === 'disconnected' && filteredItems.length === 0" 17 49 :icon="pulseOutline" 18 - title="Activity is in progress" 19 - message="The public activity feed is still in progress. This tab stays as a placeholder until the indexed feed work is ready." /> 50 + title="Disconnected" 51 + message="Could not connect to the Jetstream feed. Pull to refresh to try again." 52 + action-label="Reconnect" 53 + @action="reconnect" /> 54 + 55 + <!-- Empty: connected but filter has no matches --> 56 + <EmptyState 57 + v-else-if="status === 'connected' && filteredItems.length === 0" 58 + :icon="pulseOutline" 59 + title="Waiting for events…" 60 + message="Connected to the network. Events matching this filter will appear here." /> 61 + 62 + <!-- Feed --> 63 + <div v-else class="feed"> 64 + <ActivityCard 65 + v-for="item in filteredItems" 66 + :key="item.id" 67 + :item="displayItem(item)" 68 + @click="handleItemClick(item)" 69 + @actor-click="handleActorClick(item)" /> 70 + </div> 71 + 20 72 </ion-content> 21 73 </ion-page> 22 74 </template> 23 75 24 76 <script setup lang="ts"> 25 - import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from "@ionic/vue"; 26 - import { pulseOutline } from "ionicons/icons"; 27 - import EmptyState from "@/components/common/EmptyState.vue"; 77 + import { computed, ref, onUnmounted, shallowRef } from "vue"; 78 + import { useRouter } from "vue-router"; 79 + import { 80 + IonPage, 81 + IonHeader, 82 + IonToolbar, 83 + IonTitle, 84 + IonContent, 85 + IonButtons, 86 + IonButton, 87 + IonIcon, 88 + IonSegment, 89 + IonSegmentButton, 90 + IonRefresher, 91 + IonRefresherContent, 92 + onIonViewWillEnter, 93 + onIonViewWillLeave, 94 + } from "@ionic/vue"; 95 + import { pulseOutline, trashOutline, wifiOutline, cloudOfflineOutline, syncOutline } from "ionicons/icons"; 96 + import ActivityCard from "@/components/common/ActivityCard.vue"; 97 + import EmptyState from "@/components/common/EmptyState.vue"; 98 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 99 + import { JetstreamClient } from "@/services/jetstream/client.js"; 100 + import { resolveHandleFromDid } from "@/services/tangled/endpoints.js"; 101 + import type { ActivityItem } from "@/domain/models/activity.js"; 102 + 103 + type ConnectionStatus = "connecting" | "connected" | "disconnected"; 104 + type FilterKind = "all" | ActivityItem["kind"]; 105 + 106 + const MAX_ITEMS = 200; 107 + 108 + const router = useRouter(); 109 + 110 + const items = shallowRef<ActivityItem[]>([]); 111 + const activeFilter = ref<FilterKind>("all"); 112 + const status = ref<ConnectionStatus>("connecting"); 113 + const handleCache = ref<Map<string, string>>(new Map()); 114 + 115 + const filteredItems = computed(() => { 116 + if (activeFilter.value === "all") return items.value; 117 + return items.value.filter((item) => item.kind === activeFilter.value); 118 + }); 119 + 120 + const statusClass = computed(() => ({ 121 + "status-connecting": status.value === "connecting", 122 + "status-connected": status.value === "connected", 123 + "status-disconnected": status.value === "disconnected", 124 + })); 125 + 126 + const statusText = computed((): string => { 127 + switch (status.value) { 128 + case "connected": 129 + return `Live · ${items.value.length} event${items.value.length === 1 ? "" : "s"}`; 130 + case "disconnected": 131 + return "Disconnected · reconnecting…"; 132 + default: 133 + return "Connecting to Jetstream…"; 134 + } 135 + }); 136 + 137 + const statusIcon = computed(() => { 138 + switch (status.value) { 139 + case "connected": 140 + return wifiOutline; 141 + case "disconnected": 142 + return cloudOfflineOutline; 143 + default: 144 + return syncOutline; 145 + } 146 + }); 147 + 148 + /** Returns a copy of the item with resolved handle if available. */ 149 + function displayItem(item: ActivityItem): ActivityItem { 150 + const resolved = handleCache.value.get(item.actorDid); 151 + if (!resolved || resolved === item.actorHandle) return item; 152 + return { ...item, actorHandle: resolved }; 153 + } 154 + 155 + function resolveHandle(did: string): void { 156 + if (handleCache.value.has(did)) return; 157 + // Optimistically mark as in-progress by setting to DID to avoid re-entrancy 158 + handleCache.value.set(did, did); 159 + 160 + resolveHandleFromDid(did) 161 + .then((handle) => { 162 + const next = new Map(handleCache.value); 163 + next.set(did, handle); 164 + handleCache.value = next; 165 + }) 166 + .catch(() => { 167 + // Leave the placeholder handle from the item 168 + handleCache.value.delete(did); 169 + }); 170 + } 171 + 172 + const client = new JetstreamClient({ 173 + onEvent(item) { 174 + const next = [item, ...items.value]; 175 + if (next.length > MAX_ITEMS) next.length = MAX_ITEMS; 176 + items.value = next; 177 + resolveHandle(item.actorDid); 178 + }, 179 + onConnected() { 180 + status.value = "connected"; 181 + }, 182 + onDisconnected() { 183 + if (status.value !== "connecting") { 184 + status.value = "disconnected"; 185 + } 186 + }, 187 + onError() { 188 + status.value = "disconnected"; 189 + }, 190 + }); 191 + 192 + onIonViewWillEnter(() => { 193 + status.value = "connecting"; 194 + client.connect(); 195 + }); 196 + 197 + onIonViewWillLeave(() => { 198 + client.disconnect(); 199 + status.value = "connecting"; // Reset so next enter shows "connecting" 200 + }); 201 + 202 + onUnmounted(() => { 203 + client.disconnect(); 204 + }); 205 + 206 + function clearFeed() { 207 + items.value = []; 208 + } 209 + 210 + function reconnect() { 211 + status.value = "connecting"; 212 + client.resetCursor(); 213 + client.disconnect(); 214 + client.connect(); 215 + } 216 + 217 + async function handleRefresh(event: CustomEvent) { 218 + clearFeed(); 219 + client.resetCursor(); 220 + client.disconnect(); 221 + status.value = "connecting"; 222 + client.connect(); 223 + // Complete the refresher after a short delay 224 + await new Promise<void>((resolve) => setTimeout(resolve, 1000)); 225 + (event.target as HTMLIonRefresherElement).complete(); 226 + } 227 + 228 + function handleActorClick(item: ActivityItem) { 229 + const handle = handleCache.value.get(item.actorDid); 230 + if (handle && handle !== item.actorDid) { 231 + router.push(`/tabs/activity/user/${handle}`); 232 + } else { 233 + // Resolve then navigate 234 + resolveHandleFromDid(item.actorDid) 235 + .then((h) => { 236 + const next = new Map(handleCache.value); 237 + next.set(item.actorDid, h); 238 + handleCache.value = next; 239 + router.push(`/tabs/activity/user/${h}`); 240 + }) 241 + .catch(() => { 242 + // Cannot navigate without a handle 243 + }); 244 + } 245 + } 246 + 247 + function handleItemClick(item: ActivityItem) { 248 + // Navigate to repo if we can determine owner handle and repo name 249 + if (item.targetName && item.targetOwnerDid) { 250 + const ownerHandle = handleCache.value.get(item.targetOwnerDid); 251 + if (ownerHandle && ownerHandle !== item.targetOwnerDid) { 252 + router.push(`/tabs/activity/repo/${ownerHandle}/${item.targetName}`); 253 + } 254 + // If handle not yet resolved, resolve it in background for future clicks 255 + else { 256 + resolveHandle(item.targetOwnerDid); 257 + } 258 + } 259 + } 28 260 </script> 261 + 262 + <style scoped> 263 + /* Status bar */ 264 + .status-bar { 265 + display: flex; 266 + align-items: center; 267 + gap: 8px; 268 + padding: 8px 16px; 269 + font-size: 12px; 270 + font-weight: 500; 271 + transition: background 0.2s; 272 + } 273 + 274 + .status-connecting { 275 + color: var(--t-text-muted); 276 + background: transparent; 277 + } 278 + 279 + .status-connected { 280 + color: #34d399; 281 + } 282 + 283 + .status-disconnected { 284 + color: #fb923c; 285 + } 286 + 287 + .status-icon { 288 + font-size: 14px; 289 + flex-shrink: 0; 290 + } 291 + 292 + /* Filter */ 293 + .filter-segment { 294 + padding: 0 16px 8px; 295 + } 296 + 297 + /* Feed */ 298 + .feed { 299 + padding-bottom: 24px; 300 + } 301 + 302 + /* New items pill */ 303 + .new-items-pill { 304 + position: sticky; 305 + bottom: 80px; 306 + left: 50%; 307 + transform: translateX(-50%); 308 + width: fit-content; 309 + background: var(--t-accent); 310 + color: #0d1117; 311 + font-size: 12px; 312 + font-weight: 700; 313 + padding: 6px 14px; 314 + border-radius: 999px; 315 + cursor: pointer; 316 + z-index: 10; 317 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 318 + margin: 0 auto 8px; 319 + display: block; 320 + text-align: center; 321 + } 322 + </style>
+274 -194
apps/twisted/src/features/explore/ExplorePage.vue
··· 13 13 </ion-toolbar> 14 14 </ion-header> 15 15 16 - <section class="hero"> 17 - <p class="eyebrow">Indexed Search</p> 18 - <h1 class="hero-title">Search the Tangled network through the project index.</h1> 19 - <p class="hero-copy"> 20 - Explore uses the Twister index for global search. Open any result to continue browsing through Tangled's 21 - public repo and profile APIs. 22 - </p> 23 - </section> 24 - 25 16 <section class="search-card"> 26 - <label class="field-label" for="search-input">Search query</label> 27 17 <ion-input 28 18 id="search-input" 29 19 v-model="draftQuery" ··· 32 22 autocapitalize="off" 33 23 :spellcheck="false" 34 24 clear-input 35 - placeholder="Search repos, profiles, issues, and strings" 25 + placeholder="Search repos and people…" 36 26 @keydown.enter="runSearch" /> 37 27 38 28 <ion-segment v-model="resultType" class="search-segment"> ··· 41 31 <ion-segment-button value="profile">People</ion-segment-button> 42 32 </ion-segment> 43 33 44 - <div class="action-row"> 45 - <ion-button class="primary-action" expand="block" @click="runSearch" :disabled="!canSearch"> 46 - Search 47 - </ion-button> 48 - <ion-button fill="outline" expand="block" @click="clearSearch" :disabled="!hasAnyQuery">Clear</ion-button> 34 + <p v-if="!hasTwisterApi" class="hint-copy"> 35 + Set <code>VITE_TWISTER_API_BASE_URL</code> to enable global search. 36 + </p> 37 + </section> 38 + 39 + <!-- Recent search history (shown when no active search) --> 40 + <section v-if="showHistory" class="history-section"> 41 + <div class="history-header"> 42 + <span class="section-label">Recent</span> 43 + <button class="clear-btn" type="button" @click="clearHistory">Clear all</button> 44 + </div> 45 + <div class="history-list"> 46 + <div v-for="entry in searchHistory" :key="entry.query" class="history-chip"> 47 + <button class="chip-label" type="button" @click="applyHistoryEntry(entry.query)"> 48 + <ion-icon :icon="timeOutline" class="chip-icon" /> 49 + {{ entry.query }} 50 + </button> 51 + <button class="chip-remove" type="button" @click="removeHistoryEntry(entry.query)" aria-label="Remove"> 52 + <ion-icon :icon="closeOutline" /> 53 + </button> 54 + </div> 49 55 </div> 50 - 51 - <p v-if="hasTwisterApi" class="hint-copy"> 52 - Search results and follower counts come from the project index when available. 53 - </p> 54 - <p v-else class="hint-copy"> 55 - Set <code>VITE_TWISTER_API_BASE_URL</code> to enable global search and index-backed graph summaries. 56 - </p> 57 56 </section> 58 57 59 58 <section class="results-section"> ··· 61 60 v-if="!hasTwisterApi" 62 61 :icon="searchOutline" 63 62 title="Index API not configured" 64 - message="Explore can search globally once the Twister API base URL is configured for this app." /> 63 + message="Explore can search globally once the Twister API base URL is configured." /> 65 64 66 65 <EmptyState 67 66 v-else-if="!hasAttemptedSearch" 68 67 :icon="searchOutline" 69 68 title="Search repos and people" 70 - message="Run a query against the project index, then open any result to continue browsing with Tangled's public APIs." /> 69 + message="Start typing to search the project index. Results are filtered by type using the segments above." /> 71 70 72 71 <template v-else-if="isLoading"> 73 72 <SkeletonLoader v-for="n in 3" :key="`repo-${n}`" variant="card" /> ··· 85 84 <template v-else-if="hasResults"> 86 85 <div class="results-header"> 87 86 <div> 88 - <p class="results-label">Indexed results</p> 87 + <p class="results-label">Results for</p> 89 88 <h2 class="results-title">{{ submittedQuery }}</h2> 90 89 </div> 91 90 <p class="results-meta">{{ totalLabel }}</p> ··· 114 113 <EmptyState 115 114 v-else 116 115 :icon="searchOutline" 117 - title="No indexed matches" 118 - message="Try a different query, or use Home to jump directly to a known handle." /> 116 + title="No results" 117 + message="Try a different query. Use Home to jump directly to a known handle." /> 119 118 </section> 120 119 </ion-content> 121 120 </ion-page> 122 121 </template> 123 122 124 123 <script setup lang="ts"> 125 - import { computed, ref } from "vue"; 126 - import { useRouter } from "vue-router"; 127 - import { 128 - IonPage, 129 - IonHeader, 130 - IonToolbar, 131 - IonTitle, 132 - IonContent, 133 - IonInput, 134 - IonButton, 135 - IonSegment, 136 - IonSegmentButton, 137 - } from "@ionic/vue"; 138 - import { alertCircleOutline, searchOutline } from "ionicons/icons"; 139 - import EmptyState from "@/components/common/EmptyState.vue"; 140 - import RepoCard from "@/components/common/RepoCard.vue"; 141 - import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 142 - import UserCard from "@/components/common/UserCard.vue"; 143 - import { hasTwisterApi } from "@/core/config/project.js"; 144 - import type { RepoSummary } from "@/domain/models/repo.js"; 145 - import { useProjectSearch } from "@/services/project-api/queries.js"; 124 + import { computed, ref, watch, onUnmounted } from "vue"; 125 + import { useRouter } from "vue-router"; 126 + import { 127 + IonPage, 128 + IonHeader, 129 + IonToolbar, 130 + IonTitle, 131 + IonContent, 132 + IonInput, 133 + IonSegment, 134 + IonSegmentButton, 135 + IonIcon, 136 + } from "@ionic/vue"; 137 + import { alertCircleOutline, searchOutline, timeOutline, closeOutline } from "ionicons/icons"; 138 + import EmptyState from "@/components/common/EmptyState.vue"; 139 + import RepoCard from "@/components/common/RepoCard.vue"; 140 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 141 + import UserCard from "@/components/common/UserCard.vue"; 142 + import { hasTwisterApi } from "@/core/config/project.js"; 143 + import { 144 + getSearchHistory, 145 + addToSearchHistory, 146 + removeFromSearchHistory, 147 + clearSearchHistory, 148 + } from "@/core/search-history/index.js"; 149 + import { trackRepoVisit, trackProfileVisit } from "@/core/browse-history/index.js"; 150 + import type { SearchHistoryEntry } from "@/core/search-history/index.js"; 151 + import type { RepoSummary } from "@/domain/models/repo.js"; 152 + import { useProjectSearch } from "@/services/project-api/queries.js"; 146 153 147 - const router = useRouter(); 154 + const router = useRouter(); 148 155 149 - const draftQuery = ref(""); 150 - const submittedQuery = ref(""); 151 - const hasAttemptedSearch = ref(false); 152 - const resultType = ref<"all" | "repo" | "profile">("all"); 156 + const draftQuery = ref(""); 157 + const submittedQuery = ref(""); 158 + const hasAttemptedSearch = ref(false); 159 + const resultType = ref<"all" | "repo" | "profile">("all"); 160 + const searchHistory = ref<SearchHistoryEntry[]>(getSearchHistory()); 153 161 154 - const hasAnyQuery = computed(() => draftQuery.value.trim().length > 0 || submittedQuery.value.length > 0); 155 - const canSearch = computed(() => hasTwisterApi && draftQuery.value.trim().length > 0); 162 + const showHistory = computed( 163 + () => searchHistory.value.length > 0 && !draftQuery.value.trim() && !hasAttemptedSearch.value, 164 + ); 165 + 166 + const searchQuery = useProjectSearch(submittedQuery, { 167 + type: resultType, 168 + enabled: computed(() => hasTwisterApi && hasAttemptedSearch.value && submittedQuery.value.length > 0), 169 + }); 170 + 171 + const repos = computed(() => searchQuery.data.value?.repos ?? []); 172 + const profiles = computed(() => searchQuery.data.value?.profiles ?? []); 173 + const hasResults = computed(() => repos.value.length > 0 || profiles.value.length > 0); 174 + const isLoading = computed(() => searchQuery.isPending.value); 175 + const isError = computed(() => searchQuery.isError.value); 176 + const errorMessage = computed(() => { 177 + const err = searchQuery.error.value; 178 + return err instanceof Error ? err.message : "An unexpected error occurred while searching the project index."; 179 + }); 180 + const totalLabel = computed(() => { 181 + const total = searchQuery.data.value?.total ?? repos.value.length + profiles.value.length; 182 + return `${total} result${total === 1 ? "" : "s"}`; 183 + }); 184 + 185 + let debounceTimer: ReturnType<typeof setTimeout> | null = null; 186 + 187 + watch(draftQuery, (val) => { 188 + if (debounceTimer) clearTimeout(debounceTimer); 189 + 190 + const trimmed = val.trim(); 191 + if (!trimmed) { 192 + return; 193 + } 194 + 195 + if (!hasTwisterApi) return; 196 + 197 + debounceTimer = setTimeout(() => { 198 + submittedQuery.value = trimmed; 199 + hasAttemptedSearch.value = true; 200 + addToSearchHistory(trimmed); 201 + searchHistory.value = getSearchHistory(); 202 + }, 400); 203 + }); 204 + 205 + onUnmounted(() => { 206 + if (debounceTimer) clearTimeout(debounceTimer); 207 + }); 156 208 157 - const searchQuery = useProjectSearch(submittedQuery, { 158 - type: resultType, 159 - enabled: computed(() => hasTwisterApi && hasAttemptedSearch.value && submittedQuery.value.length > 0), 160 - }); 209 + function runSearch() { 210 + if (!hasTwisterApi) return; 211 + const trimmed = draftQuery.value.trim(); 212 + if (!trimmed) return; 213 + if (debounceTimer) clearTimeout(debounceTimer); 214 + submittedQuery.value = trimmed; 215 + hasAttemptedSearch.value = true; 216 + addToSearchHistory(trimmed); 217 + searchHistory.value = getSearchHistory(); 218 + } 161 219 162 - const repos = computed(() => searchQuery.data.value?.repos ?? []); 163 - const profiles = computed(() => searchQuery.data.value?.profiles ?? []); 164 - const hasResults = computed(() => repos.value.length > 0 || profiles.value.length > 0); 165 - const isLoading = computed(() => searchQuery.isPending.value); 166 - const isError = computed(() => searchQuery.isError.value); 167 - const errorMessage = computed(() => { 168 - const err = searchQuery.error.value; 169 - return err instanceof Error ? err.message : "An unexpected error occurred while searching the project index."; 170 - }); 171 - const totalLabel = computed(() => { 172 - const total = searchQuery.data.value?.total ?? repos.value.length + profiles.value.length; 173 - return `${total} indexed result${total === 1 ? "" : "s"}`; 174 - }); 220 + function applyHistoryEntry(query: string) { 221 + draftQuery.value = query; 222 + submittedQuery.value = query; 223 + hasAttemptedSearch.value = true; 224 + } 175 225 176 - function runSearch() { 177 - if (!canSearch.value) return; 178 - submittedQuery.value = draftQuery.value.trim(); 179 - hasAttemptedSearch.value = true; 180 - } 226 + function removeHistoryEntry(query: string) { 227 + removeFromSearchHistory(query); 228 + searchHistory.value = getSearchHistory(); 229 + } 181 230 182 - function clearSearch() { 183 - draftQuery.value = ""; 184 - submittedQuery.value = ""; 185 - hasAttemptedSearch.value = false; 186 - } 231 + function clearHistory() { 232 + clearSearchHistory(); 233 + searchHistory.value = []; 234 + } 187 235 188 - function navigateToRepo(repo: RepoSummary) { 189 - router.push(`/tabs/explore/repo/${repo.ownerHandle}/${repo.name}`); 190 - } 236 + function navigateToRepo(repo: RepoSummary) { 237 + trackRepoVisit({ 238 + ownerHandle: repo.ownerHandle, 239 + name: repo.name, 240 + description: repo.description, 241 + primaryLanguage: repo.primaryLanguage, 242 + stars: repo.stars, 243 + }); 244 + router.push(`/tabs/explore/repo/${repo.ownerHandle}/${repo.name}`); 245 + } 191 246 192 - function navigateToUser(handle: string) { 193 - router.push(`/tabs/explore/user/${handle}`); 194 - } 247 + function navigateToUser(handle: string) { 248 + trackProfileVisit({ handle }); 249 + router.push(`/tabs/explore/user/${handle}`); 250 + } 195 251 </script> 196 252 197 253 <style scoped> 198 - .hero { 199 - padding: 24px 20px 12px; 200 - } 254 + .search-card { 255 + margin: 16px 16px 0; 256 + padding: 14px 14px 12px; 257 + border: 1px solid var(--t-border); 258 + border-radius: var(--t-radius-lg); 259 + background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface)); 260 + } 201 261 202 - .eyebrow { 203 - margin: 0 0 10px; 204 - font-size: 12px; 205 - font-weight: 700; 206 - letter-spacing: 0.08em; 207 - text-transform: uppercase; 208 - color: var(--t-accent); 209 - } 262 + .search-input { 263 + --background: rgba(255, 255, 255, 0.04); 264 + --border-radius: var(--t-radius-md); 265 + --color: var(--t-text-primary); 266 + --padding-start: 14px; 267 + --padding-end: 14px; 268 + margin-bottom: 10px; 269 + border: 1px solid var(--t-border); 270 + border-radius: var(--t-radius-md); 271 + font-family: var(--t-mono); 272 + } 210 273 211 - .hero-title { 212 - margin: 0; 213 - font-size: 28px; 214 - line-height: 1.15; 215 - color: var(--t-text-primary); 216 - } 274 + .search-segment { 275 + margin-bottom: 4px; 276 + } 217 277 218 - .hero-copy { 219 - margin: 12px 0 0; 220 - font-size: 14px; 221 - line-height: 1.6; 222 - color: var(--t-text-secondary); 223 - max-width: 34rem; 224 - } 278 + .hint-copy { 279 + margin: 8px 0 0; 280 + font-size: 12px; 281 + line-height: 1.5; 282 + color: var(--t-text-muted); 283 + } 225 284 226 - .search-card { 227 - margin: 0 16px; 228 - padding: 18px 16px 16px; 229 - border: 1px solid var(--t-border); 230 - border-radius: var(--t-radius-lg); 231 - background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface)); 232 - } 285 + .history-section { 286 + padding: 14px 16px 4px; 287 + } 233 288 234 - .field-label { 235 - display: block; 236 - margin-bottom: 8px; 237 - font-size: 13px; 238 - font-weight: 600; 239 - color: var(--t-text-primary); 240 - } 289 + .history-header { 290 + display: flex; 291 + align-items: center; 292 + justify-content: space-between; 293 + margin-bottom: 10px; 294 + } 241 295 242 - .search-input { 243 - --background: rgba(255, 255, 255, 0.04); 244 - --border-radius: var(--t-radius-md); 245 - --color: var(--t-text-primary); 246 - --padding-start: 14px; 247 - --padding-end: 14px; 248 - margin-bottom: 12px; 249 - border: 1px solid var(--t-border); 250 - border-radius: var(--t-radius-md); 251 - font-family: var(--t-mono); 252 - } 296 + .clear-btn { 297 + appearance: none; 298 + background: transparent; 299 + border: 0; 300 + padding: 0; 301 + cursor: pointer; 302 + font-size: 12px; 303 + color: var(--t-text-muted); 304 + } 253 305 254 - .search-segment { 255 - margin-bottom: 12px; 256 - } 306 + .history-list { 307 + display: flex; 308 + flex-wrap: wrap; 309 + gap: 8px; 310 + } 257 311 258 - .action-row { 259 - display: grid; 260 - grid-template-columns: repeat(2, minmax(0, 1fr)); 261 - gap: 10px; 262 - } 312 + .history-chip { 313 + display: flex; 314 + align-items: center; 315 + gap: 0; 316 + border: 1px solid var(--t-border); 317 + border-radius: 999px; 318 + background: var(--t-surface); 319 + overflow: hidden; 320 + } 263 321 264 - .primary-action { 265 - --background: var(--t-accent); 266 - --background-activated: var(--t-accent); 267 - --color: #0d1117; 268 - } 322 + .chip-label { 323 + display: flex; 324 + align-items: center; 325 + gap: 6px; 326 + appearance: none; 327 + background: transparent; 328 + border: 0; 329 + padding: 5px 10px 5px 10px; 330 + cursor: pointer; 331 + font-size: 13px; 332 + color: var(--t-text-primary); 333 + font-family: var(--t-mono); 334 + } 269 335 270 - .hint-copy { 271 - margin: 12px 0 0; 272 - font-size: 12px; 273 - line-height: 1.5; 274 - color: var(--t-text-muted); 275 - } 336 + .chip-icon { 337 + font-size: 13px; 338 + color: var(--t-text-muted); 339 + flex-shrink: 0; 340 + } 341 + 342 + .chip-remove { 343 + appearance: none; 344 + background: transparent; 345 + border: 0; 346 + border-left: 1px solid var(--t-border); 347 + padding: 5px 8px; 348 + cursor: pointer; 349 + font-size: 13px; 350 + color: var(--t-text-muted); 351 + display: flex; 352 + align-items: center; 353 + } 276 354 277 - .results-section { 278 - padding: 18px 0 24px; 279 - } 355 + .results-section { 356 + padding: 14px 0 24px; 357 + } 280 358 281 - .results-header { 282 - display: flex; 283 - align-items: flex-end; 284 - justify-content: space-between; 285 - gap: 12px; 286 - margin: 0 16px 12px; 287 - } 359 + .results-header { 360 + display: flex; 361 + align-items: flex-end; 362 + justify-content: space-between; 363 + gap: 12px; 364 + margin: 0 16px 12px; 365 + } 288 366 289 - .results-label { 290 - margin: 0 0 4px; 291 - font-size: 12px; 292 - font-weight: 700; 293 - letter-spacing: 0.08em; 294 - text-transform: uppercase; 295 - color: var(--t-accent); 296 - } 367 + .results-label { 368 + margin: 0 0 2px; 369 + font-size: 11px; 370 + font-weight: 700; 371 + letter-spacing: 0.08em; 372 + text-transform: uppercase; 373 + color: var(--t-accent); 374 + } 297 375 298 - .results-title { 299 - margin: 0; 300 - font-size: 20px; 301 - line-height: 1.2; 302 - color: var(--t-text-primary); 303 - } 376 + .results-title { 377 + margin: 0; 378 + font-size: 18px; 379 + line-height: 1.2; 380 + color: var(--t-text-primary); 381 + } 304 382 305 - .results-meta { 306 - margin: 0; 307 - font-size: 12px; 308 - color: var(--t-text-muted); 309 - } 383 + .results-meta { 384 + margin: 0; 385 + font-size: 12px; 386 + color: var(--t-text-muted); 387 + white-space: nowrap; 388 + } 310 389 311 - .section-label { 312 - margin: 0 16px 8px; 313 - font-size: 12px; 314 - font-weight: 700; 315 - letter-spacing: 0.08em; 316 - text-transform: uppercase; 317 - color: var(--t-text-muted); 318 - } 390 + .section-label { 391 + display: block; 392 + margin: 0 16px 8px; 393 + font-size: 11px; 394 + font-weight: 700; 395 + letter-spacing: 0.08em; 396 + text-transform: uppercase; 397 + color: var(--t-text-muted); 398 + } 319 399 </style>
+371 -180
apps/twisted/src/features/home/HomePage.vue
··· 13 13 </ion-toolbar> 14 14 </ion-header> 15 15 16 - <section class="hero"> 17 - <p class="eyebrow">Profile Browser</p> 18 - <h1 class="hero-title">Jump straight to a Tangled profile or repo.</h1> 19 - <p class="hero-copy"> 20 - Enter an AT Protocol handle, then open the profile directly or resolve the user's Personal Data Server and 21 - browse their public repositories here. 22 - </p> 23 - </section> 24 - 25 16 <section class="lookup-card"> 26 17 <label class="field-label" for="handle-input">AT Protocol handle</label> 27 18 <ion-input ··· 45 36 </ion-button> 46 37 </div> 47 38 48 - <p class="hint-copy"> 49 - Home is still the fastest way to jump to a known handle directly. 50 - </p> 39 + <p class="hint-copy">Home is the fastest way to jump to a known handle directly.</p> 40 + </section> 41 + 42 + <!-- Recently viewed repos & profiles --> 43 + <section v-if="hasRecentItems && !hasAttemptedBrowse" class="recent-section"> 44 + <div class="recent-header"> 45 + <span class="section-label">Recently Viewed</span> 46 + <button class="clear-btn" type="button" @click="clearRecent">Clear</button> 47 + </div> 48 + 49 + <template v-if="recentProfiles.length"> 50 + <p class="recent-group-label">Profiles</p> 51 + <ion-item 52 + v-for="profile in recentProfiles" 53 + :key="profile.handle" 54 + button 55 + lines="none" 56 + class="recent-item" 57 + @click="openRecentProfile(profile.handle)"> 58 + <div slot="start" class="recent-avatar" :style="{ background: avatarColor(profile.handle) }"> 59 + {{ initials(profile.handle) }} 60 + </div> 61 + <ion-label> 62 + <div class="recent-handle">{{ profile.handle }}</div> 63 + <div v-if="profile.displayName" class="recent-name">{{ profile.displayName }}</div> 64 + </ion-label> 65 + </ion-item> 66 + </template> 67 + 68 + <template v-if="recentRepos.length"> 69 + <p class="recent-group-label">Repos</p> 70 + <ion-item 71 + v-for="repo in recentRepos" 72 + :key="`${repo.ownerHandle}/${repo.name}`" 73 + button 74 + lines="none" 75 + class="recent-item" 76 + @click="openRecentRepo(repo)"> 77 + <ion-label> 78 + <div class="recent-repo-title"> 79 + <span class="recent-owner">{{ repo.ownerHandle }}</span 80 + ><span class="recent-sep">/</span><span class="recent-repo-name">{{ repo.name }}</span> 81 + </div> 82 + <div v-if="repo.description" class="recent-desc">{{ repo.description }}</div> 83 + </ion-label> 84 + <div v-if="repo.primaryLanguage" slot="end" class="lang-badge">{{ repo.primaryLanguage }}</div> 85 + </ion-item> 86 + </template> 51 87 </section> 52 88 53 89 <section v-if="hasAttemptedBrowse" class="results-section"> ··· 90 126 v-else 91 127 :icon="folderOpenOutline" 92 128 title="No public repos yet" 93 - message="This handle resolved successfully, but there are no public Tangled repositories to browse yet." /> 129 + message="This handle resolved successfully, but there are no public Tangled repositories yet." /> 94 130 </template> 95 131 </section> 96 132 97 - <section v-else class="results-section"> 133 + <section v-else-if="!hasRecentItems" class="results-section"> 98 134 <EmptyState 99 135 :icon="compassOutline" 100 136 title="Browse by handle" 101 - message="Browse Tangled repositories by entering a handle above." /> 137 + message="Enter an AT Protocol handle above to view their profile and repos." /> 102 138 </section> 103 139 </ion-content> 104 140 </ion-page> 105 141 </template> 106 142 107 143 <script setup lang="ts"> 108 - import { computed, ref } from "vue"; 109 - import { useRouter } from "vue-router"; 110 - import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonInput, IonButton } from "@ionic/vue"; 111 - import { alertCircleOutline, compassOutline, folderOpenOutline } from "ionicons/icons"; 112 - import RepoCard from "@/components/common/RepoCard.vue"; 113 - import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 114 - import EmptyState from "@/components/common/EmptyState.vue"; 115 - import { useIdentity, useUserRepos, useActorProfile } from "@/services/tangled/queries.js"; 116 - import type { RepoSummary } from "@/domain/models/repo.js"; 144 + import { computed, ref } from "vue"; 145 + import { useRouter } from "vue-router"; 146 + import { 147 + IonPage, 148 + IonHeader, 149 + IonToolbar, 150 + IonTitle, 151 + IonContent, 152 + IonInput, 153 + IonButton, 154 + IonItem, 155 + IonLabel, 156 + } from "@ionic/vue"; 157 + import { alertCircleOutline, compassOutline, folderOpenOutline } from "ionicons/icons"; 158 + import RepoCard from "@/components/common/RepoCard.vue"; 159 + import SkeletonLoader from "@/components/common/SkeletonLoader.vue"; 160 + import EmptyState from "@/components/common/EmptyState.vue"; 161 + import { useIdentity, useUserRepos, useActorProfile } from "@/services/tangled/queries.js"; 162 + import { 163 + trackRepoVisit, 164 + trackProfileVisit, 165 + getRecentRepos, 166 + getRecentProfiles, 167 + clearBrowseHistory, 168 + } from "@/core/browse-history/index.js"; 169 + import type { RepoSummary } from "@/domain/models/repo.js"; 170 + import type { RecentRepo, RecentProfile } from "@/core/browse-history/index.js"; 117 171 118 - const router = useRouter(); 172 + const router = useRouter(); 119 173 120 - const draftHandle = ref(""); 121 - const activeHandle = ref(""); 122 - const hasAttemptedBrowse = ref(false); 174 + const draftHandle = ref(""); 175 + const activeHandle = ref(""); 176 + const hasAttemptedBrowse = ref(false); 177 + const recentRepos = ref<RecentRepo[]>(getRecentRepos()); 178 + const recentProfiles = ref<RecentProfile[]>(getRecentProfiles()); 123 179 124 - const normalizedHandle = computed(() => draftHandle.value.trim().toLowerCase()); 125 - const hasHandle = computed(() => normalizedHandle.value.length > 0); 180 + const normalizedHandle = computed(() => draftHandle.value.trim().toLowerCase()); 181 + const hasHandle = computed(() => normalizedHandle.value.length > 0); 182 + const hasRecentItems = computed(() => recentRepos.value.length > 0 || recentProfiles.value.length > 0); 126 183 127 - const identity = useIdentity(activeHandle, { enabled: computed(() => !!activeHandle.value) }); 128 - const did = computed(() => identity.data.value?.did ?? ""); 129 - const pds = computed(() => identity.data.value?.pds ?? ""); 130 - const hasResolvedIdentity = computed(() => !!identity.data.value); 184 + const identity = useIdentity(activeHandle, { enabled: computed(() => !!activeHandle.value) }); 185 + const did = computed(() => identity.data.value?.did ?? ""); 186 + const pds = computed(() => identity.data.value?.pds ?? ""); 187 + const hasResolvedIdentity = computed(() => !!identity.data.value); 131 188 132 - const profileQuery = useActorProfile(pds, did, activeHandle, undefined, { enabled: hasResolvedIdentity }); 133 - const reposQuery = useUserRepos(pds, did, activeHandle, { enabled: hasResolvedIdentity }); 189 + const profileQuery = useActorProfile(pds, did, activeHandle, undefined, { enabled: hasResolvedIdentity }); 190 + const reposQuery = useUserRepos(pds, did, activeHandle, { enabled: hasResolvedIdentity }); 134 191 135 - const repos = computed(() => reposQuery.data.value ?? []); 136 - const displayName = computed(() => profileQuery.data.value?.displayName ?? "Public Tangled account"); 137 - const repoCountLabel = computed(() => `${repos.value.length} repo${repos.value.length === 1 ? "" : "s"}`); 138 - const isBrowsing = computed(() => hasAttemptedBrowse.value && activeHandle.value === normalizedHandle.value && isLoading.value); 139 - const isLoading = computed( 140 - () => 141 - hasAttemptedBrowse.value && 142 - activeHandle.value.length > 0 && 143 - (identity.isPending.value || (hasResolvedIdentity.value && reposQuery.isPending.value)), 144 - ); 145 - const isError = computed(() => hasAttemptedBrowse.value && (identity.isError.value || reposQuery.isError.value)); 146 - const errorMessage = computed(() => { 147 - const err = identity.error.value ?? reposQuery.error.value; 148 - return err instanceof Error ? err.message : "An unexpected error occurred while resolving this handle."; 149 - }); 192 + const repos = computed(() => reposQuery.data.value ?? []); 193 + const displayName = computed(() => profileQuery.data.value?.displayName ?? "Public Tangled account"); 194 + const repoCountLabel = computed(() => `${repos.value.length} repo${repos.value.length === 1 ? "" : "s"}`); 195 + const isBrowsing = computed( 196 + () => hasAttemptedBrowse.value && activeHandle.value === normalizedHandle.value && isLoading.value, 197 + ); 198 + const isLoading = computed( 199 + () => 200 + hasAttemptedBrowse.value && 201 + activeHandle.value.length > 0 && 202 + (identity.isPending.value || (hasResolvedIdentity.value && reposQuery.isPending.value)), 203 + ); 204 + const isError = computed(() => hasAttemptedBrowse.value && (identity.isError.value || reposQuery.isError.value)); 205 + const errorMessage = computed(() => { 206 + const err = identity.error.value ?? reposQuery.error.value; 207 + return err instanceof Error ? err.message : "An unexpected error occurred while resolving this handle."; 208 + }); 209 + 210 + const PALETTE = ["#22d3ee", "#a78bfa", "#34d399", "#fbbf24", "#f87171", "#fb923c", "#60a5fa"]; 211 + 212 + function avatarColor(handle: string): string { 213 + let hash = 0; 214 + for (const ch of handle) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff; 215 + return PALETTE[Math.abs(hash) % PALETTE.length]; 216 + } 150 217 151 - function openProfile() { 152 - if (!hasHandle.value) return; 153 - router.push(`/tabs/home/user/${normalizedHandle.value}`); 154 - } 218 + function initials(handle: string): string { 219 + const base = handle.split(".")[0]; 220 + return base.slice(0, 2).toUpperCase(); 221 + } 155 222 156 - function browseRepos() { 157 - if (!hasHandle.value) return; 158 - hasAttemptedBrowse.value = true; 159 - activeHandle.value = normalizedHandle.value; 160 - } 223 + function openProfile() { 224 + if (!hasHandle.value) return; 225 + trackProfileVisit({ handle: normalizedHandle.value }); 226 + router.push(`/tabs/home/user/${normalizedHandle.value}`); 227 + } 161 228 162 - function openResolvedProfile() { 163 - if (!activeHandle.value) return; 164 - router.push(`/tabs/home/user/${activeHandle.value}`); 165 - } 229 + function browseRepos() { 230 + if (!hasHandle.value) return; 231 + hasAttemptedBrowse.value = true; 232 + activeHandle.value = normalizedHandle.value; 233 + } 166 234 167 - function navigateToRepo(repo: RepoSummary) { 168 - router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`); 169 - } 235 + function openResolvedProfile() { 236 + if (!activeHandle.value) return; 237 + trackProfileVisit({ 238 + handle: activeHandle.value, 239 + displayName: profileQuery.data.value?.displayName, 240 + bio: profileQuery.data.value?.bio, 241 + }); 242 + router.push(`/tabs/home/user/${activeHandle.value}`); 243 + } 244 + 245 + function navigateToRepo(repo: RepoSummary) { 246 + trackRepoVisit({ 247 + ownerHandle: repo.ownerHandle, 248 + name: repo.name, 249 + description: repo.description, 250 + primaryLanguage: repo.primaryLanguage, 251 + stars: repo.stars, 252 + }); 253 + router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`); 254 + } 255 + 256 + function openRecentProfile(handle: string) { 257 + trackProfileVisit({ handle }); 258 + router.push(`/tabs/home/user/${handle}`); 259 + } 260 + 261 + function openRecentRepo(repo: RecentRepo) { 262 + trackRepoVisit(repo); 263 + recentRepos.value = getRecentRepos(); 264 + router.push(`/tabs/home/repo/${repo.ownerHandle}/${repo.name}`); 265 + } 266 + 267 + function clearRecent() { 268 + clearBrowseHistory(); 269 + recentRepos.value = []; 270 + recentProfiles.value = []; 271 + } 170 272 </script> 171 273 172 274 <style scoped> 173 - .hero { 174 - padding: 24px 20px 12px; 175 - } 275 + .lookup-card { 276 + margin: 16px 16px 0; 277 + padding: 18px 16px 16px; 278 + border: 1px solid var(--t-border); 279 + border-radius: var(--t-radius-lg); 280 + background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface)); 281 + } 282 + 283 + .field-label { 284 + display: block; 285 + margin-bottom: 8px; 286 + font-size: 13px; 287 + font-weight: 600; 288 + color: var(--t-text-primary); 289 + } 176 290 177 - .eyebrow { 178 - margin: 0 0 10px; 179 - font-size: 12px; 180 - font-weight: 700; 181 - letter-spacing: 0.08em; 182 - text-transform: uppercase; 183 - color: var(--t-accent); 184 - } 291 + .handle-input { 292 + --background: rgba(255, 255, 255, 0.04); 293 + --border-radius: var(--t-radius-md); 294 + --color: var(--t-text-primary); 295 + --padding-start: 14px; 296 + --padding-end: 14px; 297 + margin-bottom: 12px; 298 + border: 1px solid var(--t-border); 299 + border-radius: var(--t-radius-md); 300 + font-family: var(--t-mono); 301 + } 302 + 303 + .action-row { 304 + display: grid; 305 + grid-template-columns: repeat(2, minmax(0, 1fr)); 306 + gap: 10px; 307 + } 308 + 309 + .primary-action { 310 + --background: var(--t-accent); 311 + --background-activated: var(--t-accent); 312 + --color: #0d1117; 313 + } 185 314 186 - .hero-title { 187 - margin: 0; 188 - font-size: 28px; 189 - line-height: 1.15; 190 - color: var(--t-text-primary); 191 - } 315 + .hint-copy { 316 + margin: 12px 0 0; 317 + font-size: 12px; 318 + line-height: 1.5; 319 + color: var(--t-text-muted); 320 + } 192 321 193 - .hero-copy { 194 - margin: 12px 0 0; 195 - font-size: 14px; 196 - line-height: 1.6; 197 - color: var(--t-text-secondary); 198 - max-width: 34rem; 199 - } 322 + .recent-section { 323 + padding: 16px 0 8px; 324 + } 200 325 201 - .lookup-card { 202 - margin: 0 16px; 203 - padding: 18px 16px 16px; 204 - border: 1px solid var(--t-border); 205 - border-radius: var(--t-radius-lg); 206 - background: linear-gradient(180deg, var(--t-surface-raised), var(--t-surface)); 207 - } 326 + .recent-header { 327 + display: flex; 328 + align-items: center; 329 + justify-content: space-between; 330 + padding: 0 16px; 331 + margin-bottom: 10px; 332 + } 208 333 209 - .field-label { 210 - display: block; 211 - margin-bottom: 8px; 212 - font-size: 13px; 213 - font-weight: 600; 214 - color: var(--t-text-primary); 215 - } 334 + .section-label { 335 + font-size: 11px; 336 + font-weight: 700; 337 + letter-spacing: 0.08em; 338 + text-transform: uppercase; 339 + color: var(--t-text-muted); 340 + } 216 341 217 - .handle-input { 218 - --background: rgba(255, 255, 255, 0.04); 219 - --border-radius: var(--t-radius-md); 220 - --color: var(--t-text-primary); 221 - --padding-start: 14px; 222 - --padding-end: 14px; 223 - margin-bottom: 12px; 224 - border: 1px solid var(--t-border); 225 - border-radius: var(--t-radius-md); 226 - font-family: var(--t-mono); 227 - } 342 + .clear-btn { 343 + appearance: none; 344 + background: transparent; 345 + border: 0; 346 + padding: 0; 347 + cursor: pointer; 348 + font-size: 12px; 349 + color: var(--t-text-muted); 350 + } 228 351 229 - .action-row { 230 - display: grid; 231 - grid-template-columns: repeat(2, minmax(0, 1fr)); 232 - gap: 10px; 233 - } 352 + .recent-group-label { 353 + margin: 8px 16px 4px; 354 + font-size: 11px; 355 + font-weight: 600; 356 + letter-spacing: 0.05em; 357 + text-transform: uppercase; 358 + color: var(--t-text-muted); 359 + } 234 360 235 - .primary-action { 236 - --background: var(--t-accent); 237 - --background-activated: var(--t-accent); 238 - --color: #0d1117; 239 - } 361 + .recent-item { 362 + --background: transparent; 363 + --padding-start: 16px; 364 + --padding-end: 16px; 365 + --inner-padding-end: 0; 366 + --min-height: 48px; 367 + } 240 368 241 - .hint-copy { 242 - margin: 12px 0 0; 243 - font-size: 12px; 244 - line-height: 1.5; 245 - color: var(--t-text-muted); 246 - } 369 + .recent-avatar { 370 + width: 32px; 371 + height: 32px; 372 + border-radius: var(--t-radius-sm); 373 + display: flex; 374 + align-items: center; 375 + justify-content: center; 376 + font-family: var(--t-mono); 377 + font-size: 11px; 378 + font-weight: 700; 379 + color: #0d1117; 380 + margin-right: 12px; 381 + flex-shrink: 0; 382 + } 247 383 248 - .results-section { 249 - padding: 18px 0 24px; 250 - } 384 + .recent-handle { 385 + font-family: var(--t-mono); 386 + font-size: 13px; 387 + font-weight: 600; 388 + color: var(--t-accent); 389 + } 251 390 252 - .resolved-header { 253 - display: flex; 254 - align-items: flex-start; 255 - justify-content: space-between; 256 - gap: 12px; 257 - margin: 0 16px 10px; 258 - padding: 16px; 259 - border: 1px solid var(--t-border); 260 - border-radius: var(--t-radius-md); 261 - background: var(--t-surface); 262 - } 391 + .recent-name { 392 + font-size: 12px; 393 + color: var(--t-text-secondary); 394 + margin-top: 1px; 395 + } 263 396 264 - .resolved-copy { 265 - min-width: 0; 266 - } 397 + .recent-repo-title { 398 + font-family: var(--t-mono); 399 + font-size: 13px; 400 + font-weight: 500; 401 + color: var(--t-text-primary); 402 + line-height: 1.3; 403 + } 267 404 268 - .resolved-label { 269 - margin: 0 0 6px; 270 - font-size: 12px; 271 - font-weight: 600; 272 - letter-spacing: 0.06em; 273 - text-transform: uppercase; 274 - color: var(--t-text-muted); 275 - } 405 + .recent-owner { 406 + color: var(--t-text-secondary); 407 + } 276 408 277 - .resolved-title { 278 - margin: 0; 279 - font-size: 15px; 280 - color: var(--t-text-primary); 281 - word-break: break-word; 282 - } 409 + .recent-sep { 410 + color: var(--t-text-muted); 411 + margin: 0 1px; 412 + } 283 413 284 - .resolved-meta { 285 - margin: 8px 0 0; 286 - font-size: 13px; 287 - color: var(--t-text-secondary); 288 - } 414 + .recent-repo-name { 415 + color: var(--t-accent); 416 + } 289 417 290 - .meta-separator { 291 - margin: 0 6px; 292 - color: var(--t-text-muted); 293 - } 418 + .recent-desc { 419 + font-size: 12px; 420 + color: var(--t-text-muted); 421 + margin-top: 2px; 422 + overflow: hidden; 423 + text-overflow: ellipsis; 424 + white-space: nowrap; 425 + } 294 426 295 - @media (max-width: 480px) { 296 - .hero-title { 297 - font-size: 24px; 427 + .lang-badge { 428 + font-family: var(--t-mono); 429 + font-size: 11px; 430 + font-weight: 600; 431 + color: var(--t-text-muted); 432 + padding: 2px 6px; 433 + border: 1px solid var(--t-border); 434 + border-radius: 999px; 298 435 } 299 436 300 - .action-row { 301 - grid-template-columns: 1fr; 437 + /* Browse results */ 438 + 439 + .results-section { 440 + padding: 18px 0 24px; 302 441 } 303 442 304 443 .resolved-header { 305 - flex-direction: column; 306 - align-items: stretch; 444 + display: flex; 445 + align-items: flex-start; 446 + justify-content: space-between; 447 + gap: 12px; 448 + margin: 0 16px 10px; 449 + padding: 16px; 450 + border: 1px solid var(--t-border); 451 + border-radius: var(--t-radius-md); 452 + background: var(--t-surface); 307 453 } 308 - } 454 + 455 + .resolved-copy { 456 + min-width: 0; 457 + } 458 + 459 + .resolved-label { 460 + margin: 0 0 6px; 461 + font-size: 12px; 462 + font-weight: 600; 463 + letter-spacing: 0.06em; 464 + text-transform: uppercase; 465 + color: var(--t-text-muted); 466 + } 467 + 468 + .resolved-title { 469 + margin: 0; 470 + font-size: 15px; 471 + color: var(--t-text-primary); 472 + word-break: break-word; 473 + } 474 + 475 + .resolved-meta { 476 + margin: 8px 0 0; 477 + font-size: 13px; 478 + color: var(--t-text-secondary); 479 + } 480 + 481 + .meta-separator { 482 + margin: 0 6px; 483 + color: var(--t-text-muted); 484 + } 485 + 486 + .mono { 487 + font-family: var(--t-mono); 488 + } 489 + 490 + @media (max-width: 480px) { 491 + .action-row { 492 + grid-template-columns: 1fr; 493 + } 494 + 495 + .resolved-header { 496 + flex-direction: column; 497 + align-items: stretch; 498 + } 499 + } 309 500 </style>
+4
apps/twisted/src/features/repo/RepoDetailPage.vue
··· 94 94 useRepoIssues, 95 95 useRepoPRs, 96 96 } from "@/services/tangled/queries.js"; 97 + import { useRepoStarCount } from "@/services/constellation/queries.js"; 97 98 import type { RepoDetail } from "@/domain/models/repo.js"; 98 99 import type { RepoAssetContext } from "@/services/tangled/repo-assets.js"; 99 100 ··· 163 164 if (!rec) return undefined; 164 165 return { 165 166 ...rec, 167 + stars: starCountQuery.data.value ?? rec.stars, 166 168 defaultBranch: defaultBranch.value || undefined, 167 169 languages: languagesQuery.data.value, 168 170 readme: readmeQuery.data.value?.isBinary ? undefined : readmeQuery.data.value?.content, ··· 171 173 172 174 const repoAtUri = computed(() => recordQuery.data.value?.atUri ?? ""); 173 175 const hasAtUri = computed(() => !!repoAtUri.value); 176 + 177 + const starCountQuery = useRepoStarCount(repoAtUri, { enabled: hasAtUri }); 174 178 175 179 const issuesQuery = useRepoIssues(pds, did, owner, repoAtUri, { enabled: hasAtUri }); 176 180 const prsQuery = useRepoPRs(pds, did, owner, repoAtUri, { enabled: hasAtUri });
+76
apps/twisted/src/services/constellation/queries.ts
··· 1 + /** 2 + * TanStack Query hooks for the Constellation backlink API. 3 + * https://constellation.microcosm.blue 4 + * 5 + * Constellation is a public AT Protocol backlink index. It answers 6 + * "how many records link to this subject?" — star counts, follower 7 + * counts, reaction counts — without requiring authentication. 8 + * 9 + * Calling it directly from the app avoids adding per-resource endpoints 10 + * to the Twister API for every social signal we need. 11 + */ 12 + import { useQuery } from "@tanstack/vue-query"; 13 + import { computed, toValue } from "vue"; 14 + import type { MaybeRef } from "vue"; 15 + 16 + const CONSTELLATION_BASE = "https://constellation.microcosm.blue"; 17 + 18 + // AT Protocol collection + field paths used as Constellation "sources". 19 + const SOURCE_STAR = "sh.tangled.feed.star:subject.uri"; 20 + const SOURCE_FOLLOW = "sh.tangled.graph.follow:subject"; 21 + 22 + const MIN = 60_000; 23 + 24 + async function fetchBacklinksCount(subject: string, source: string): Promise<number> { 25 + const url = new URL(`${CONSTELLATION_BASE}/xrpc/blue.microcosm.links.getBacklinksCount`); 26 + url.searchParams.set("subject", subject); 27 + url.searchParams.set("source", source); 28 + 29 + const res = await fetch(url.toString(), { 30 + headers: { Accept: "application/json" }, 31 + }); 32 + 33 + if (!res.ok) { 34 + throw new Error(`Constellation request failed: ${res.status}`); 35 + } 36 + 37 + const data = (await res.json()) as { count: number }; 38 + return data.count; 39 + } 40 + 41 + /** 42 + * Fetches the star count for a repo AT URI from Constellation. 43 + * atUri should be in the form "at://did:plc:.../sh.tangled.repo/reponame". 44 + */ 45 + export function useRepoStarCount(atUri: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 46 + const normalizedUri = computed(() => toValue(atUri).trim()); 47 + const enabled = computed( 48 + () => normalizedUri.value.length > 0 && (options.enabled === undefined || !!toValue(options.enabled)), 49 + ); 50 + 51 + return useQuery({ 52 + queryKey: computed(() => ["constellationStars", normalizedUri.value]), 53 + queryFn: () => fetchBacklinksCount(normalizedUri.value, SOURCE_STAR), 54 + enabled, 55 + staleTime: 5 * MIN, 56 + gcTime: 30 * MIN, 57 + }); 58 + } 59 + 60 + /** 61 + * Fetches the follower count for a DID from Constellation. 62 + */ 63 + export function useFollowerCount(did: MaybeRef<string>, options: { enabled?: MaybeRef<boolean> } = {}) { 64 + const normalizedDid = computed(() => toValue(did).trim()); 65 + const enabled = computed( 66 + () => normalizedDid.value.length > 0 && (options.enabled === undefined || !!toValue(options.enabled)), 67 + ); 68 + 69 + return useQuery({ 70 + queryKey: computed(() => ["constellationFollowers", normalizedDid.value]), 71 + queryFn: () => fetchBacklinksCount(normalizedDid.value, SOURCE_FOLLOW), 72 + enabled, 73 + staleTime: 5 * MIN, 74 + gcTime: 30 * MIN, 75 + }); 76 + }
+252
apps/twisted/src/services/jetstream/client.ts
··· 1 + /** 2 + * JetstreamClient — subscribes to the AT Protocol Jetstream WebSocket firehose 3 + * and filters for sh.tangled.* collection events, emitting ActivityItems. 4 + * 5 + * Connects on demand, auto-reconnects after disconnection, and tracks the last 6 + * event cursor so gap-free resume is possible on reconnect. 7 + * 8 + * Data source decision: Jetstream is chosen over PDS polling because it provides 9 + * a public, real-time stream of all network events without requiring authentication 10 + * or prior knowledge of specific user DIDs. PDS polling would require a known list 11 + * of accounts to follow, and the Twister API does not yet expose an activity feed. 12 + */ 13 + import type { ActivityItem } from "@/domain/models/activity.js"; 14 + 15 + const JETSTREAM_URL = "wss://jetstream2.us-east.bsky.network/subscribe"; 16 + const MAX_ITEMS = 200; 17 + const RECONNECT_DELAY_MS = 3_000; 18 + 19 + const WANTED_COLLECTIONS = [ 20 + "sh.tangled.repo", 21 + "sh.tangled.feed.star", 22 + "sh.tangled.graph.follow", 23 + "sh.tangled.repo.issue", 24 + "sh.tangled.repo.issue.state", 25 + "sh.tangled.repo.pull", 26 + ]; 27 + 28 + type JetstreamRecord = Record<string, unknown>; 29 + 30 + type JetstreamCommit = { 31 + rev: string; 32 + operation: "create" | "update" | "delete"; 33 + collection: string; 34 + rkey: string; 35 + record?: JetstreamRecord; 36 + cid?: string; 37 + }; 38 + 39 + type JetstreamEventKind = "commit" | "identity" | "account"; 40 + 41 + type JetstreamEvent = { did: string; time_us: number; kind: JetstreamEventKind; commit?: JetstreamCommit }; 42 + 43 + export type JetstreamCallbacks = { 44 + onEvent: (item: ActivityItem) => void; 45 + onConnected?: () => void; 46 + onDisconnected?: () => void; 47 + onError?: () => void; 48 + }; 49 + 50 + function extractAtUri(record: JetstreamRecord, field: string): string { 51 + const subject = record[field] as { uri?: string } | string | undefined; 52 + if (typeof subject === "string") return subject; 53 + return subject?.uri ?? ""; 54 + } 55 + 56 + /** Extract the last path segment of an AT URI (the rkey / repo name). */ 57 + function atUriRkey(atUri: string): string | undefined { 58 + const seg = atUri.split("/").pop(); 59 + return seg || undefined; 60 + } 61 + 62 + /** Extract the DID embedded in an AT URI (at://did:plc:.../collection/rkey). */ 63 + function atUriDid(atUri: string): string | undefined { 64 + const match = /^at:\/\/(did:[^/]+)/.exec(atUri); 65 + return match?.[1]; 66 + } 67 + 68 + function toActivityKind(commit: JetstreamCommit): ActivityItem["kind"] | null { 69 + if (commit.operation === "delete") return null; 70 + 71 + switch (commit.collection) { 72 + case "sh.tangled.repo": 73 + return commit.operation === "create" ? "repo_created" : null; 74 + case "sh.tangled.feed.star": 75 + return commit.operation === "create" ? "repo_starred" : null; 76 + case "sh.tangled.graph.follow": 77 + return commit.operation === "create" ? "user_followed" : null; 78 + case "sh.tangled.repo.issue": 79 + return commit.operation === "create" ? "issue_opened" : null; 80 + case "sh.tangled.repo.issue.state": { 81 + if (commit.operation !== "create" || !commit.record) return null; 82 + const status = commit.record["status"] as string | undefined; 83 + return status === "closed" ? "issue_closed" : null; 84 + } 85 + case "sh.tangled.repo.pull": 86 + return commit.operation === "create" ? "pr_opened" : null; 87 + default: 88 + return null; 89 + } 90 + } 91 + 92 + function extractTargetInfo(commit: JetstreamCommit): { targetName?: string; targetOwnerDid?: string } { 93 + const record = commit.record; 94 + if (!record) return {}; 95 + 96 + switch (commit.collection) { 97 + case "sh.tangled.repo": { 98 + return { targetName: record["name"] as string | undefined }; 99 + } 100 + case "sh.tangled.feed.star": { 101 + const uri = extractAtUri(record, "subject"); 102 + return { targetName: atUriRkey(uri), targetOwnerDid: atUriDid(uri) }; 103 + } 104 + case "sh.tangled.graph.follow": { 105 + const subject = record["subject"] as string | undefined; 106 + return { targetOwnerDid: subject }; 107 + } 108 + case "sh.tangled.repo.issue": 109 + case "sh.tangled.repo.issue.state": 110 + case "sh.tangled.repo.pull": { 111 + const uri = extractAtUri(record, "subject"); 112 + return { targetName: atUriRkey(uri), targetOwnerDid: atUriDid(uri) }; 113 + } 114 + default: 115 + return {}; 116 + } 117 + } 118 + 119 + /** Returns a short, human-readable identifier from a DID while the handle is being resolved. */ 120 + function placeholderHandle(did: string): string { 121 + const parts = did.split(":"); 122 + const id = parts[2] ?? did; 123 + return id.length > 10 ? `${id.slice(0, 10)}…` : id; 124 + } 125 + 126 + export class JetstreamClient { 127 + private ws: WebSocket | null = null; 128 + private callbacks: JetstreamCallbacks; 129 + private reconnectTimer: ReturnType<typeof setTimeout> | null = null; 130 + private cursor: number | null = null; 131 + private stopped = false; 132 + readonly maxItems: number; 133 + 134 + constructor(callbacks: JetstreamCallbacks, maxItems = MAX_ITEMS) { 135 + this.callbacks = callbacks; 136 + this.maxItems = maxItems; 137 + } 138 + 139 + connect(): void { 140 + this.stopped = false; 141 + this._open(); 142 + } 143 + 144 + disconnect(): void { 145 + this.stopped = true; 146 + this._clearTimer(); 147 + this._close(); 148 + this.callbacks.onDisconnected?.(); 149 + } 150 + 151 + resetCursor(): void { 152 + this.cursor = null; 153 + } 154 + 155 + private _buildUrl(): string { 156 + const params = new URLSearchParams(); 157 + for (const col of WANTED_COLLECTIONS) { 158 + params.append("wantedCollections", col); 159 + } 160 + if (this.cursor !== null) { 161 + params.set("cursor", String(this.cursor)); 162 + } 163 + return `${JETSTREAM_URL}?${params.toString()}`; 164 + } 165 + 166 + private _open(): void { 167 + this._close(); 168 + 169 + let ws: WebSocket; 170 + try { 171 + ws = new WebSocket(this._buildUrl()); 172 + } catch { 173 + if (!this.stopped) this._scheduleReconnect(); 174 + return; 175 + } 176 + 177 + this.ws = ws; 178 + 179 + ws.onopen = () => { 180 + this.callbacks.onConnected?.(); 181 + }; 182 + 183 + ws.onmessage = (ev: MessageEvent) => { 184 + try { 185 + const event = JSON.parse(ev.data as string) as JetstreamEvent; 186 + this.cursor = event.time_us; 187 + 188 + if (event.kind !== "commit" || !event.commit) return; 189 + 190 + const kind = toActivityKind(event.commit); 191 + if (!kind) return; 192 + 193 + const { targetName, targetOwnerDid } = extractTargetInfo(event.commit); 194 + 195 + const item: ActivityItem = { 196 + id: `${event.did}-${event.commit.collection}-${event.commit.rkey}`, 197 + kind, 198 + actorDid: event.did, 199 + actorHandle: placeholderHandle(event.did), 200 + targetUri: 201 + targetOwnerDid && targetName 202 + ? `at://${targetOwnerDid}/${event.commit.collection}/${targetName}` 203 + : undefined, 204 + targetName, 205 + targetOwnerDid, 206 + createdAt: new Date(Math.floor(event.time_us / 1_000)).toISOString(), 207 + }; 208 + 209 + this.callbacks.onEvent(item); 210 + } catch (error) { 211 + console.warn("Failed to parse Jetstream event", { raw: ev.data, error }); 212 + } 213 + }; 214 + 215 + ws.onerror = () => { 216 + this.callbacks.onError?.(); 217 + }; 218 + 219 + ws.onclose = () => { 220 + this.ws = null; 221 + if (!this.stopped) { 222 + this._scheduleReconnect(); 223 + } 224 + }; 225 + } 226 + 227 + private _close(): void { 228 + if (this.ws) { 229 + this.ws.onclose = null; 230 + this.ws.onerror = null; 231 + this.ws.onmessage = null; 232 + this.ws.onopen = null; 233 + this.ws.close(); 234 + this.ws = null; 235 + } 236 + } 237 + 238 + private _clearTimer(): void { 239 + if (this.reconnectTimer !== null) { 240 + clearTimeout(this.reconnectTimer); 241 + this.reconnectTimer = null; 242 + } 243 + } 244 + 245 + private _scheduleReconnect(): void { 246 + this._clearTimer(); 247 + this.callbacks.onDisconnected?.(); 248 + this.reconnectTimer = setTimeout(() => { 249 + if (!this.stopped) this._open(); 250 + }, RECONNECT_DELAY_MS); 251 + } 252 + }
+8 -8
docs/roadmap.md
··· 52 52 53 53 **Depends on:** API: Constellation Integration 54 54 55 - - [ ] Search service pointing at Twister API 56 - - [ ] Constellation service for star/follower counts 57 - - [ ] Debounced search on Explore tab with segmented results 58 - - [ ] Recent search history (local) 59 - - [ ] Graceful fallback when search API unavailable 60 - - [ ] Activity feed data source investigation (Jetstream vs polling) 61 - - [ ] Activity tab with filters, infinite scroll, pull-to-refresh 62 - - [ ] Home tab: surface recently viewed repos/profiles 55 + - [x] Search service pointing at Twister API 56 + - [x] Constellation service for star/follower counts 57 + - [x] Debounced search on Explore tab with segmented results 58 + - [x] Recent search history (local) 59 + - [x] Graceful fallback when search API unavailable 60 + - [x] Activity feed data source investigation (Jetstream vs polling) 61 + - [x] Activity tab with filters, infinite scroll, pull-to-refresh 62 + - [x] Home tab: surface recently viewed repos/profiles 63 63 64 64 ## App: Authentication & Social 65 65
+86 -1
packages/api/internal/store/db.go
··· 50 50 return "libsql", url + "?authToken=" + token 51 51 } 52 52 53 - // Migrate runs all embedded SQL migration files in order. 53 + // Migrate runs all embedded SQL migration files in order, skipping any that 54 + // have already been applied. Applied filenames are recorded in the 55 + // schema_migrations table so re-runs are idempotent. 54 56 func Migrate(db *sql.DB, url string) error { 57 + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( 58 + filename TEXT PRIMARY KEY, 59 + applied_at TEXT NOT NULL 60 + )`); err != nil { 61 + return fmt.Errorf("create schema_migrations table: %w", err) 62 + } 63 + 64 + // For databases that were created before migration tracking was added, 65 + // backfill schema_migrations by introspecting which tables/columns exist. 66 + if err := backfillMigrationHistory(db); err != nil { 67 + return fmt.Errorf("backfill migration history: %w", err) 68 + } 69 + 55 70 mode := migrationMode{ 56 71 allowTursoExtensionSkip: strings.HasPrefix(url, "file:"), 57 72 targetDescription: migrationTargetDescription(url), ··· 67 82 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { 68 83 continue 69 84 } 85 + var already int 86 + _ = db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE filename = ?`, entry.Name()).Scan(&already) 87 + if already > 0 { 88 + slog.Debug("migration already applied, skipping", "file", entry.Name()) 89 + continue 90 + } 70 91 data, err := migrationsFS.ReadFile("migrations/" + entry.Name()) 71 92 if err != nil { 72 93 return fmt.Errorf("read migration %s: %w", entry.Name(), err) ··· 74 95 if err := execMigration(db, entry.Name(), string(data), mode); err != nil { 75 96 return err 76 97 } 98 + if _, err := db.Exec( 99 + `INSERT INTO schema_migrations (filename, applied_at) VALUES (?, datetime('now'))`, 100 + entry.Name(), 101 + ); err != nil { 102 + return fmt.Errorf("record migration %s: %w", entry.Name(), err) 103 + } 77 104 slog.Info("migration applied", "file", entry.Name()) 78 105 } 79 106 return nil 107 + } 108 + 109 + // backfillMigrationHistory records already-applied migrations for databases 110 + // that pre-date the schema_migrations tracking table. It is a no-op if the 111 + // table already has any entries (i.e. tracking was already in place). 112 + func backfillMigrationHistory(db *sql.DB) error { 113 + var count int 114 + if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&count); err != nil || count > 0 { 115 + return nil 116 + } 117 + 118 + // If the documents table does not exist yet this is a fresh database — nothing to backfill. 119 + if !sqliteTableExists(db, "documents") { 120 + return nil 121 + } 122 + 123 + mark := func(filename string) { 124 + _, _ = db.Exec( 125 + `INSERT OR IGNORE INTO schema_migrations (filename, applied_at) VALUES (?, datetime('now'))`, 126 + filename, 127 + ) 128 + } 129 + 130 + // 001 — documents table is present. 131 + mark("001_initial.sql") 132 + 133 + // 002 — identity_handles table. 134 + if sqliteTableExists(db, "identity_handles") { 135 + mark("002_identity_handles.sql") 136 + } 137 + 138 + // 003 — documents_fts virtual table. 139 + if sqliteTableExists(db, "documents_fts") { 140 + mark("003_documents_fts.sql") 141 + } 142 + 143 + // 004 — web_url column on documents. 144 + if sqliteColumnExists(db, "documents", "web_url") { 145 + mark("004_web_url.sql") 146 + } 147 + 148 + return nil 149 + } 150 + 151 + func sqliteTableExists(db *sql.DB, table string) bool { 152 + var n int 153 + _ = db.QueryRow( 154 + `SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table','view') AND name = ?`, table, 155 + ).Scan(&n) 156 + return n > 0 157 + } 158 + 159 + func sqliteColumnExists(db *sql.DB, table, column string) bool { 160 + var n int 161 + _ = db.QueryRow( 162 + `SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, column, 163 + ).Scan(&n) 164 + return n > 0 80 165 } 81 166 82 167 func execMigration(db *sql.DB, name, content string, mode migrationMode) error {