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.
This commit is contained in:
@@ -26,6 +26,7 @@ It connects to any compatible self-hosted Gitea instance (including Gitpub) with
|
||||
- Dynamic API base URL generation (`<server>/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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+576
-5
@@ -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 = `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="#54aeff" d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>`;
|
||||
const FILE_ICON = `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="#848d97" d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>`;
|
||||
|
||||
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,
|
||||
'<span class="syntax-keyword">$1</span>'
|
||||
);
|
||||
} else if (language === "JSON") {
|
||||
html = html.replace(/("[^&]*?")(\s*:)/g, '<span class="syntax-key">$1</span>$2');
|
||||
} else if (language === "CSS") {
|
||||
html = html.replace(/([.#]?[a-zA-Z0-9_-]+)(\s*\{)/g, '<span class="syntax-key">$1</span>$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, "<code>$1</code>")
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_match, label, href) => {
|
||||
const safeHref = safeMarkdownHref(href.replaceAll("&", "&"));
|
||||
return safeHref ? `<a href="${escapeHtml(safeHref)}">${label}</a>` : label;
|
||||
})
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||||
}
|
||||
|
||||
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) => `<li>${renderMarkdownInline(item)}</li>`).join("")}</${listType}>`);
|
||||
listType = "";
|
||||
listItems = [];
|
||||
};
|
||||
|
||||
const flushFence = () => {
|
||||
html.push(`<pre class="markdown-code" data-language="${escapeHtml(fenceLanguage)}"><code>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
||||
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(`<h${level}>${renderMarkdownInline(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*_]{3,}\s*$/.test(line)) {
|
||||
flushList();
|
||||
html.push("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = line.match(/^>\s?(.*)$/);
|
||||
if (quote) {
|
||||
flushList();
|
||||
html.push(`<blockquote>${renderMarkdownInline(quote[1])}</blockquote>`);
|
||||
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(`<p>${renderMarkdownInline(line)}</p>`);
|
||||
}
|
||||
|
||||
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) {
|
||||
<div><strong>${escapeHtml(repo.full_name)}</strong></div>
|
||||
<div class="muted">${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}</div>
|
||||
<div class="row">
|
||||
<button class="view-repo-btn primary-blue" data-repo-name="${escapeHtml(repo.full_name)}" type="button">View</button>
|
||||
<button class="clone-repo-btn" data-clone-url="${escapeHtml(repo.clone_url || "")}" type="button">Clone</button>
|
||||
<button class="open-repo-btn" data-repo-name="${escapeHtml(repo.full_name)}" type="button">Open</button>
|
||||
</div>
|
||||
@@ -165,6 +383,117 @@ function repoCardTemplate(repo) {
|
||||
`;
|
||||
}
|
||||
|
||||
function breadcrumbTemplate(repoName, path = "") {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
const crumbs = [`<button class="viewer-crumb-btn" data-viewer-path="" type="button">${escapeHtml(repoName)}</button>`];
|
||||
parts.forEach((part, index) => {
|
||||
const crumbPath = parts.slice(0, index + 1).join("/");
|
||||
crumbs.push(`<span class="viewer-crumb-sep">/</span><button class="viewer-crumb-btn" data-viewer-path="${escapeHtml(crumbPath)}" type="button">${escapeHtml(part)}</button>`);
|
||||
});
|
||||
return crumbs.join("");
|
||||
}
|
||||
|
||||
function filePreviewTemplate(file) {
|
||||
const language = languageForPath(file.path);
|
||||
const meta = [language, formatBytes(file.size)].filter(Boolean).join(" · ");
|
||||
|
||||
const header = `
|
||||
<div class="viewer-file-panel-header">
|
||||
<span class="viewer-file-name">${FILE_ICON} ${escapeHtml(file.path.split("/").pop())}</span>
|
||||
<span class="muted">${escapeHtml(meta)}</span>
|
||||
</div>`;
|
||||
|
||||
if (file.tooLarge) {
|
||||
return header + `<div class="empty-state viewer-empty"><div>File too large to preview</div><div class="muted">Preview is limited to ${formatBytes(maxPreviewBytes)}.</div></div>`;
|
||||
}
|
||||
if (file.isBinary) {
|
||||
return header + `<div class="empty-state viewer-empty"><div>Binary file</div><div class="muted">Binary content cannot be previewed.</div></div>`;
|
||||
}
|
||||
if (isMarkdownPath(file.path)) {
|
||||
return header + `<div class="markdown-body">${renderMarkdown(file.content || "")}</div>`;
|
||||
}
|
||||
return header + `<pre class="code-preview" data-language="${escapeHtml(language)}"><code>${highlightCode(file.content || "", language)}</code></pre>`;
|
||||
}
|
||||
|
||||
function viewerTemplate() {
|
||||
const { viewer } = getState();
|
||||
if (!viewer.source) {
|
||||
return `
|
||||
<section class="panel stack">
|
||||
<h3 class="title">Repository Viewer</h3>
|
||||
<div class="empty-state viewer-empty">
|
||||
<div>No repository selected</div>
|
||||
<div class="muted">Use View on a repository card, or open a local path and click View files.</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
if (viewer.path) {
|
||||
rows.push(`
|
||||
<button class="viewer-row" data-entry-type="dir" data-entry-path="${escapeHtml(parentPath(viewer.path))}" type="button">
|
||||
<span class="viewer-row-icon">${FOLDER_ICON}</span>
|
||||
<span class="viewer-row-name">..</span>
|
||||
<span class="viewer-row-size"></span>
|
||||
</button>`);
|
||||
}
|
||||
viewer.entries.forEach((entry) => {
|
||||
const active = viewer.selectedFile?.path === entry.path ? " active" : "";
|
||||
rows.push(`
|
||||
<button class="viewer-row${active}" data-entry-type="${entry.type}" data-entry-path="${escapeHtml(entry.path)}" type="button">
|
||||
<span class="viewer-row-icon">${entry.type === "dir" ? FOLDER_ICON : FILE_ICON}</span>
|
||||
<span class="viewer-row-name ${entry.type === "dir" ? "viewer-dir-name" : ""}">${escapeHtml(entry.name)}</span>
|
||||
<span class="viewer-row-size muted">${entry.type === "file" ? formatBytes(entry.size) : ""}</span>
|
||||
</button>`);
|
||||
});
|
||||
if (!rows.length && !viewer.loading) {
|
||||
rows.push(`<div class="viewer-empty-row"><span class="muted">This folder is empty.</span></div>`);
|
||||
}
|
||||
|
||||
const showReadme = viewer.readmeFile && !viewer.selectedFile;
|
||||
|
||||
return `
|
||||
<section class="panel stack viewer-panel">
|
||||
<div class="viewer-topbar">
|
||||
<div class="viewer-crumb-row">
|
||||
${breadcrumbTemplate(viewer.repoName || "repo", viewer.path)}
|
||||
</div>
|
||||
<div class="row viewer-controls">
|
||||
<span class="viewer-source-badge">${viewer.source === "remote" ? "Remote" : "Local"}</span>
|
||||
<select id="viewer-branch-select" ${viewer.loading ? "disabled" : ""}>
|
||||
${viewer.branches.map((b) => `<option value="${escapeHtml(b.name)}" ${b.name === viewer.branch ? "selected" : ""}>${escapeHtml(b.name)}${b.current ? " ✓" : ""}</option>`).join("")}
|
||||
</select>
|
||||
<button id="viewer-refresh-btn" type="button">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${viewer.error ? `<div class="viewer-error">${escapeHtml(viewer.error)}</div>` : ""}
|
||||
${viewer.loading ? `<div class="viewer-loading">Loading…</div>` : ""}
|
||||
|
||||
<div class="viewer-table">
|
||||
<div class="viewer-table-header">
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
</div>
|
||||
${rows.join("")}
|
||||
</div>
|
||||
|
||||
${viewer.selectedFile ? `<div class="viewer-file-panel">${filePreviewTemplate(viewer.selectedFile)}</div>` : ""}
|
||||
|
||||
${showReadme ? `
|
||||
<div class="viewer-readme-panel">
|
||||
<div class="viewer-readme-header">
|
||||
${FILE_ICON}
|
||||
<span>${escapeHtml(viewer.readmeFile.path.split("/").pop())}</span>
|
||||
</div>
|
||||
<div class="viewer-readme-body markdown-body">${renderMarkdown(viewer.readmeFile.content || "")}</div>
|
||||
</div>` : ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function dashboardView() {
|
||||
const state = getState();
|
||||
const activeServer = getActiveServer();
|
||||
@@ -207,6 +536,7 @@ function dashboardView() {
|
||||
<div class="tabs main-tabs">
|
||||
<button class="tab-btn ${activeMainTab === "repos" ? "active" : ""}" data-main-tab="repos">Repositories</button>
|
||||
<button class="tab-btn ${activeMainTab === "local" ? "active" : ""}" data-main-tab="local">Local Repo</button>
|
||||
<button class="tab-btn ${activeMainTab === "viewer" ? "active" : ""}" data-main-tab="viewer">Viewer</button>
|
||||
${activeMainTab === "repos"
|
||||
? `<span class="tab-spacer"></span><input id="repo-search-input" class="tab-search" placeholder="Search…" value="${escapeHtml(state.repoSearch)}" />`
|
||||
: ""}
|
||||
@@ -251,7 +581,7 @@ function dashboardView() {
|
||||
</div>`}
|
||||
</div>`}
|
||||
</section>
|
||||
` : `
|
||||
` : activeMainTab === "local" ? `
|
||||
<section class="panel stack">
|
||||
<h3 class="title">Local Repository</h3>
|
||||
<div class="row">
|
||||
@@ -266,6 +596,7 @@ function dashboardView() {
|
||||
<button id="git-branch-btn" type="button">Branch</button>
|
||||
<button id="git-pull-btn" type="button">Pull</button>
|
||||
<button id="git-push-btn" type="button">Push</button>
|
||||
<button id="view-local-repo-btn" class="primary-blue" type="button">View files</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">Commit message</div>
|
||||
@@ -276,7 +607,7 @@ function dashboardView() {
|
||||
? `<pre class="git-output">${escapeHtml(gitOutput)}</pre>`
|
||||
: `<p class="muted git-output-placeholder">Run a git command to see output here.</p>`}
|
||||
</section>
|
||||
`}
|
||||
` : viewerTemplate()}
|
||||
</main>
|
||||
|
||||
<aside class="rightbar stack">
|
||||
@@ -446,6 +777,202 @@ async function loadRepositories() {
|
||||
}
|
||||
}
|
||||
|
||||
async function autoLoadReadme() {
|
||||
const state = getState();
|
||||
const viewer = state.viewer;
|
||||
const entry = viewer.entries.find(
|
||||
(e) => e.type === "file" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name)
|
||||
);
|
||||
viewer.readmeFile = null;
|
||||
if (!entry) return;
|
||||
|
||||
try {
|
||||
if (viewer.source === "remote") {
|
||||
const activeServer = getActiveServer();
|
||||
const file = await fetchRepoContents(activeServer, viewer.repoName, entry.path, viewer.branch);
|
||||
const decoded = file.encoding === "base64"
|
||||
? decodeBase64Content(file.content || "")
|
||||
: { content: file.content || "", isBinary: false, tooLarge: false };
|
||||
if (!decoded.isBinary && !decoded.tooLarge) {
|
||||
viewer.readmeFile = { path: entry.path, content: decoded.content };
|
||||
}
|
||||
} else {
|
||||
const file = await readLocalRepoFile(
|
||||
viewer.repoPath, viewer.branch, entry.path, state.settings.gitExecutablePath
|
||||
);
|
||||
if (!file.isBinary && !file.tooLarge) {
|
||||
viewer.readmeFile = { path: entry.path, content: file.content || "" };
|
||||
}
|
||||
}
|
||||
render();
|
||||
} catch {
|
||||
// README load failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
async function loadViewerPath(path = "") {
|
||||
const state = getState();
|
||||
const viewer = state.viewer;
|
||||
if (!viewer.source) return;
|
||||
|
||||
viewer.path = path;
|
||||
viewer.loading = true;
|
||||
viewer.error = "";
|
||||
viewer.entries = [];
|
||||
viewer.selectedFile = null;
|
||||
viewer.readmeFile = null;
|
||||
render();
|
||||
|
||||
try {
|
||||
if (viewer.source === "remote") {
|
||||
const activeServer = getActiveServer();
|
||||
if (!activeServer) throw new Error("No active server is configured.");
|
||||
const contents = await fetchRepoContents(activeServer, viewer.repoName, path, viewer.branch);
|
||||
viewer.entries = normaliseRemoteEntries(contents).filter((entry) => entry.type === "dir" || entry.path !== path);
|
||||
} else {
|
||||
const entries = await listLocalRepoTree(
|
||||
viewer.repoPath,
|
||||
viewer.branch,
|
||||
path,
|
||||
state.settings.gitExecutablePath
|
||||
);
|
||||
viewer.entries = normaliseLocalEntries(entries);
|
||||
}
|
||||
viewer.loading = false;
|
||||
render();
|
||||
await autoLoadReadme();
|
||||
} catch (error) {
|
||||
viewer.error = `Unable to load repository contents: ${error.message}`;
|
||||
viewer.loading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function openViewerFile(path) {
|
||||
const state = getState();
|
||||
const viewer = state.viewer;
|
||||
if (!viewer.source || !path) return;
|
||||
|
||||
viewer.loading = true;
|
||||
viewer.error = "";
|
||||
viewer.selectedFile = null;
|
||||
render();
|
||||
|
||||
try {
|
||||
if (viewer.source === "remote") {
|
||||
const activeServer = getActiveServer();
|
||||
if (!activeServer) throw new Error("No active server is configured.");
|
||||
const file = await fetchRepoContents(activeServer, viewer.repoName, path, viewer.branch);
|
||||
const decoded = file.encoding === "base64"
|
||||
? decodeBase64Content(file.content || "")
|
||||
: { content: file.content || "", size: file.size || 0, isBinary: false, tooLarge: false };
|
||||
viewer.selectedFile = {
|
||||
path: file.path || path,
|
||||
size: file.size || decoded.size,
|
||||
content: decoded.content,
|
||||
isBinary: decoded.isBinary,
|
||||
tooLarge: decoded.tooLarge,
|
||||
};
|
||||
} else {
|
||||
const file = await readLocalRepoFile(
|
||||
viewer.repoPath,
|
||||
viewer.branch,
|
||||
path,
|
||||
state.settings.gitExecutablePath
|
||||
);
|
||||
viewer.selectedFile = {
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
content: file.content || "",
|
||||
isBinary: file.isBinary,
|
||||
tooLarge: file.tooLarge,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
viewer.error = `Unable to preview file: ${error.message}`;
|
||||
} finally {
|
||||
viewer.loading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function openRemoteViewer(repo) {
|
||||
const state = getState();
|
||||
const viewer = state.viewer;
|
||||
Object.assign(viewer, {
|
||||
source: "remote",
|
||||
repoName: repo.full_name,
|
||||
repoPath: "",
|
||||
cloneUrl: repo.clone_url || "",
|
||||
defaultBranch: repo.default_branch || "",
|
||||
branch: "",
|
||||
branches: [],
|
||||
path: "",
|
||||
entries: [],
|
||||
selectedFile: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
});
|
||||
activeMainTab = "viewer";
|
||||
render();
|
||||
|
||||
try {
|
||||
const activeServer = getActiveServer();
|
||||
if (!activeServer) throw new Error("No active server is configured.");
|
||||
const branches = await fetchRepoBranches(activeServer, repo.full_name);
|
||||
const branchNames = branches.map((branch) => branch.name).filter(Boolean);
|
||||
const selectedBranch = repo.default_branch || branchNames[0] || "";
|
||||
viewer.branches = branchNames.map((name) => ({ name, current: name === selectedBranch }));
|
||||
viewer.branch = selectedBranch;
|
||||
await loadViewerPath("");
|
||||
} catch (error) {
|
||||
viewer.loading = false;
|
||||
viewer.error = `Unable to open repository viewer: ${error.message}`;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function openLocalViewer() {
|
||||
const state = getState();
|
||||
const value = state.selectedRepoPath || state.localRepoPathInput;
|
||||
if (!value) {
|
||||
gitOutput = "Select or enter a local repository path first.";
|
||||
activeMainTab = "local";
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
const viewer = state.viewer;
|
||||
Object.assign(viewer, {
|
||||
source: "local",
|
||||
repoName: state.selectedRepoName || value.split(/[/\\]/).filter(Boolean).pop() || value,
|
||||
repoPath: value,
|
||||
cloneUrl: "",
|
||||
defaultBranch: "",
|
||||
branch: "",
|
||||
branches: [],
|
||||
path: "",
|
||||
entries: [],
|
||||
selectedFile: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
});
|
||||
activeMainTab = "viewer";
|
||||
render();
|
||||
|
||||
try {
|
||||
const branches = await listLocalRepoBranches(value, state.settings.gitExecutablePath);
|
||||
const selectedBranch = branches.find((branch) => branch.current)?.name || branches[0]?.name || "";
|
||||
viewer.branches = branches;
|
||||
viewer.branch = selectedBranch;
|
||||
await loadViewerPath("");
|
||||
} catch (error) {
|
||||
viewer.loading = false;
|
||||
viewer.error = `Unable to open local viewer: ${error.message}`;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function runRepoCommand(actionName, runner) {
|
||||
const state = getState();
|
||||
if (!state.selectedRepoPath) {
|
||||
@@ -502,6 +1029,15 @@ function bindDashboardEvents() {
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".view-repo-btn").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const repo = repositories.find((item) => item.full_name === button.dataset.repoName);
|
||||
if (repo) {
|
||||
openRemoteViewer(repo);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".open-repo-btn").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.selectedRepoName = button.dataset.repoName || "Repository";
|
||||
@@ -523,11 +1059,20 @@ function bindDashboardEvents() {
|
||||
const value = document.getElementById("local-repo-path-input")?.value?.trim() || "";
|
||||
state.localRepoPathInput = value;
|
||||
state.selectedRepoPath = value;
|
||||
state.selectedRepoName = value.split("/").filter(Boolean).pop() || value;
|
||||
state.selectedRepoName = value.split(/[/\\]/).filter(Boolean).pop() || value;
|
||||
addRecentRepo(value);
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById("view-local-repo-btn")?.addEventListener("click", () => {
|
||||
const value = document.getElementById("local-repo-path-input")?.value?.trim() || state.selectedRepoPath;
|
||||
state.localRepoPathInput = value;
|
||||
state.selectedRepoPath = value;
|
||||
state.selectedRepoName = value.split(/[/\\]/).filter(Boolean).pop() || value;
|
||||
if (value) addRecentRepo(value);
|
||||
openLocalViewer();
|
||||
});
|
||||
|
||||
document.getElementById("clone-btn")?.addEventListener("click", async () => {
|
||||
state.cloneUrlInput = document.getElementById("clone-url-input")?.value?.trim() || "";
|
||||
state.cloneDestinationInput = document.getElementById("clone-destination-input")?.value?.trim() || "";
|
||||
@@ -600,6 +1145,32 @@ function bindDashboardEvents() {
|
||||
document.querySelectorAll(".edit-server-btn").forEach((button) => {
|
||||
button.addEventListener("click", () => openServerForm(button.dataset.id));
|
||||
});
|
||||
|
||||
document.getElementById("viewer-branch-select")?.addEventListener("change", (event) => {
|
||||
state.viewer.branch = event.target.value;
|
||||
loadViewerPath("");
|
||||
});
|
||||
|
||||
document.getElementById("viewer-refresh-btn")?.addEventListener("click", () => {
|
||||
loadViewerPath(state.viewer.path);
|
||||
});
|
||||
|
||||
document.querySelectorAll(".viewer-crumb-btn").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
loadViewerPath(button.dataset.viewerPath || "");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".viewer-row").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const path = button.dataset.entryPath || "";
|
||||
if (button.dataset.entryType === "dir") {
|
||||
loadViewerPath(path);
|
||||
} else {
|
||||
openViewerFile(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
||||
@@ -50,3 +50,54 @@ export async function fetchRepositories(serverConfig, page = 1, limit = 50) {
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function encodePathSegment(value = "") {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function encodeRepoPath(path = "") {
|
||||
return path
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(encodePathSegment)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function splitRepoFullName(fullName = "") {
|
||||
const [owner, repo] = fullName.split("/");
|
||||
if (!owner || !repo) {
|
||||
throw new Error("Repository full name must be in owner/repo format.");
|
||||
}
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
export async function fetchRepoBranches(serverConfig, fullName) {
|
||||
const apiBase = buildApiBaseUrl(serverConfig.serverUrl);
|
||||
const { owner, repo } = splitRepoFullName(fullName);
|
||||
const url = `${apiBase}/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/branches`;
|
||||
const response = await fetch(url, {
|
||||
headers: buildHeaders(serverConfig),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea API error: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchRepoContents(serverConfig, fullName, path = "", ref = "") {
|
||||
const apiBase = buildApiBaseUrl(serverConfig.serverUrl);
|
||||
const { owner, repo } = splitRepoFullName(fullName);
|
||||
const encodedPath = encodeRepoPath(path);
|
||||
const pathSuffix = encodedPath ? `/${encodedPath}` : "";
|
||||
const query = ref ? `?ref=${encodeURIComponent(ref)}` : "";
|
||||
const url = `${apiBase}/repos/${encodePathSegment(owner)}/${encodePathSegment(repo)}/contents${pathSuffix}${query}`;
|
||||
const response = await fetch(url, {
|
||||
headers: buildHeaders(serverConfig),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gitea API error: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,21 @@ const state = {
|
||||
cloneUrlInput: "",
|
||||
cloneDestinationInput: "",
|
||||
commitMessage: "",
|
||||
viewer: {
|
||||
source: "",
|
||||
repoName: "",
|
||||
repoPath: "",
|
||||
cloneUrl: "",
|
||||
defaultBranch: "",
|
||||
branch: "",
|
||||
branches: [],
|
||||
path: "",
|
||||
entries: [],
|
||||
selectedFile: null,
|
||||
readmeFile: null,
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function getState() {
|
||||
|
||||
@@ -35,6 +35,31 @@ export async function runGitBranch(repoPath, gitPath) {
|
||||
return invoke("git_branch", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function listLocalRepoBranches(repoPath, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("local_repo_branches", { repoPath, gitPath: gitPath || null });
|
||||
}
|
||||
|
||||
export async function listLocalRepoTree(repoPath, reference, path, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("local_repo_tree", {
|
||||
repoPath,
|
||||
reference,
|
||||
path: path || "",
|
||||
gitPath: gitPath || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readLocalRepoFile(repoPath, reference, path, gitPath) {
|
||||
ensureInvoke();
|
||||
return invoke("local_repo_file", {
|
||||
repoPath,
|
||||
reference,
|
||||
path,
|
||||
gitPath: gitPath || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function testGiteaConnection(payload) {
|
||||
ensureInvoke();
|
||||
return invoke("test_gitea_connection", payload);
|
||||
|
||||
@@ -13,6 +13,7 @@ struct GitCommandResult {
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ServerConnectionResult {
|
||||
ok: bool,
|
||||
message: String,
|
||||
@@ -20,6 +21,32 @@ struct ServerConnectionResult {
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalRepoBranch {
|
||||
name: String,
|
||||
current: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalRepoEntry {
|
||||
name: String,
|
||||
path: String,
|
||||
entry_type: String,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalRepoFile {
|
||||
path: String,
|
||||
size: u64,
|
||||
content: Option<String>,
|
||||
is_binary: bool,
|
||||
too_large: bool,
|
||||
}
|
||||
|
||||
fn normalize_api_base_url(server_url: &str) -> Result<String, String> {
|
||||
// Normalize user input so every backend consistently resolves to /api/v1.
|
||||
let trimmed = server_url.trim().trim_end_matches('/');
|
||||
@@ -72,6 +99,71 @@ fn run_git_command(
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git_output(
|
||||
repo_path: &str,
|
||||
git_path: Option<String>,
|
||||
args: Vec<String>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let git_binary = resolve_git_binary(git_path);
|
||||
let path = repo_path.trim();
|
||||
if !Path::new(path).exists() {
|
||||
return Err(format!("Repository path does not exist: {path}"));
|
||||
}
|
||||
|
||||
let output = Command::new(&git_binary)
|
||||
.current_dir(path)
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|err| format!("Failed to run git command: {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if stderr.is_empty() {
|
||||
format!("{git_binary} {} failed", args.join(" "))
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
|
||||
Ok(output.stdout)
|
||||
}
|
||||
|
||||
fn normalize_repo_path(path: &str) -> Result<String, String> {
|
||||
let trimmed = path.trim().trim_matches('/');
|
||||
if trimmed.contains('\\') || trimmed.contains('\0') {
|
||||
return Err("Repository paths must use forward slashes.".to_string());
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for part in trimmed.split('/') {
|
||||
if part.is_empty() || part == "." {
|
||||
continue;
|
||||
}
|
||||
if part == ".." {
|
||||
return Err("Repository paths cannot contain parent directory segments.".to_string());
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
Ok(parts.join("/"))
|
||||
}
|
||||
|
||||
fn normalize_reference(reference: &str) -> Result<String, String> {
|
||||
let trimmed = reference.trim();
|
||||
if trimmed.is_empty() || trimmed.contains('\0') {
|
||||
return Err("Branch or reference is required.".to_string());
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn treeish(reference: &str, path: &str) -> String {
|
||||
if path.is_empty() {
|
||||
reference.to_string()
|
||||
} else {
|
||||
format!("{reference}:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_clone(
|
||||
repo_url: String,
|
||||
@@ -124,6 +216,171 @@ fn git_branch(repo_path: String, git_path: Option<String>) -> Result<GitCommandR
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn local_repo_branches(
|
||||
repo_path: String,
|
||||
git_path: Option<String>,
|
||||
) -> Result<Vec<LocalRepoBranch>, String> {
|
||||
let current = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path.clone(),
|
||||
vec![
|
||||
"rev-parse".to_string(),
|
||||
"--abbrev-ref".to_string(),
|
||||
"HEAD".to_string(),
|
||||
],
|
||||
)
|
||||
.ok()
|
||||
.map(|output| String::from_utf8_lossy(&output).trim().to_string())
|
||||
.filter(|value| !value.is_empty() && value != "HEAD");
|
||||
|
||||
let output = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path,
|
||||
vec![
|
||||
"for-each-ref".to_string(),
|
||||
"--format=%(refname)".to_string(),
|
||||
"refs/heads".to_string(),
|
||||
"refs/remotes".to_string(),
|
||||
],
|
||||
)?;
|
||||
|
||||
let branches = String::from_utf8_lossy(&output)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.ends_with("/HEAD"))
|
||||
.filter_map(|ref_name| {
|
||||
ref_name
|
||||
.strip_prefix("refs/heads/")
|
||||
.or_else(|| ref_name.strip_prefix("refs/remotes/"))
|
||||
})
|
||||
.map(|name| LocalRepoBranch {
|
||||
name: name.to_string(),
|
||||
current: current.as_deref() == Some(name),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(branches)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn local_repo_tree(
|
||||
repo_path: String,
|
||||
reference: String,
|
||||
path: String,
|
||||
git_path: Option<String>,
|
||||
) -> Result<Vec<LocalRepoEntry>, String> {
|
||||
let reference = normalize_reference(&reference)?;
|
||||
let path = normalize_repo_path(&path)?;
|
||||
let output = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path,
|
||||
vec!["ls-tree".to_string(), "-l".to_string(), treeish(&reference, &path)],
|
||||
)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for line in String::from_utf8_lossy(&output).lines() {
|
||||
let Some((meta, name)) = line.split_once('\t') else {
|
||||
continue;
|
||||
};
|
||||
let meta_parts: Vec<&str> = meta.split_whitespace().collect();
|
||||
if meta_parts.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry_type = meta_parts[1].to_string();
|
||||
let size = meta_parts[3].parse::<u64>().ok();
|
||||
let entry_path = if path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{path}/{name}")
|
||||
};
|
||||
|
||||
entries.push(LocalRepoEntry {
|
||||
name: name.to_string(),
|
||||
path: entry_path,
|
||||
entry_type,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
let a_is_tree = a.entry_type == "tree";
|
||||
let b_is_tree = b.entry_type == "tree";
|
||||
b_is_tree
|
||||
.cmp(&a_is_tree)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn local_repo_file(
|
||||
repo_path: String,
|
||||
reference: String,
|
||||
path: String,
|
||||
git_path: Option<String>,
|
||||
) -> Result<LocalRepoFile, String> {
|
||||
const MAX_TEXT_PREVIEW_BYTES: u64 = 256 * 1024;
|
||||
|
||||
let reference = normalize_reference(&reference)?;
|
||||
let path = normalize_repo_path(&path)?;
|
||||
if path.is_empty() {
|
||||
return Err("File path is required.".to_string());
|
||||
}
|
||||
|
||||
let spec = treeish(&reference, &path);
|
||||
let object_type = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path.clone(),
|
||||
vec!["cat-file".to_string(), "-t".to_string(), spec.clone()],
|
||||
)?;
|
||||
if String::from_utf8_lossy(&object_type).trim() != "blob" {
|
||||
return Err("Selected path is not a file.".to_string());
|
||||
}
|
||||
|
||||
let size_output = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path.clone(),
|
||||
vec!["cat-file".to_string(), "-s".to_string(), spec.clone()],
|
||||
)?;
|
||||
let size = String::from_utf8_lossy(&size_output)
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| "Unable to determine file size.".to_string())?;
|
||||
|
||||
if size > MAX_TEXT_PREVIEW_BYTES {
|
||||
return Ok(LocalRepoFile {
|
||||
path,
|
||||
size,
|
||||
content: None,
|
||||
is_binary: false,
|
||||
too_large: true,
|
||||
});
|
||||
}
|
||||
|
||||
let content = run_git_output(
|
||||
repo_path.trim(),
|
||||
git_path,
|
||||
vec!["show".to_string(), "--no-ext-diff".to_string(), spec],
|
||||
)?;
|
||||
let is_binary = content.contains(&0);
|
||||
let content = if is_binary {
|
||||
None
|
||||
} else {
|
||||
String::from_utf8(content).ok()
|
||||
};
|
||||
|
||||
Ok(LocalRepoFile {
|
||||
path,
|
||||
size,
|
||||
is_binary: content.is_none(),
|
||||
too_large: false,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn test_gitea_connection(
|
||||
server_url: String,
|
||||
@@ -190,6 +447,9 @@ pub fn run() {
|
||||
git_push,
|
||||
git_status,
|
||||
git_branch,
|
||||
local_repo_branches,
|
||||
local_repo_tree,
|
||||
local_repo_file,
|
||||
test_gitea_connection
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
Reference in New Issue
Block a user