diff --git a/package.json b/package.json index fe2dfe8..fb0e0cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qortium-explore", - "version": "1.4.7", + "version": "1.4.8", "private": true, "license": "0BSD", "description": "Browse, search, inspect, and open public Qortium QDN resources.", diff --git a/src/GitRepositoryViewer.tsx b/src/GitRepositoryViewer.tsx index b201c78..fc751ee 100644 --- a/src/GitRepositoryViewer.tsx +++ b/src/GitRepositoryViewer.tsx @@ -2,11 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { classifyContent, ContentPreview } from './contentViewer'; import { createTranslator } from './i18n'; import { qdnRequest } from './qdnRequest'; -import { QdnGitRepositoryReader, type QdnGitCommit } from './qdnGitRepository'; +import { QdnGitRepositoryReader, type QdnGitCommit, type QdnGitOverview } from './qdnGitRepository'; import { resourceFetchRequest } from './resourceFiles'; import type { QdnResource } from './types'; -type RepoState = { phase: 'loading' } | { branch: string; commit: QdnGitCommit; files: string[]; phase: 'ready' }; +type Async = { phase: 'idle' } | { phase: 'loading' } | { message: string; phase: 'error' } | { phase: 'ready'; value: T }; type BlobState = { path: string; phase: 'loading' } | { message: string; path: string; phase: 'error' } | { bytes: Uint8Array; path: string; phase: 'ready' }; function bytesToBase64(bytes: Uint8Array) { @@ -15,15 +15,23 @@ function bytesToBase64(bytes: Uint8Array) { return btoa(binary); } +function commitDate(timestamp: number | null) { + return timestamp === null ? '' : new Date(timestamp * 1_000).toLocaleString(); +} + /** * Read-only viewer for a published Git repository (bare or worktree). All Git * parsing happens in the app from per-file resource fetches; nothing here can - * write. Errors surface through onFallback so the caller can show the raw - * published files instead. + * write. Repository-level errors surface through onFallback so the caller can + * show the raw published files instead. */ export function GitRepositoryViewer({ files, language, onFallback, resource }: { files: string[]; language: unknown; onFallback: (message: string) => void; resource: QdnResource }) { const t = useMemo(() => createTranslator(language), [language]); - const [repo, setRepo] = useState({ phase: 'loading' }); + const [overview, setOverview] = useState>({ phase: 'loading' }); + const [branch, setBranch] = useState(''); + const [history, setHistory] = useState>({ phase: 'idle' }); + const [commitOid, setCommitOid] = useState(''); + const [tree, setTree] = useState>({ phase: 'idle' }); const [blob, setBlob] = useState(null); const blobRequestRef = useRef(0); const readerResult = useMemo(() => { @@ -40,41 +48,77 @@ export function GitRepositoryViewer({ files, language, onFallback, resource }: { if (!reader) { onFallback('error' in readerResult && readerResult.error ? readerResult.error : 'Unable to read the Git repository.'); return; } let active = true; blobRequestRef.current += 1; - setRepo({ phase: 'loading' }); + setOverview({ phase: 'loading' }); + setBranch(''); + setBlob(null); + void reader.getOverview() + .then(value => { if (active) { setOverview({ phase: 'ready', value }); setBranch(value.currentBranch ?? value.branches[0] ?? ''); } }) + .catch(error => { if (active) onFallback(error instanceof Error ? error.message : String(error)); }); + return () => { active = false; }; + }, [onFallback, reader, readerResult]); + + useEffect(() => { + if (!reader || !branch) { setHistory({ phase: branch ? 'loading' : 'idle' }); setCommitOid(''); return; } + let active = true; + blobRequestRef.current += 1; + setHistory({ phase: 'loading' }); + setCommitOid(''); + setBlob(null); + void reader.getHistory(branch) + .then(value => { if (active) { setHistory({ phase: 'ready', value }); setCommitOid(value[0]?.oid ?? ''); } }) + .catch(error => { if (active) onFallback(error instanceof Error ? error.message : String(error)); }); + return () => { active = false; }; + }, [branch, onFallback, reader]); + + useEffect(() => { + if (!reader || !commitOid) { setTree({ phase: 'idle' }); return; } + let active = true; + blobRequestRef.current += 1; + setTree({ phase: 'loading' }); setBlob(null); - void (async () => { - const overview = await reader.getOverview(); - const branch = overview.currentBranch ?? overview.branches[0]; - if (!branch) throw new Error(t('git.empty')); - const [commit] = await reader.getHistory(branch, 1); - if (!commit) throw new Error(t('git.empty')); - const paths = await reader.listFiles(commit.oid); - if (active) setRepo({ branch, commit, files: paths, phase: 'ready' }); - })().catch(error => { if (active) onFallback(error instanceof Error ? error.message : String(error)); }); + void reader.listFiles(commitOid) + .then(value => { if (active) setTree({ phase: 'ready', value }); }) + .catch(error => { if (active) setTree({ message: error instanceof Error ? error.message : String(error), phase: 'error' }); }); return () => { active = false; }; - }, [onFallback, reader, readerResult, t]); + }, [commitOid, reader]); const openPath = async (path: string) => { - if (!reader || repo.phase !== 'ready') return; + if (!reader || !commitOid) return; const requestId = ++blobRequestRef.current; setBlob({ path, phase: 'loading' }); try { - const bytes = await reader.readBlob(repo.commit.oid, path); + const bytes = await reader.readBlob(commitOid, path); if (requestId === blobRequestRef.current) setBlob({ bytes, path, phase: 'ready' }); } catch (error) { if (requestId === blobRequestRef.current) setBlob({ message: error instanceof Error ? error.message : String(error), path, phase: 'error' }); } }; - if (repo.phase !== 'ready') return

{t('loading')}

; + if (overview.phase !== 'ready') return

{t('loading')}

; + const commits = history.phase === 'ready' ? history.value : []; + if (!branch || (history.phase === 'ready' && commits.length === 0)) return

{t('git.empty')}

; const viewed = blob ? { ...resource, path: blob.path } : resource; const kind = blob?.phase === 'ready' ? classifyContent(viewed) : null; return <> -

{t('git.branch')} {repo.branch} · {t('git.commit')} {repo.commit.oid.slice(0, 8)} {repo.commit.summary}

- {blob ? <> - -

{blob.path}

- {blob.phase === 'loading' ?

{t('loading')}

: blob.phase === 'error' ?

{blob.message}

: kind ? : null} - :
{repo.files.map(path => )}
} +

+ + {history.phase === 'ready' ? {commits.length.toLocaleString()} · {t('git.history')} : null} +

+
+ +
+ {blob ? <> + +

{blob.path} @{commitOid.slice(0, 8)}

+ {blob.phase === 'loading' ?

{t('loading')}

: blob.phase === 'error' ?

{blob.message}

: kind ? : null} + : <> +

{t('label.files')} @{commitOid.slice(0, 8)}

+ {tree.phase === 'error' ?

{tree.message}

: tree.phase !== 'ready' ?

{t('loading')}

:
{tree.value.map(path => )}
} + } +
+
; } diff --git a/src/locales/en.ts b/src/locales/en.ts index 870f717..8ed878a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -5,7 +5,7 @@ export const EN_STRINGS = { 'column.size': 'Size', 'column.status': 'Status', 'column.updated': 'Updated', 'empty.resources': 'No resources found.', 'empty.search': 'No matching resources found.', 'error.coreOffline': 'Core is offline or unavailable. Start it, check the connection, then retry.', 'error.load': 'Could not load QDN resources.', 'error.details': 'Could not load resource details.', 'field.query': 'Search QDN', - 'field.service': 'All services', 'git.branch': 'Branch', 'git.commit': 'Latest commit', 'git.empty': 'This Git repository has no commits.', 'git.title': 'Git repository', + 'field.service': 'All services', 'git.branch': 'Branch', 'git.commit': 'Latest commit', 'git.empty': 'This Git repository has no commits.', 'git.history': 'Commits', 'git.title': 'Git repository', 'label.description': 'Description', 'label.details': 'Resource details', 'label.filename': 'Filename', 'label.files': 'Files', 'label.metadata': 'Metadata', 'label.name': 'Name', 'label.nameOwner': 'Current name owner', 'label.properties': 'Properties', 'label.services': 'Services', 'label.status': 'Status', 'label.title': 'Title', 'label.type': 'Type', 'label.unknown': 'Unknown', 'loading': 'Loading…', diff --git a/src/styles.css b/src/styles.css index 21face0..397bc84 100644 --- a/src/styles.css +++ b/src/styles.css @@ -13,3 +13,4 @@ button:hover:not(:disabled) { border-color: var(--brand); } button:disabled { op :root[data-ui='modern'] { --panel: color-mix(in srgb, var(--bg) 18%, #ffffff); } :root[data-theme='dark'][data-ui='modern'] { --panel: #17252b; }:root[data-ui='modern'] .app { max-width: 1320px; }:root[data-ui='modern'] .list, :root[data-ui='modern'] .search, :root[data-ui='modern'] .detail-grid > section { border-radius: 18px; box-shadow: 0 10px 30px #00000012; }:root[data-ui='modern'] .top h1 { letter-spacing: -.04em; font-size: 2rem; } @media (max-width: 720px) { .app { width: min(100% - 1rem, 1200px); padding-top: .75rem; }.search { grid-template-columns: 1fr 1fr; }.search input { grid-column: 1 / -1; }.head, .row { grid-template-columns: minmax(11rem, 1fr) 6rem; }.head > :last-child, .row > :last-child { display: none; }.head.resources, .row.resource { grid-template-columns: 34px minmax(9rem, 1fr) 6rem; }.head.resources > :nth-child(n+4), .row.resource > :nth-child(n+4) { display: none; }.detail-grid { grid-template-columns: 1fr; } } .git-meta { margin: 0 0 .8rem; color: var(--muted); overflow-wrap: anywhere; }.git-path { margin: 0 0 .8rem; overflow-wrap: anywhere; } +.git-body { display: grid; grid-template-columns: minmax(13rem, 1fr) 2fr; gap: 1rem; }.git-body aside h3, .git-path { margin-top: 0; }.git-commit span, .git-commit small, .git-commit code { display: block; }.git-commit small { color: var(--muted); }@media (max-width: 900px) { .git-body { grid-template-columns: 1fr; } }