forked from pdsls.dev/pdsls
this repo has no description

PLC operation logs (#43)

authored by juli.ee and committed by GitHub 43c69f40 6d531fe2

Changed files
+616 -75
src
utils
views
+416
src/utils/plc-logs.ts
··· 1 + // courtesy of the best 🐇 mary 2 + // https://github.com/mary-ext/boat/blob/trunk/src/views/identity/plc-oplogs.tsx 3 + import { IndexedEntry, Service } from "@atcute/did-plc"; 4 + 5 + export type DiffEntry = 6 + | { 7 + type: "identity_created"; 8 + orig: IndexedEntry; 9 + nullified: boolean; 10 + at: string; 11 + rotationKeys: string[]; 12 + verificationMethods: Record<string, string>; 13 + alsoKnownAs: string[]; 14 + services: Record<string, { type: string; endpoint: string }>; 15 + } 16 + | { 17 + type: "identity_tombstoned"; 18 + orig: IndexedEntry; 19 + nullified: boolean; 20 + at: string; 21 + } 22 + | { 23 + type: "rotation_key_added"; 24 + orig: IndexedEntry; 25 + nullified: boolean; 26 + at: string; 27 + rotation_key: string; 28 + } 29 + | { 30 + type: "rotation_key_removed"; 31 + orig: IndexedEntry; 32 + nullified: boolean; 33 + at: string; 34 + rotation_key: string; 35 + } 36 + | { 37 + type: "verification_method_added"; 38 + orig: IndexedEntry; 39 + nullified: boolean; 40 + at: string; 41 + method_id: string; 42 + method_key: string; 43 + } 44 + | { 45 + type: "verification_method_removed"; 46 + orig: IndexedEntry; 47 + nullified: boolean; 48 + at: string; 49 + method_id: string; 50 + method_key: string; 51 + } 52 + | { 53 + type: "verification_method_changed"; 54 + orig: IndexedEntry; 55 + nullified: boolean; 56 + at: string; 57 + method_id: string; 58 + prev_method_key: string; 59 + next_method_key: string; 60 + } 61 + | { 62 + type: "handle_added"; 63 + orig: IndexedEntry; 64 + nullified: boolean; 65 + at: string; 66 + handle: string; 67 + } 68 + | { 69 + type: "handle_removed"; 70 + orig: IndexedEntry; 71 + nullified: boolean; 72 + at: string; 73 + handle: string; 74 + } 75 + | { 76 + type: "handle_changed"; 77 + orig: IndexedEntry; 78 + nullified: boolean; 79 + at: string; 80 + prev_handle: string; 81 + next_handle: string; 82 + } 83 + | { 84 + type: "service_added"; 85 + orig: IndexedEntry; 86 + nullified: boolean; 87 + at: string; 88 + service_id: string; 89 + service_type: string; 90 + service_endpoint: string; 91 + } 92 + | { 93 + type: "service_removed"; 94 + orig: IndexedEntry; 95 + nullified: boolean; 96 + at: string; 97 + service_id: string; 98 + service_type: string; 99 + service_endpoint: string; 100 + } 101 + | { 102 + type: "service_changed"; 103 + orig: IndexedEntry; 104 + nullified: boolean; 105 + at: string; 106 + service_id: string; 107 + prev_service_type: string; 108 + next_service_type: string; 109 + prev_service_endpoint: string; 110 + next_service_endpoint: string; 111 + }; 112 + 113 + export const createOperationHistory = (entries: IndexedEntry[]): DiffEntry[] => { 114 + const history: DiffEntry[] = []; 115 + 116 + for (let idx = 0, len = entries.length; idx < len; idx++) { 117 + const entry = entries[idx]; 118 + const op = entry.operation; 119 + 120 + if (op.type === "create") { 121 + history.push({ 122 + type: "identity_created", 123 + orig: entry, 124 + nullified: entry.nullified, 125 + at: entry.createdAt, 126 + rotationKeys: [op.recoveryKey, op.signingKey], 127 + verificationMethods: { atproto: op.signingKey }, 128 + alsoKnownAs: [`at://${op.handle}`], 129 + services: { 130 + atproto_pds: { 131 + type: "AtprotoPersonalDataServer", 132 + endpoint: op.service, 133 + }, 134 + }, 135 + }); 136 + } else if (op.type === "plc_operation") { 137 + const prevOp = findLastMatching(entries, (entry) => !entry.nullified, idx - 1)?.operation; 138 + 139 + let oldRotationKeys: string[]; 140 + let oldVerificationMethods: Record<string, string>; 141 + let oldAlsoKnownAs: string[]; 142 + let oldServices: Record<string, Service>; 143 + 144 + if (!prevOp) { 145 + history.push({ 146 + type: "identity_created", 147 + orig: entry, 148 + nullified: entry.nullified, 149 + at: entry.createdAt, 150 + rotationKeys: op.rotationKeys, 151 + verificationMethods: op.verificationMethods, 152 + alsoKnownAs: op.alsoKnownAs, 153 + services: op.services, 154 + }); 155 + 156 + continue; 157 + } else if (prevOp.type === "create") { 158 + oldRotationKeys = [prevOp.recoveryKey, prevOp.signingKey]; 159 + oldVerificationMethods = { atproto: prevOp.signingKey }; 160 + oldAlsoKnownAs = [`at://${prevOp.handle}`]; 161 + oldServices = { 162 + atproto_pds: { 163 + type: "AtprotoPersonalDataServer", 164 + endpoint: prevOp.service, 165 + }, 166 + }; 167 + } else if (prevOp.type === "plc_operation") { 168 + oldRotationKeys = prevOp.rotationKeys; 169 + oldVerificationMethods = prevOp.verificationMethods; 170 + oldAlsoKnownAs = prevOp.alsoKnownAs; 171 + oldServices = prevOp.services; 172 + } else { 173 + continue; 174 + } 175 + 176 + // Check for rotation key changes 177 + { 178 + const additions = difference(op.rotationKeys, oldRotationKeys); 179 + const removals = difference(oldRotationKeys, op.rotationKeys); 180 + 181 + for (const key of additions) { 182 + history.push({ 183 + type: "rotation_key_added", 184 + orig: entry, 185 + nullified: entry.nullified, 186 + at: entry.createdAt, 187 + rotation_key: key, 188 + }); 189 + } 190 + 191 + for (const key of removals) { 192 + history.push({ 193 + type: "rotation_key_removed", 194 + orig: entry, 195 + nullified: entry.nullified, 196 + at: entry.createdAt, 197 + rotation_key: key, 198 + }); 199 + } 200 + } 201 + 202 + // Check for verification method changes 203 + { 204 + for (const id in op.verificationMethods) { 205 + if (!(id in oldVerificationMethods)) { 206 + history.push({ 207 + type: "verification_method_added", 208 + orig: entry, 209 + nullified: entry.nullified, 210 + at: entry.createdAt, 211 + method_id: id, 212 + method_key: op.verificationMethods[id], 213 + }); 214 + } else if (op.verificationMethods[id] !== oldVerificationMethods[id]) { 215 + history.push({ 216 + type: "verification_method_changed", 217 + orig: entry, 218 + nullified: entry.nullified, 219 + at: entry.createdAt, 220 + method_id: id, 221 + prev_method_key: oldVerificationMethods[id], 222 + next_method_key: op.verificationMethods[id], 223 + }); 224 + } 225 + } 226 + 227 + for (const id in oldVerificationMethods) { 228 + if (!(id in op.verificationMethods)) { 229 + history.push({ 230 + type: "verification_method_removed", 231 + orig: entry, 232 + nullified: entry.nullified, 233 + at: entry.createdAt, 234 + method_id: id, 235 + method_key: oldVerificationMethods[id], 236 + }); 237 + } 238 + } 239 + } 240 + 241 + // Check for handle changes 242 + if (op.alsoKnownAs.length === 1 && oldAlsoKnownAs.length === 1) { 243 + if (op.alsoKnownAs[0] !== oldAlsoKnownAs[0]) { 244 + history.push({ 245 + type: "handle_changed", 246 + orig: entry, 247 + nullified: entry.nullified, 248 + at: entry.createdAt, 249 + prev_handle: oldAlsoKnownAs[0], 250 + next_handle: op.alsoKnownAs[0], 251 + }); 252 + } 253 + } else { 254 + const additions = difference(op.alsoKnownAs, oldAlsoKnownAs); 255 + const removals = difference(oldAlsoKnownAs, op.alsoKnownAs); 256 + 257 + for (const handle of additions) { 258 + history.push({ 259 + type: "handle_added", 260 + orig: entry, 261 + nullified: entry.nullified, 262 + at: entry.createdAt, 263 + handle: handle, 264 + }); 265 + } 266 + 267 + for (const handle of removals) { 268 + history.push({ 269 + type: "handle_removed", 270 + orig: entry, 271 + nullified: entry.nullified, 272 + at: entry.createdAt, 273 + handle: handle, 274 + }); 275 + } 276 + } 277 + 278 + // Check for service changes 279 + { 280 + for (const id in op.services) { 281 + if (!(id in oldServices)) { 282 + history.push({ 283 + type: "service_added", 284 + orig: entry, 285 + nullified: entry.nullified, 286 + at: entry.createdAt, 287 + service_id: id, 288 + service_type: op.services[id].type, 289 + service_endpoint: op.services[id].endpoint, 290 + }); 291 + } else if (!dequal(op.services[id], oldServices[id])) { 292 + history.push({ 293 + type: "service_changed", 294 + orig: entry, 295 + nullified: entry.nullified, 296 + at: entry.createdAt, 297 + service_id: id, 298 + prev_service_type: oldServices[id].type, 299 + next_service_type: op.services[id].type, 300 + prev_service_endpoint: oldServices[id].endpoint, 301 + next_service_endpoint: op.services[id].endpoint, 302 + }); 303 + } 304 + } 305 + 306 + for (const id in oldServices) { 307 + if (!(id in op.services)) { 308 + history.push({ 309 + type: "service_removed", 310 + orig: entry, 311 + nullified: entry.nullified, 312 + at: entry.createdAt, 313 + service_id: id, 314 + service_type: oldServices[id].type, 315 + service_endpoint: oldServices[id].endpoint, 316 + }); 317 + } 318 + } 319 + } 320 + } else if (op.type === "plc_tombstone") { 321 + history.push({ 322 + type: "identity_tombstoned", 323 + orig: entry, 324 + nullified: entry.nullified, 325 + at: entry.createdAt, 326 + }); 327 + } 328 + } 329 + 330 + return history; 331 + }; 332 + 333 + function findLastMatching<T, S extends T>( 334 + arr: T[], 335 + predicate: (item: T) => item is S, 336 + start?: number, 337 + ): S | undefined; 338 + function findLastMatching<T>( 339 + arr: T[], 340 + predicate: (item: T) => boolean, 341 + start?: number, 342 + ): T | undefined; 343 + function findLastMatching<T>( 344 + arr: T[], 345 + predicate: (item: T) => boolean, 346 + start: number = arr.length - 1, 347 + ): T | undefined { 348 + for (let i = start, v: any; i >= 0; i--) { 349 + if (predicate((v = arr[i]))) { 350 + return v; 351 + } 352 + } 353 + 354 + return undefined; 355 + } 356 + 357 + function difference<T>(a: readonly T[], b: readonly T[]): T[] { 358 + const set = new Set(b); 359 + return a.filter((value) => !set.has(value)); 360 + } 361 + 362 + const dequal = (a: any, b: any): boolean => { 363 + let ctor: any; 364 + let len: number; 365 + 366 + if (a === b) { 367 + return true; 368 + } 369 + 370 + if (a && b && (ctor = a.constructor) === b.constructor) { 371 + if (ctor === Array) { 372 + if ((len = a.length) === b.length) { 373 + while (len--) { 374 + if (!dequal(a[len], b[len])) { 375 + return false; 376 + } 377 + } 378 + } 379 + 380 + return len === -1; 381 + } else if (!ctor || ctor === Object) { 382 + len = 0; 383 + 384 + for (ctor in a) { 385 + len++; 386 + 387 + if (!(ctor in b) || !dequal(a[ctor], b[ctor])) { 388 + return false; 389 + } 390 + } 391 + 392 + return Object.keys(b).length === len; 393 + } 394 + } 395 + 396 + return a !== a && b !== b; 397 + }; 398 + 399 + export const groupBy = <K, T>(items: T[], keyFn: (item: T, index: number) => K): Map<K, T[]> => { 400 + const map = new Map<K, T[]>(); 401 + 402 + for (let idx = 0, len = items.length; idx < len; idx++) { 403 + const val = items[idx]; 404 + const key = keyFn(val, idx); 405 + 406 + const list = map.get(key); 407 + 408 + if (list !== undefined) { 409 + list.push(val); 410 + } else { 411 + map.set(key, [val]); 412 + } 413 + } 414 + 415 + return map; 416 + };
+200 -75
src/views/repo.tsx
··· 16 16 import { BlobView } from "./blob.jsx"; 17 17 import { TextInput } from "../components/text-input.jsx"; 18 18 import Tooltip from "../components/tooltip.jsx"; 19 + import { CompatibleOperationOrTombstone, defs, IndexedEntry } from "@atcute/did-plc"; 20 + import { createOperationHistory, DiffEntry, groupBy } from "../utils/plc-logs.js"; 21 + import { localDateFromTimestamp } from "../utils/date.js"; 19 22 20 23 type Tab = "collections" | "backlinks" | "doc" | "blobs"; 21 24 ··· 28 31 const [nsids, setNsids] = createSignal<Record<string, { hidden: boolean; nsids: string[] }>>(); 29 32 const [tab, setTab] = createSignal<Tab>("collections"); 30 33 const [filter, setFilter] = createSignal<string>(); 34 + const [plcOps, setPlcOps] = 35 + createSignal<[IndexedEntry<CompatibleOperationOrTombstone>, DiffEntry[]][]>(); 36 + const [showPlcLogs, setShowPlcLogs] = createSignal(false); 37 + const [loading, setLoading] = createSignal(false); 31 38 let rpc: Client; 32 39 let pds: string; 33 40 const did = params.repo; ··· 44 51 {props.label} 45 52 </button> 46 53 ); 54 + 55 + const DiffItem = (props: { diff: DiffEntry }) => { 56 + const diff = props.diff; 57 + let title = "Unknown log entry"; 58 + let icon = "i-lucide-circle-help"; 59 + let value = ""; 60 + 61 + if (diff.type === "identity_created") { 62 + icon = "i-lucide-bell"; 63 + title = `Identity created`; 64 + } else if (diff.type === "identity_tombstoned") { 65 + icon = "i-lucide-skull"; 66 + title = `Identity tombstoned`; 67 + } else if (diff.type === "handle_added") { 68 + icon = "i-lucide-at-sign"; 69 + title = "Alias added"; 70 + value = diff.handle; 71 + } else if (diff.type === "handle_changed") { 72 + icon = "i-lucide-at-sign"; 73 + title = "Alias updated"; 74 + value = `${diff.prev_handle} → ${diff.next_handle}`; 75 + } else if (diff.type === "handle_removed") { 76 + icon = "i-lucide-at-sign"; 77 + title = `Alias removed`; 78 + value = diff.handle; 79 + } else if (diff.type === "rotation_key_added") { 80 + icon = "i-lucide-key-round"; 81 + title = `Rotation key added`; 82 + value = diff.rotation_key; 83 + } else if (diff.type === "rotation_key_removed") { 84 + icon = "i-lucide-key-round"; 85 + title = `Rotation key removed`; 86 + value = diff.rotation_key; 87 + } else if (diff.type === "service_added") { 88 + icon = "i-lucide-server"; 89 + title = `Service ${diff.service_id} added`; 90 + value = `${diff.service_endpoint}`; 91 + } else if (diff.type === "service_changed") { 92 + icon = "i-lucide-server"; 93 + title = `Service ${diff.service_id} updated`; 94 + value = `${diff.prev_service_endpoint} → ${diff.next_service_endpoint}`; 95 + } else if (diff.type === "service_removed") { 96 + icon = "i-lucide-server"; 97 + title = `Service ${diff.service_id} removed`; 98 + value = `${diff.service_endpoint}`; 99 + } else if (diff.type === "verification_method_added") { 100 + icon = "i-lucide-shield-check"; 101 + title = `Verification method ${diff.method_id} added`; 102 + value = `${diff.method_key}`; 103 + } else if (diff.type === "verification_method_changed") { 104 + icon = "i-lucide-shield-check"; 105 + title = `Verification method ${diff.method_id} updated`; 106 + value = `${diff.prev_method_key} → ${diff.next_method_key}`; 107 + } else if (diff.type === "verification_method_removed") { 108 + icon = "i-lucide-shield-check"; 109 + title = `Verification method ${diff.method_id} removed`; 110 + value = `${diff.method_key}`; 111 + } 112 + 113 + return ( 114 + <div class="grid grid-cols-[min-content_1fr] items-center"> 115 + <div class={icon + ` mr-1 shrink-0 text-lg`} /> 116 + <p 117 + classList={{ 118 + "font-semibold": true, 119 + "text-gray-500 line-through dark:text-gray-400": diff.orig.nullified, 120 + }} 121 + > 122 + {title} 123 + </p> 124 + <div></div> 125 + {value} 126 + </div> 127 + ); 128 + }; 47 129 48 130 const fetchRepo = async () => { 49 131 pds = await resolvePDS(did); ··· 228 310 <Show when={tab() === "doc"}> 229 311 <Show when={didDoc()}> 230 312 {(didDocument) => ( 231 - <div class="break-anywhere flex flex-col gap-y-1"> 232 - <div class="flex items-center justify-between gap-2"> 313 + <div class="break-anywhere flex flex-col gap-y-2"> 314 + <div class="flex flex-col gap-y-1"> 315 + <div class="flex items-center justify-between gap-2"> 316 + <div> 317 + <span class="font-semibold text-stone-600 dark:text-stone-400">ID </span> 318 + <span>{didDocument().id}</span> 319 + </div> 320 + <Tooltip text="DID Document"> 321 + <a 322 + href={ 323 + did.startsWith("did:plc") ? 324 + `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 325 + : `https://${did.split("did:web:")[1]}/.well-known/did.json` 326 + } 327 + target="_blank" 328 + > 329 + <div class="i-lucide-external-link text-lg" /> 330 + </a> 331 + </Tooltip> 332 + </div> 333 + <div> 334 + <p class="font-semibold text-stone-600 dark:text-stone-400">Identities</p> 335 + <ul class="ml-2"> 336 + <For each={didDocument().alsoKnownAs}>{(alias) => <li>{alias}</li>}</For> 337 + </ul> 338 + </div> 233 339 <div> 234 - <span class="font-semibold text-stone-600 dark:text-stone-400">ID </span> 235 - <span>{didDocument().id}</span> 340 + <p class="font-semibold text-stone-600 dark:text-stone-400">Services</p> 341 + <ul class="ml-2"> 342 + <For each={didDocument().service}> 343 + {(service) => ( 344 + <li class="flex flex-col"> 345 + <span>#{service.id.split("#")[1]}</span> 346 + <a 347 + class="w-fit text-blue-400 hover:underline" 348 + href={service.serviceEndpoint.toString()} 349 + target="_blank" 350 + > 351 + {service.serviceEndpoint.toString()} 352 + </a> 353 + </li> 354 + )} 355 + </For> 356 + </ul> 357 + </div> 358 + <div> 359 + <p class="font-semibold text-stone-600 dark:text-stone-400"> 360 + Verification methods 361 + </p> 362 + <ul class="ml-2"> 363 + <For each={didDocument().verificationMethod}> 364 + {(verif) => ( 365 + <li class="flex flex-col"> 366 + <span>#{verif.id.split("#")[1]}</span> 367 + <span>{verif.publicKeyMultibase}</span> 368 + </li> 369 + )} 370 + </For> 371 + </ul> 236 372 </div> 237 - <Tooltip text="DID Document"> 238 - <a 239 - href={ 240 - did.startsWith("did:plc") ? 241 - `${localStorage.plcDirectory ?? "https://plc.directory"}/${did}` 242 - : `https://${did.split("did:web:")[1]}/.well-known/did.json` 243 - } 244 - target="_blank" 373 + </div> 374 + <div class="flex justify-between"> 375 + <Show when={did.startsWith("did:plc")}> 376 + <div class="flex items-center gap-1"> 377 + <button 378 + type="button" 379 + onclick={async () => { 380 + if (!plcOps()) { 381 + setLoading(true); 382 + const response = await fetch(`https://plc.directory/${did}/log/audit`); 383 + const json = await response.json(); 384 + const logs = defs.indexedEntryLog.parse(json); 385 + const opHistory = createOperationHistory(logs).reverse(); 386 + setPlcOps(Array.from(groupBy(opHistory, (item) => item.orig))); 387 + setLoading(false); 388 + } 389 + 390 + setShowPlcLogs(!showPlcLogs()); 391 + }} 392 + class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100" 393 + > 394 + <div class="i-lucide-logs text-sm" /> 395 + {showPlcLogs() ? "Hide" : "Show"} PLC logs 396 + </button> 397 + <Show when={loading()}> 398 + <div class="i-lucide-loader-circle animate-spin text-xl" /> 399 + </Show> 400 + </div> 401 + </Show> 402 + <Show when={error()?.length === 0 || error() === undefined}> 403 + <div 404 + classList={{ 405 + "flex items-center gap-1": true, 406 + "flex-row-reverse": did.startsWith("did:web"), 407 + }} 245 408 > 246 - <div class="i-lucide-external-link text-lg" /> 247 - </a> 248 - </Tooltip> 409 + <Show when={downloading()}> 410 + <div class="i-lucide-loader-circle animate-spin text-xl" /> 411 + </Show> 412 + <button 413 + type="button" 414 + onclick={() => downloadRepo()} 415 + class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100" 416 + > 417 + <div class="i-lucide-download text-sm" /> 418 + Export Repo 419 + </button> 420 + </div> 421 + </Show> 249 422 </div> 250 - <div> 251 - <p class="font-semibold text-stone-600 dark:text-stone-400">Identities</p> 252 - <ul class="ml-2"> 253 - <For each={didDocument().alsoKnownAs}>{(alias) => <li>{alias}</li>}</For> 254 - </ul> 255 - </div> 256 - <div> 257 - <p class="font-semibold text-stone-600 dark:text-stone-400">Services</p> 258 - <ul class="ml-2"> 259 - <For each={didDocument().service}> 260 - {(service) => ( 261 - <li class="flex flex-col"> 262 - <span>#{service.id.split("#")[1]}</span> 263 - <a 264 - class="w-fit text-blue-400 hover:underline" 265 - href={service.serviceEndpoint.toString()} 266 - target="_blank" 267 - > 268 - {service.serviceEndpoint.toString()} 269 - </a> 270 - </li> 271 - )} 272 - </For> 273 - </ul> 274 - </div> 275 - <div> 276 - <p class="font-semibold text-stone-600 dark:text-stone-400"> 277 - Verification methods 278 - </p> 279 - <ul class="ml-2"> 280 - <For each={didDocument().verificationMethod}> 281 - {(verif) => ( 282 - <li class="flex flex-col"> 283 - <span>#{verif.id.split("#")[1]}</span> 284 - <span>{verif.publicKeyMultibase}</span> 285 - </li> 423 + <Show when={showPlcLogs()}> 424 + <div class="flex flex-col gap-1 text-sm"> 425 + <For each={plcOps()}> 426 + {([entry, diffs]) => ( 427 + <div class="flex flex-col"> 428 + <span class="text-neutral-500 dark:text-neutral-400"> 429 + {localDateFromTimestamp(new Date(entry.createdAt).getTime())} 430 + </span> 431 + {diffs.map((diff) => ( 432 + <DiffItem diff={diff} /> 433 + ))} 434 + </div> 286 435 )} 287 436 </For> 288 - </ul> 289 - </div> 290 - <Show when={did.startsWith("did:plc")}> 291 - <a 292 - class="flex w-fit items-center text-blue-400 hover:underline" 293 - href={`https://boat.kelinci.net/plc-oplogs?q=${did}`} 294 - target="_blank" 295 - > 296 - PLC operation logs <div class="i-lucide-external-link ml-0.5 text-sm" /> 297 - </a> 298 - </Show> 299 - <Show when={error()?.length === 0 || error() === undefined}> 300 - <div class="flex items-center gap-1"> 301 - <button 302 - type="button" 303 - onclick={() => downloadRepo()} 304 - class="dark:hover:bg-dark-100 dark:bg-dark-300 focus:outline-1.5 dark:shadow-dark-900 flex items-center gap-1 rounded-lg bg-white px-2 py-1.5 text-xs font-bold shadow-sm hover:bg-zinc-200/50 focus:outline-slate-900 dark:focus:outline-slate-100" 305 - > 306 - <div class="i-lucide-download text-sm" /> 307 - Export Repo 308 - </button> 309 - <Show when={downloading()}> 310 - <div class="i-lucide-loader-circle animate-spin text-xl" /> 311 - </Show> 312 437 </div> 313 438 </Show> 314 439 </div>