Something went wrong. Try again.
a love letter to tangled (android, iOS, and a search API)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396<template> <ion-page> <page-header :back="repositoryBack" title="Source" /> <ion-content :fullscreen="true"> <main id="page-content" class="source-page page-frame"> <repository-navigation :current-ref="currentRef" :repo="repo" />
<request-state :error="repositoryRequest.error.value" :loading="repositoryRequest.phase.value === 'loading'" noun="repository" @retry="repositoryRequest.retry" />
<template v-if="repository"> <header class="source-page__header"> <div> <p class="section-label">{{ repository.value.name || 'Repository' }}</p> <h1>{{ path ? fileName : 'Source' }}</h1> </div> <label class="ref-picker"> <span>Branch or tag</span> <select :value="currentRef" @change="selectRef"> <option v-if="currentRef" :value="currentRef">{{ currentRef }}</option> <optgroup v-if="branches.length" label="Branches"> <option v-for="branch in branches" :key="`branch-${branch.name}`" :value="branch.name"> {{ branch.name }} </option> </optgroup> <optgroup v-if="tags.length" label="Tags"> <option v-for="tag in tags" :key="`tag-${tag.name}`" :value="tag.name">{{ tag.name }}</option> </optgroup> </select> </label> </header>
<nav class="breadcrumbs" aria-label="Source path"> <router-link :to="links.source(repo, currentRef)">Root</router-link> <template v-for="crumb in breadcrumbs" :key="crumb.path"> <span aria-hidden="true">/</span> <router-link :to="links.source(repo, currentRef, crumb.path, crumb.isFile ? 'blob' : undefined)"> {{ crumb.name }} </router-link> </template> </nav>
<request-state :error="contentRequest.error.value" :loading="contentRequest.phase.value === 'loading'" noun="source" @retry="contentRequest.retry" />
<section v-if="content?.kind === 'tree'" class="source-panel" aria-label="Directory contents"> <ul v-if="content.data.files.length" class="tree-list"> <li v-for="entry in sortedFiles" :key="entry.name"> <span aria-hidden="true">{{ isDirectory(entry.mode) ? '↳' : '·' }}</span> <router-link :to=" links.source(repo, currentRef, childPath(entry.name), isDirectory(entry.mode) ? undefined : 'blob') "> {{ entry.name }} </router-link> <span>{{ isDirectory(entry.mode) ? 'Directory' : formatBytes(entry.size) }}</span> </li> </ul> <div v-else class="source-empty"> <strong>This directory is empty</strong> <p>There are no files at this path and ref.</p> </div> </section>
<section v-else-if="content?.kind === 'blob'" class="source-panel source-panel--blob"> <header class="blob-toolbar"> <div> <strong>{{ content.data.path }}</strong> <span v-if="content.data.size !== undefined">{{ formatBytes(content.data.size) }}</span> </div> <div> <button v-if="blobText.kind === 'text'" type="button" @click="wrapLines = !wrapLines"> {{ wrapLines ? 'Don’t wrap lines' : 'Wrap lines' }} </button> <a :href="rawUrl" download>Download</a> <a :href="canonicalUrl" rel="noopener noreferrer" target="_blank">Open on Tangled</a> </div> </header>
<div v-if="content.data.submodule" class="source-empty"> <strong>{{ content.data.submodule.name }}</strong> <p>This path points to another repository.</p> <a v-if="submoduleUrl" :href="submoduleUrl" rel="noopener noreferrer" target="_blank">Open repository</a> </div> <ol v-else-if="blobText.kind === 'text'" :class="['code-lines', { 'code-lines--wrap': wrapLines }]"> <li v-for="(line, index) in blobText.lines" :id="`L${index + 1}`" :key="index"> <a :href="`#L${index + 1}`" :aria-label="`Line ${index + 1}`">{{ index + 1 }}</a> <code>{{ line || ' ' }}</code> </li> </ol> <div v-else class="source-empty"> <strong>{{ downloadMessage }}</strong> <p>Download the file to view it with an app on your device.</p> </div> </section> </template> </main> </ion-content> </ion-page></template>
<script setup lang="ts">import PageHeader from '@/components/PageHeader.vue'import RequestState from '@/components/RequestState.vue'import { presentBlobText } from '@/features/repositories/blobText'import RepositoryNavigation from '@/features/repositories/RepositoryNavigation.vue'import { useRepositoryRoute } from '@/features/repositories/useRepositoryRoute'import { useRouteRequest } from '@/lib/requests'import { links } from '@/lib/router/links'import { IonContent, IonPage } from '@ionic/vue'import { computed, ref } from 'vue'import { useRouter } from 'vue-router'
const { getClient, repo, repoDid, repoUri, repository, repositoryRequest, route } = useRepositoryRoute()const router = useRouter()const path = computed(() => String(route.query.path ?? '').replace(/^\/+|\/+$/g, ''))const requestedRef = computed(() => String(route.query.ref ?? ''))const requestedView = computed(() => (route.query.view === 'blob' ? 'blob' : 'tree'))const repositoryBack = computed(() => router.resolve(links.repository(repo.value)).href)const defaultBranchRequest = useRouteRequest(repoUri, (uri, signal, attempt) => uri ? getClient().getRepositoryDefaultBranch(uri, { signal, cache: attempt.cache }) : Promise.resolve(undefined),)const currentRef = computed(() => requestedRef.value || defaultBranchRequest.data.value?.name || 'HEAD')const branchesRequest = useRouteRequest(repoUri, (uri, signal, attempt) => uri ? getClient().listRepositoryBranches(uri, { signal, cache: attempt.cache, limit: 99 }) : Promise.resolve({ items: [] }),)const tagsRequest = useRouteRequest(repoUri, (uri, signal, attempt) => uri ? getClient().listRepositoryTags(uri, { signal, cache: attempt.cache, limit: 99 }) : Promise.resolve({ items: [] }),)const branches = computed(() => branchesRequest.data.value?.items ?? [])const tags = computed(() => tagsRequest.data.value?.items ?? [])const contentSource = computed(() => ({ uri: repoUri.value, path: path.value, ref: currentRef.value, view: requestedView.value,}))const contentRequest = useRouteRequest(contentSource, async (source, signal, attempt) => { if (!source.uri) return undefined if (source.view === 'blob') { return { kind: 'blob' as const, data: await getClient().getRepositoryBlob(source.uri, source.ref, source.path, { signal, cache: attempt.cache }), } } const tree = await getClient().getRepositoryTree( source.uri, { path: source.path, ref: source.ref }, { signal, cache: attempt.cache }, ) if (source.path && tree.files.length === 0 && tree.parent === source.path) { return { kind: 'blob' as const, data: await getClient().getRepositoryBlob(source.uri, source.ref, source.path, { signal, cache: attempt.cache }), } } return { kind: 'tree' as const, data: tree }})const content = computed(() => contentRequest.data.value)const sortedFiles = computed(() => content.value?.kind === 'tree' ? [...content.value.data.files].sort( (a, b) => Number(isDirectory(b.mode)) - Number(isDirectory(a.mode)) || a.name.localeCompare(b.name), ) : [],)const blobText = computed(() => content.value?.kind === 'blob' ? presentBlobText(content.value.data) : { kind: 'download' as const, reason: 'unavailable' as const },)const wrapLines = ref(false)const fileName = computed(() => path.value.split('/').at(-1) || 'Source')const breadcrumbs = computed(() => { const parts = path.value.split('/').filter(Boolean) return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join('/'), isFile: index === parts.length - 1 && content.value?.kind === 'blob', }))})const rawUrl = computed(() => getClient().repositoryBlobUrl(repoUri.value, currentRef.value, path.value))const canonicalUrl = computed(() => { const url = new URL(`https://tangled.org/${repoDid.value}`) url.pathname += `/tree/${encodeURIComponent(currentRef.value)}/${path.value.split('/').map(encodeURIComponent).join('/')}` return url.href})const submoduleUrl = computed(() => safeUrl(content.value?.kind === 'blob' ? content.value.data.submodule?.url : undefined),)const downloadMessage = computed(() => blobText.value.kind === 'text' ? '' : blobText.value.reason === 'binary' ? 'This file cannot be shown as text' : blobText.value.reason === 'large' ? 'This file is too large to display here' : 'A preview is not available',)
function childPath(name: string) { return path.value ? `${path.value}/${name}` : name}function isDirectory(mode: string) { return mode === '040000' || mode === '40000' || mode.startsWith('04')}function formatBytes(bytes: number) { return bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`}function selectRef(event: Event) { const refValue = (event.target as HTMLSelectElement).value void router.replace( links.source(repo.value, refValue, path.value || undefined, requestedView.value === 'blob' ? 'blob' : undefined), )}function safeUrl(value: string | undefined) { if (!value) return undefined try { const url = new URL(value) return url.protocol === 'https:' ? url.href : undefined } catch { return undefined }}</script>
<style scoped>.source-page { display: grid; gap: var(--space-6);}.source-page__header { display: flex; align-items: end; justify-content: space-between; gap: var(--space-5);}.source-page__header h1 { margin: 0; overflow-wrap: anywhere;}.ref-picker { display: grid; gap: var(--space-2); color: var(--app-text-muted); font-size: var(--text-sm); font-weight: 700;}.ref-picker select { min-block-size: 44px; min-inline-size: min(18rem, 70vw); border: 1px solid var(--app-border); border-radius: var(--radius-sm); padding-inline: var(--space-3); color: var(--app-text); background: var(--app-surface);}.breadcrumbs { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); min-block-size: 44px;}.breadcrumbs a { overflow-wrap: anywhere;}.source-panel { overflow: hidden; border: 1px solid var(--app-border); border-radius: var(--radius-md); background: var(--app-surface);}.tree-list { margin: 0; padding: 0; list-style: none;}.tree-list li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); min-block-size: 52px; border-block-end: 1px solid var(--app-border); padding: var(--space-2) var(--space-4);}.tree-list li:last-child { border-block-end: 0;}.tree-list li > span:last-child { color: var(--app-text-muted); font-size: var(--text-sm);}.blob-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); border-block-end: 1px solid var(--app-border); padding: var(--space-3) var(--space-4);}.blob-toolbar > div { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3);}.blob-toolbar span { color: var(--app-text-muted);}.blob-toolbar button,.blob-toolbar a { min-block-size: 44px; padding: var(--space-3);}.blob-toolbar button { border: 1px solid var(--app-border); border-radius: var(--radius-sm); color: var(--app-text); background: transparent; cursor: pointer;}.code-lines { margin: 0; padding: var(--space-3) 0; overflow-x: auto; counter-reset: line; background: var(--app-background); list-style: none;}.code-lines li { display: grid; grid-template-columns: 4rem minmax(max-content, 1fr); min-block-size: 1.55rem; padding-inline-end: var(--space-4);}.code-lines li:target { background: color-mix(in srgb, var(--app-accent) 14%, transparent);}.code-lines li > a { padding-inline: var(--space-3); color: var(--app-text-muted); text-align: end; text-decoration: none; user-select: none;}.code-lines code { white-space: pre;}.code-lines--wrap li { grid-template-columns: 4rem minmax(0, 1fr);}.code-lines--wrap code { overflow-wrap: anywhere; white-space: pre-wrap;}.source-empty { padding: var(--space-6);}.source-empty p { margin: var(--space-2) 0 var(--space-4); color: var(--app-text-muted);}@media (max-width: 640px) { .source-page__header, .blob-toolbar { align-items: stretch; flex-direction: column; } .ref-picker select { inline-size: 100%; } .tree-list li { grid-template-columns: auto minmax(0, 1fr); } .tree-list li > span:last-child { grid-column: 2; }}</style>