From f0358dbdfe077008b163b8d85fdef3fd3fa2bf67 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Sat, 9 May 2026 18:45:51 +1200 Subject: [PATCH] Add read-only repository viewer (UI + backend) Implements a read-only repository viewer for remote Gitea repos and local clones. Adds UI/CSS for viewer panels, breadcrumb/branch controls, file table, code/Markdown preview, and readme rendering (frontend/css/components.css, frontend/js/app.js). Extends app state and wiring (state.js, app.js) with viewer actions, branch/content loading, local/remote navigation, and preview helpers (base64 decoding, markdown rendering, syntax highlighting, 256 KB preview limit). Adds Gitea API helpers to fetch repo branches and contents (frontend/js/gitea-api.js) and Tauri JS bindings for local repo operations (tauri-api.js). Implements Rust backend commands to list branches, tree entries, and file contents (with size/binary checks and helper utilities) and wires them into the Tauri command registry (src-tauri/src/lib.rs). Also updates README to mention the new read-only viewer. --- README.md | 1 + frontend/css/components.css | 348 +++++++++++++++++++++ frontend/js/app.js | 581 +++++++++++++++++++++++++++++++++++- frontend/js/gitea-api.js | 51 ++++ frontend/js/state.js | 15 + frontend/js/tauri-api.js | 25 ++ src-tauri/src/lib.rs | 260 ++++++++++++++++ 7 files changed, 1276 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aaa49bb..38dd67c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ It connects to any compatible self-hosted Gitea instance (including Gitpub) with - Dynamic API base URL generation (`/api/v1`) - Connection testing against Gitea API - Repository dashboard with search and mock cards fallback +- Read-only repository viewer for remote Gitea repos and local clones - Local repository open + recent repositories - Rust Git commands: clone, pull, push, status, branch - Settings for git path, clone directory, and theme placeholder diff --git a/frontend/css/components.css b/frontend/css/components.css index 9837f3a..a531d5e 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -416,6 +416,354 @@ cursor: pointer; } +/* ── Repository viewer ────────────────────────────── */ +.viewer-panel { + gap: 0; +} + +/* Top bar: breadcrumb + branch controls */ +.viewer-topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 0 0 14px; +} + +.viewer-crumb-row { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + min-width: 0; +} + +.viewer-crumb-btn { + padding: 0 2px; + border: 0; + background: transparent; + color: var(--accent); + font-size: 16px; + font-weight: 600; +} + +.viewer-crumb-btn:hover { + background: transparent; + border: 0; + text-decoration: underline; + color: var(--accent); +} + +.viewer-crumb-sep { + color: var(--text-muted); + font-size: 16px; + font-weight: 300; + padding: 0 2px; + user-select: none; +} + +.viewer-source-badge { + font-size: 12px; + font-weight: 500; + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-muted); + background: var(--bg-panel-alt); + white-space: nowrap; +} + +.viewer-controls { + gap: 6px; + align-items: center; +} + +/* File table */ +.viewer-table { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 16px; +} + +.viewer-table-header { + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg-panel-alt); + border-bottom: 1px solid var(--border); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.viewer-row { + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + width: 100%; + gap: 8px; + padding: 9px 16px; + border: 0; + border-radius: 0; + border-bottom: 1px solid var(--border); + background: var(--bg-panel); + text-align: left; + color: var(--text-main); + font-size: 14px; + cursor: pointer; + transition: background 0.08s ease; +} + +.viewer-row:last-child { + border-bottom: 0; +} + +.viewer-row:hover { + background: var(--bg-hover); + border-color: var(--border); +} + +.viewer-row.active { + background: rgba(47, 129, 247, 0.07); + border-color: var(--border); +} + +.viewer-row-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.viewer-row-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-dir-name { + color: var(--accent); +} + +.viewer-row-size { + font-size: 12px; + white-space: nowrap; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.viewer-empty-row { + padding: 32px 16px; + text-align: center; + font-size: 13px; +} + +/* File preview panel (below the table) */ +.viewer-file-panel { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 16px; + display: flex; + flex-direction: column; +} + +.viewer-file-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: var(--bg-panel-alt); + border-bottom: 1px solid var(--border); +} + +.viewer-file-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-main); +} + +/* README panel */ +.viewer-readme-panel { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 16px; +} + +.viewer-readme-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--bg-panel-alt); + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 600; + color: var(--text-main); +} + +.viewer-readme-body { + background: var(--bg-panel); +} + +.markdown-body { + padding: 24px; + font-family: inherit; + font-size: 14px; + line-height: 1.75; + word-break: break-word; + color: var(--text-main); + background: var(--bg-panel); +} + +.markdown-body > :first-child { + margin-top: 0; +} + +.markdown-body > :last-child { + margin-bottom: 0; +} + +.markdown-body h1, +.markdown-body h2 { + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.markdown-body h1 { + margin: 0 0 16px; + font-size: 28px; +} + +.markdown-body h2 { + margin: 24px 0 12px; + font-size: 22px; +} + +.markdown-body h3 { + margin: 20px 0 10px; + font-size: 18px; +} + +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin: 16px 0 8px; +} + +.markdown-body p { + margin: 0 0 12px; +} + +.markdown-body ul, +.markdown-body ol { + margin: 0 0 12px; + padding-left: 24px; +} + +.markdown-body blockquote { + margin: 0 0 12px; + padding: 0 12px; + border-left: 3px solid var(--border); + color: var(--text-muted); +} + +.markdown-body hr { + height: 1px; + margin: 24px 0; + border: 0; + background: var(--border); +} + +.markdown-body a { + color: var(--accent); + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body code { + padding: 2px 5px; + border-radius: var(--radius-md); + background: var(--bg-panel-alt); + font-family: ui-monospace, "Cascadia Code", "Fira Code", "Consolas", monospace; + font-size: 85%; +} + +.markdown-body .markdown-code { + margin: 0 0 12px; + padding: 12px; + overflow: auto; + border-radius: var(--radius-md); + background: #010409; +} + +.markdown-body .markdown-code code { + padding: 0; + background: transparent; + font-size: 12px; + line-height: 1.6; +} + +/* Code preview (shared by file panel) */ +.code-preview { + flex: 1; + margin: 0; + padding: 16px; + overflow: auto; + background: #010409; + color: var(--text-main); + font-family: ui-monospace, "Cascadia Code", "Fira Code", "Consolas", monospace; + font-size: 12px; + line-height: 1.7; + white-space: pre; +} + +.syntax-keyword { + color: #ff7b72; +} + +.syntax-key { + color: #79c0ff; +} + +.viewer-error, +.viewer-loading { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px 12px; + font-size: 13px; + margin-bottom: 12px; +} + +.viewer-error { + border-color: rgba(248, 81, 73, 0.35); + color: var(--danger); + background: rgba(248, 81, 73, 0.08); +} + +.viewer-loading { + color: var(--text-muted); + background: var(--bg-app); +} + +.viewer-empty { + align-self: stretch; +} + /* ── Responsive ───────────────────────────────────── */ @media (max-width: 1180px) { .layout { diff --git a/frontend/js/app.js b/frontend/js/app.js index 9bb28e7..a2dc39f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,6 +1,9 @@ -import { fetchCurrentUser, fetchRepositories } from "./gitea-api.js"; +import { fetchCurrentUser, fetchRepoBranches, fetchRepoContents, fetchRepositories } from "./gitea-api.js"; import { getActiveServer, getState, setSettings, updateSettings, addRecentRepo } from "./state.js"; import { + listLocalRepoBranches, + listLocalRepoTree, + readLocalRepoFile, runGitBranch, runGitClone, runGitPull, @@ -25,8 +28,12 @@ let serverTestResult = ""; let settingsNotice = ""; let gitOutput = ""; let activeRightTab = "clone"; // "clone" | "settings" | "servers" -let activeMainTab = "repos"; // "repos" | "local" +let activeMainTab = "repos"; // "repos" | "local" | "viewer" let repoOwnerFilter = "all"; // "all" | "personal" | "orgs" +const maxPreviewBytes = 256 * 1024; + +const FOLDER_ICON = ``; +const FILE_ICON = ``; function uid() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -40,6 +47,216 @@ function escapeHtml(value = "") { .replaceAll('"', """); } +function formatBytes(value) { + if (!Number.isFinite(value)) return ""; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +function parentPath(path = "") { + const parts = path.split("/").filter(Boolean); + parts.pop(); + return parts.join("/"); +} + +function languageForPath(path = "") { + const extension = path.split(".").pop()?.toLowerCase(); + const names = { + js: "JavaScript", + ts: "TypeScript", + tsx: "TypeScript", + jsx: "JavaScript", + rs: "Rust", + json: "JSON", + css: "CSS", + html: "HTML", + htm: "HTML", + md: "Markdown", + toml: "TOML", + yml: "YAML", + yaml: "YAML", + }; + return names[extension] || "Text"; +} + +function highlightCode(content = "", language = "Text") { + let html = escapeHtml(content); + if (["JavaScript", "TypeScript", "Rust"].includes(language)) { + html = html.replace( + /\b(async|await|const|let|var|function|return|if|else|for|while|class|struct|enum|impl|fn|pub|use|mod|match|Ok|Err|true|false|null)\b/g, + '$1' + ); + } else if (language === "JSON") { + html = html.replace(/("[^&]*?")(\s*:)/g, '$1$2'); + } else if (language === "CSS") { + html = html.replace(/([.#]?[a-zA-Z0-9_-]+)(\s*\{)/g, '$1$2'); + } + return html; +} + +function isMarkdownPath(path = "") { + return /\.(md|markdown)$/i.test(path); +} + +function safeMarkdownHref(value = "") { + const href = value.trim(); + const lowerHref = href.toLowerCase(); + if (!href || lowerHref.startsWith("javascript:") || lowerHref.startsWith("data:")) { + return ""; + } + return href; +} + +function renderMarkdownInline(value = "") { + return escapeHtml(value) + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_match, label, href) => { + const safeHref = safeMarkdownHref(href.replaceAll("&", "&")); + return safeHref ? `${label}` : label; + }) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1"); +} + +function renderMarkdown(content = "") { + const lines = content.replace(/\r\n/g, "\n").split("\n"); + const html = []; + let inFence = false; + let fenceLanguage = ""; + let fenceLines = []; + let listType = ""; + let listItems = []; + + const flushList = () => { + if (!listType) return; + html.push(`<${listType}>${listItems.map((item) => `
  • ${renderMarkdownInline(item)}
  • `).join("")}`); + listType = ""; + listItems = []; + }; + + const flushFence = () => { + html.push(`
    ${escapeHtml(fenceLines.join("\n"))}
    `); + inFence = false; + fenceLanguage = ""; + fenceLines = []; + }; + + for (const line of lines) { + const fence = line.match(/^```(\w+)?\s*$/); + if (fence) { + if (inFence) { + flushFence(); + } else { + flushList(); + inFence = true; + fenceLanguage = fence[1] || ""; + fenceLines = []; + } + continue; + } + + if (inFence) { + fenceLines.push(line); + continue; + } + + if (!line.trim()) { + flushList(); + continue; + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/); + if (heading) { + flushList(); + const level = heading[1].length; + html.push(`${renderMarkdownInline(heading[2])}`); + continue; + } + + if (/^\s*[-*_]{3,}\s*$/.test(line)) { + flushList(); + html.push("
    "); + continue; + } + + const quote = line.match(/^>\s?(.*)$/); + if (quote) { + flushList(); + html.push(`
    ${renderMarkdownInline(quote[1])}
    `); + continue; + } + + const unordered = line.match(/^\s*[-*+]\s+(.+)$/); + if (unordered) { + if (listType && listType !== "ul") flushList(); + listType = "ul"; + listItems.push(unordered[1]); + continue; + } + + const ordered = line.match(/^\s*\d+\.\s+(.+)$/); + if (ordered) { + if (listType && listType !== "ol") flushList(); + listType = "ol"; + listItems.push(ordered[1]); + continue; + } + + flushList(); + html.push(`

    ${renderMarkdownInline(line)}

    `); + } + + if (inFence) flushFence(); + flushList(); + return html.join(""); +} + +function decodeBase64Content(content = "") { + const cleaned = content.replace(/\s/g, ""); + const binary = atob(cleaned); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + if (bytes.byteLength > maxPreviewBytes) { + return { content: "", size: bytes.byteLength, isBinary: false, tooLarge: true }; + } + if (bytes.includes(0)) { + return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false }; + } + try { + return { + content: new TextDecoder("utf-8", { fatal: true }).decode(bytes), + size: bytes.byteLength, + isBinary: false, + tooLarge: false, + }; + } catch { + return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false }; + } +} + +function normaliseRemoteEntries(contents) { + const items = Array.isArray(contents) ? contents : [contents]; + return items + .map((item) => ({ + name: item.name, + path: item.path || item.name, + type: item.type === "dir" ? "dir" : "file", + size: item.size, + })) + .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1)); +} + +function normaliseLocalEntries(entries) { + return entries.map((entry) => ({ + name: entry.name, + path: entry.path, + type: entry.entryType === "tree" ? "dir" : "file", + size: entry.size, + })); +} + function serverFormTemplate(server = null) { // Reused in first-launch setup and in Settings server management. const defaults = { @@ -158,6 +375,7 @@ function repoCardTemplate(repo) {
    ${escapeHtml(repo.full_name)}
    ${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}
    +
    @@ -165,6 +383,117 @@ function repoCardTemplate(repo) { `; } +function breadcrumbTemplate(repoName, path = "") { + const parts = path.split("/").filter(Boolean); + const crumbs = [``]; + parts.forEach((part, index) => { + const crumbPath = parts.slice(0, index + 1).join("/"); + crumbs.push(`/`); + }); + return crumbs.join(""); +} + +function filePreviewTemplate(file) { + const language = languageForPath(file.path); + const meta = [language, formatBytes(file.size)].filter(Boolean).join(" · "); + + const header = ` +
    + ${FILE_ICON} ${escapeHtml(file.path.split("/").pop())} + ${escapeHtml(meta)} +
    `; + + if (file.tooLarge) { + return header + `
    File too large to preview
    Preview is limited to ${formatBytes(maxPreviewBytes)}.
    `; + } + if (file.isBinary) { + return header + `
    Binary file
    Binary content cannot be previewed.
    `; + } + if (isMarkdownPath(file.path)) { + return header + `
    ${renderMarkdown(file.content || "")}
    `; + } + return header + `
    ${highlightCode(file.content || "", language)}
    `; +} + +function viewerTemplate() { + const { viewer } = getState(); + if (!viewer.source) { + return ` +
    +

    Repository Viewer

    +
    +
    No repository selected
    +
    Use View on a repository card, or open a local path and click View files.
    +
    +
    + `; + } + + const rows = []; + if (viewer.path) { + rows.push(` + `); + } + viewer.entries.forEach((entry) => { + const active = viewer.selectedFile?.path === entry.path ? " active" : ""; + rows.push(` + `); + }); + if (!rows.length && !viewer.loading) { + rows.push(`
    This folder is empty.
    `); + } + + const showReadme = viewer.readmeFile && !viewer.selectedFile; + + return ` +
    +
    +
    + ${breadcrumbTemplate(viewer.repoName || "repo", viewer.path)} +
    +
    + ${viewer.source === "remote" ? "Remote" : "Local"} + + +
    +
    + + ${viewer.error ? `
    ${escapeHtml(viewer.error)}
    ` : ""} + ${viewer.loading ? `
    Loading…
    ` : ""} + +
    +
    + + Name + Size +
    + ${rows.join("")} +
    + + ${viewer.selectedFile ? `
    ${filePreviewTemplate(viewer.selectedFile)}
    ` : ""} + + ${showReadme ? ` +
    +
    + ${FILE_ICON} + ${escapeHtml(viewer.readmeFile.path.split("/").pop())} +
    +
    ${renderMarkdown(viewer.readmeFile.content || "")}
    +
    ` : ""} +
    + `; +} + function dashboardView() { const state = getState(); const activeServer = getActiveServer(); @@ -207,6 +536,7 @@ function dashboardView() {
    + ${activeMainTab === "repos" ? `` : ""} @@ -251,7 +581,7 @@ function dashboardView() {
    `} `} - ` : ` + ` : activeMainTab === "local" ? `

    Local Repository

    @@ -266,6 +596,7 @@ function dashboardView() { +
    Commit message
    @@ -276,7 +607,7 @@ function dashboardView() { ? `
    ${escapeHtml(gitOutput)}
    ` : `

    Run a git command to see output here.

    `}
    - `} + ` : viewerTemplate()}