346f8536f0
Implement local repository scanning end-to-end. Frontend: add UI (toolbar, local-scan panel, list/cards), CSS styles, icons, new state fields, and templates; convert recent repo items to buttons and wire up actions to select, view and open scanned repos. JS: add helper utilities (repoNameFromPath, normalizeRemoteUrl, serverRepoRemoteUrls), scanForLocalRepos/selectLocalRepo functions, render template for scan results, and call scanLocalRepos via tauri-api. Tauri API: expose scan_local_repos (tauri command) in tauri-api.js. Backend (Rust): add LocalRepoCandidate type and a scanner implementation that walks configurable/default roots, deduplicates paths, matches local remotes against allowed server URLs, enforces depth/result limits, and returns matched candidates; register scan_local_repos in the command list. Includes error handling and user-facing messages for missing roots or remotes.
1382 lines
52 KiB
JavaScript
1382 lines
52 KiB
JavaScript
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,
|
|
runGitPush,
|
|
runGitStatus,
|
|
scanLocalRepos,
|
|
testGiteaConnection,
|
|
} from "./tauri-api.js";
|
|
|
|
const appRoot = document.getElementById("app");
|
|
|
|
const mockRepos = [
|
|
{ id: 1, full_name: "alice/portfolio", private: false, clone_url: "https://example.com/portfolio.git", updated_at: "today", owner: { login: "alice", type: "User" } },
|
|
{ id: 2, full_name: "alice/dotfiles", private: true, clone_url: "https://example.com/dotfiles.git", updated_at: "yesterday", owner: { login: "alice", type: "User" } },
|
|
{ id: 3, full_name: "acme-corp/client-ui", private: false, clone_url: "https://example.com/client-ui.git", updated_at: "today", owner: { login: "acme-corp", type: "Organization" } },
|
|
{ id: 4, full_name: "acme-corp/api-server", private: true, clone_url: "https://example.com/api-server.git", updated_at: "3 days ago", owner: { login: "acme-corp", type: "Organization" } },
|
|
{ id: 5, full_name: "oss-collective/toolkit", private: false, clone_url: "https://example.com/toolkit.git", updated_at: "last week", owner: { login: "oss-collective", type: "Organization" } },
|
|
];
|
|
|
|
let repositories = [...mockRepos];
|
|
let currentUserLogin = ""; // authenticated user's login name
|
|
let serverTestResult = "";
|
|
let settingsNotice = "";
|
|
let gitOutput = "";
|
|
let activeRightTab = "clone"; // "clone" | "settings" | "servers"
|
|
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>`;
|
|
const LOCAL_REPO_ICON = `<svg width="20" height="20" viewBox="0 0 16 16" aria-hidden="true" fill="none"><rect x="1.5" y="3" width="13" height="8.5" rx="1.25" stroke="currentColor" stroke-width="1.3"/><path d="M6 13.75h4M8 11.5v2.25" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`;
|
|
const BRANCH_ICON = `<svg width="18" height="18" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>`;
|
|
const SYNC_ICON = `<svg width="18" height="18" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7 7 0 0 1 14.95 7.16a.75.75 0 1 1-1.49.178A5.501 5.501 0 0 0 8 2.5ZM1.705 8.005a.75.75 0 0 1 .834.656 5.501 5.501 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7 7 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834Z"/></svg>`;
|
|
|
|
function uid() {
|
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
function escapeHtml(value = "") {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.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 repoNameFromPath(path = "") {
|
|
return path.split(/[/\\]/).filter(Boolean).pop() || path;
|
|
}
|
|
|
|
function normalizeRemoteUrl(value = "") {
|
|
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
|
|
}
|
|
|
|
function serverRepoRemoteUrls() {
|
|
const urls = repositories.flatMap((repo) => [
|
|
repo.clone_url,
|
|
repo.ssh_url,
|
|
repo.html_url,
|
|
repo.original_url,
|
|
]);
|
|
return [...new Set(urls.map((url) => normalizeRemoteUrl(url || "")).filter(Boolean))];
|
|
}
|
|
|
|
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 = {
|
|
id: "",
|
|
displayName: "",
|
|
serverUrl: "",
|
|
authMethod: "token",
|
|
token: "",
|
|
username: "",
|
|
password: "",
|
|
};
|
|
const config = { ...defaults, ...(server || {}) };
|
|
return `
|
|
<div class="stack panel" id="server-form-card">
|
|
<h3 class="title">${server ? "Edit Server" : "Add Server"}</h3>
|
|
<input type="hidden" name="id" value="${escapeHtml(config.id)}" />
|
|
<div>
|
|
<div class="label">Display name</div>
|
|
<input name="displayName" value="${escapeHtml(config.displayName)}" placeholder="Gitpub Main" />
|
|
</div>
|
|
<div>
|
|
<div class="label">Gitea server URL</div>
|
|
<input name="serverUrl" value="${escapeHtml(config.serverUrl)}" placeholder="https://git.example.com" />
|
|
</div>
|
|
<div>
|
|
<div class="label">Auth method</div>
|
|
<select name="authMethod">
|
|
<option value="token" ${config.authMethod === "token" ? "selected" : ""}>Access Token</option>
|
|
<option value="password" ${config.authMethod === "password" ? "selected" : ""}>Username / Password</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div class="label">Access token</div>
|
|
<input name="token" value="${escapeHtml(config.token)}" placeholder="gitea token" />
|
|
</div>
|
|
<div class="row">
|
|
<div style="flex: 1">
|
|
<div class="label">Username</div>
|
|
<input name="username" value="${escapeHtml(config.username)}" placeholder="username" />
|
|
</div>
|
|
<div style="flex: 1">
|
|
<div class="label">Password</div>
|
|
<input type="password" name="password" value="${escapeHtml(config.password)}" placeholder="password" />
|
|
</div>
|
|
</div>
|
|
<div class="row wrap">
|
|
<button id="test-server-btn" type="button">Test connection</button>
|
|
<button id="save-server-btn" class="primary" type="button">Save server</button>
|
|
</div>
|
|
<div class="muted">${escapeHtml(serverTestResult || settingsNotice)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function welcomeView() {
|
|
appRoot.innerHTML = `
|
|
<div class="welcome-wrap">
|
|
<div class="welcome-card stack">
|
|
<div class="welcome-logo">
|
|
<img src="/assets/logos/Gitpub-Word-Logo-2-White.svg" alt="Gitpub" class="welcome-logo-img" />
|
|
<span class="welcome-logo-desktop">Desktop</span>
|
|
</div>
|
|
<div class="panel stack">
|
|
<h1 class="title">Welcome to Gitpub Desktop</h1>
|
|
<p class="subtitle">
|
|
Connect your first Gitea backend. Gitpub Desktop works with any compatible Gitea server.
|
|
</p>
|
|
${serverFormTemplate(null)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
bindServerFormEvents();
|
|
}
|
|
|
|
function isOrgRepo(repo) {
|
|
const ownerLogin = repo.owner?.login || repo.full_name.split("/")[0];
|
|
// When we know the authenticated user, compare against owner login.
|
|
// Fall back to owner.type for cases where user info wasn't fetched.
|
|
if (currentUserLogin) {
|
|
return ownerLogin.toLowerCase() !== currentUserLogin.toLowerCase();
|
|
}
|
|
return repo.owner?.type === "Organization" || repo.owner?.type === "organization";
|
|
}
|
|
|
|
function filteredRepositories() {
|
|
const searchTerm = getState().repoSearch.trim().toLowerCase();
|
|
let result = repositories;
|
|
|
|
if (repoOwnerFilter === "personal") {
|
|
result = result.filter((repo) => !isOrgRepo(repo));
|
|
} else if (repoOwnerFilter === "orgs") {
|
|
result = result.filter((repo) => isOrgRepo(repo));
|
|
}
|
|
|
|
if (searchTerm) {
|
|
result = result.filter((repo) => repo.full_name.toLowerCase().includes(searchTerm));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function groupedByOrg(repos) {
|
|
const groups = {};
|
|
for (const repo of repos) {
|
|
const org = repo.owner?.login || "Unknown";
|
|
if (!groups[org]) groups[org] = [];
|
|
groups[org].push(repo);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function repoCardTemplate(repo) {
|
|
return `
|
|
<article class="repo-card stack">
|
|
<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>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function localRepoScanTemplate() {
|
|
const state = getState();
|
|
const results = state.localRepoScanResults || [];
|
|
|
|
if (state.localRepoScanLoading) {
|
|
return `<div class="viewer-loading">Scanning local folders for repositories from the selected Gitea server...</div>`;
|
|
}
|
|
|
|
if (state.localRepoScanError) {
|
|
return `<div class="viewer-error">${escapeHtml(state.localRepoScanError)}</div>`;
|
|
}
|
|
|
|
if (!results.length) {
|
|
return `<div class="empty-state local-scan-empty">
|
|
<div>No local repositories scanned yet</div>
|
|
<div class="muted">Only local repos with remotes from the selected Gitea server will be shown.</div>
|
|
</div>`;
|
|
}
|
|
|
|
return `<div class="local-repo-list">
|
|
${results
|
|
.map(
|
|
(repo) => `
|
|
<article class="local-repo-card">
|
|
<div class="local-repo-info">
|
|
<strong>${escapeHtml(repo.name || repoNameFromPath(repo.path))}</strong>
|
|
<span class="muted" title="${escapeHtml(repo.path)}">${escapeHtml(repo.path)}</span>
|
|
${repo.matchedRemoteUrl ? `<span class="muted" title="${escapeHtml(repo.matchedRemoteUrl)}">Remote: ${escapeHtml(repo.matchedRemoteUrl)}</span>` : ""}
|
|
</div>
|
|
<div class="row wrap">
|
|
<button class="use-scanned-repo-btn" data-repo-path="${escapeHtml(repo.path)}" data-repo-name="${escapeHtml(repo.name || repoNameFromPath(repo.path))}" type="button">Use</button>
|
|
<button class="view-scanned-repo-btn primary-blue" data-repo-path="${escapeHtml(repo.path)}" data-repo-name="${escapeHtml(repo.name || repoNameFromPath(repo.path))}" type="button">View files</button>
|
|
</div>
|
|
</article>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>`;
|
|
}
|
|
|
|
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();
|
|
const visibleRepos = filteredRepositories();
|
|
|
|
appRoot.innerHTML = `
|
|
<div class="layout">
|
|
<aside class="sidebar stack">
|
|
<div class="sidebar-brand">
|
|
<div class="sidebar-logo">
|
|
<img src="/assets/logos/Gitpub-Word-Logo-2-White.svg" alt="Gitpub" class="sidebar-logo-img" />
|
|
<span class="sidebar-logo-desktop">Desktop</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="server-chip">
|
|
<div class="label">Active server</div>
|
|
<div>${escapeHtml(activeServer?.displayName || "No server selected")}</div>
|
|
<div class="muted">${escapeHtml(activeServer?.serverUrl || "Add a server to connect")}</div>
|
|
</div>
|
|
|
|
<div class="sidebar-nav">
|
|
<button id="refresh-repos-btn" class="sidebar-btn" type="button">Refresh</button>
|
|
<button id="open-settings-btn" class="sidebar-btn" type="button">Servers</button>
|
|
</div>
|
|
|
|
<div class="sidebar-recents">
|
|
<div class="label">Recent repositories</div>
|
|
<ul class="list">
|
|
${state.settings.recentRepositories.length
|
|
? state.settings.recentRepositories
|
|
.map((path) => `
|
|
<li>
|
|
<button class="recent-item" data-recent-repo-path="${escapeHtml(path)}" type="button" title="${escapeHtml(path)}">
|
|
${escapeHtml(repoNameFromPath(path))}
|
|
</button>
|
|
</li>`)
|
|
.join("")
|
|
: "<li class='muted'>No recent repositories.</li>"}
|
|
</ul>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="main stack">
|
|
<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)}" />`
|
|
: ""}
|
|
</div>
|
|
|
|
${activeMainTab === "repos" ? `
|
|
<section class="panel stack">
|
|
<div class="local-scan-panel stack">
|
|
<div class="section-header">
|
|
<div>
|
|
<h4 class="title local-scan-title">Find local repositories</h4>
|
|
<p class="subtitle">Shows only local repos that belong to the selected Gitea server.</p>
|
|
</div>
|
|
<button id="scan-local-repos-btn" class="primary" type="button" ${state.localRepoScanLoading ? "disabled" : ""}>
|
|
${state.localRepoScanLoading ? "Scanning..." : "Scan for repos"}
|
|
</button>
|
|
</div>
|
|
<div class="row">
|
|
<input id="local-repo-scan-root-input" placeholder="Folder to scan, e.g. F:\\Repos" value="${escapeHtml(state.localRepoScanRootInput || state.settings.defaultCloneDirectory)}" />
|
|
<input id="local-repo-path-input" placeholder="Or paste a repo path…" value="${escapeHtml(state.localRepoPathInput)}" />
|
|
<button id="open-local-repo-btn" type="button">Open</button>
|
|
</div>
|
|
${localRepoScanTemplate()}
|
|
</div>
|
|
<div class="section-header">
|
|
<h3 class="title">Repositories</h3>
|
|
<span class="muted">${visibleRepos.length} shown</span>
|
|
</div>
|
|
<div class="repo-filter-pills">
|
|
<button class="pill-btn ${repoOwnerFilter === "all" ? "active" : ""}" data-owner-filter="all">All</button>
|
|
<button class="pill-btn ${repoOwnerFilter === "personal" ? "active" : ""}" data-owner-filter="personal">Personal</button>
|
|
<button class="pill-btn ${repoOwnerFilter === "orgs" ? "active" : ""}" data-owner-filter="orgs">Organizations</button>
|
|
</div>
|
|
${repoOwnerFilter === "orgs"
|
|
? (() => {
|
|
const groups = groupedByOrg(visibleRepos);
|
|
const orgNames = Object.keys(groups).sort();
|
|
if (!orgNames.length) {
|
|
return `<div class="empty-state"><div>No organisation repositories found</div><div class="muted">Try refreshing or check your server connection.</div></div>`;
|
|
}
|
|
return orgNames.map((org) => `
|
|
<div class="org-group stack">
|
|
<div class="org-group-header">
|
|
<span class="org-badge">${escapeHtml(org)}</span>
|
|
<span class="muted">${groups[org].length} repo${groups[org].length !== 1 ? "s" : ""}</span>
|
|
</div>
|
|
<div class="repo-grid">
|
|
${groups[org].map((repo) => repoCardTemplate(repo)).join("")}
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})()
|
|
: `<div class="repo-grid">
|
|
${visibleRepos.length
|
|
? visibleRepos.map((repo) => repoCardTemplate(repo)).join("")
|
|
: `<div class="empty-state">
|
|
<div>No repositories found</div>
|
|
<div class="muted">Try refreshing or check your server connection.</div>
|
|
</div>`}
|
|
</div>`}
|
|
</section>
|
|
` : activeMainTab === "local" ? `
|
|
<div class="gd-toolbar">
|
|
${state.selectedRepoName
|
|
? `<button class="gd-toolbar-cell selected-repo-tab" type="button" title="${escapeHtml(state.selectedRepoPath)}">
|
|
<span class="gd-cell-icon">${LOCAL_REPO_ICON}</span>
|
|
<span class="gd-cell-copy">
|
|
<span class="gd-cell-label">Current repository</span>
|
|
<span class="gd-cell-value">${escapeHtml(state.selectedRepoName)}</span>
|
|
</span>
|
|
<svg class="gd-cell-caret" viewBox="0 0 16 16" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/></svg>
|
|
</button>`
|
|
: `<div class="gd-toolbar-cell gd-toolbar-cell-empty">
|
|
<span class="gd-cell-icon">${LOCAL_REPO_ICON}</span>
|
|
<span class="gd-cell-copy">
|
|
<span class="gd-cell-label">Current repository</span>
|
|
<span class="gd-cell-value gd-cell-value-muted">No repository selected</span>
|
|
</span>
|
|
</div>`}
|
|
<button class="gd-toolbar-cell" type="button" disabled title="Branch switching coming soon">
|
|
<span class="gd-cell-icon">${BRANCH_ICON}</span>
|
|
<span class="gd-cell-copy">
|
|
<span class="gd-cell-label">Current branch</span>
|
|
<span class="gd-cell-value">${escapeHtml(state.selectedRepoName ? "main" : "—")}</span>
|
|
</span>
|
|
<svg class="gd-cell-caret" viewBox="0 0 16 16" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/></svg>
|
|
</button>
|
|
<button class="gd-toolbar-cell" id="git-pull-btn-toolbar" type="button" ${state.selectedRepoName ? "" : "disabled"}>
|
|
<span class="gd-cell-icon">${SYNC_ICON}</span>
|
|
<span class="gd-cell-copy">
|
|
<span class="gd-cell-label">Fetch origin</span>
|
|
<span class="gd-cell-value">Sync changes</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
<section class="panel stack">
|
|
<h3 class="title">${escapeHtml(state.selectedRepoName || "Local Repository")}</h3>
|
|
${state.selectedRepoName
|
|
? `<div class="muted selected-repo-path-line" title="${escapeHtml(state.selectedRepoPath)}">${escapeHtml(state.selectedRepoPath)}</div>`
|
|
: `<div class="empty-state">
|
|
<div>No repository selected</div>
|
|
<div class="muted">Pick a repository from <strong>Recent repositories</strong> on the left, or scan for local repos on the <strong>Repositories</strong> tab.</div>
|
|
</div>`}
|
|
${state.selectedRepoName ? `
|
|
<div class="row wrap">
|
|
<button id="git-status-btn" type="button">Status</button>
|
|
<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>
|
|
<textarea id="commit-message-input" placeholder="Write commit message…">${escapeHtml(state.commitMessage)}</textarea>
|
|
<div class="muted commit-note">Commit support coming in the next milestone.</div>
|
|
</div>
|
|
${gitOutput
|
|
? `<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">
|
|
<div class="tabs right-tabs">
|
|
<button class="tab-btn ${activeRightTab === "clone" ? "active" : ""}" data-right-tab="clone">Clone</button>
|
|
<button class="tab-btn ${activeRightTab === "settings" ? "active" : ""}" data-right-tab="settings">Settings</button>
|
|
<button class="tab-btn ${activeRightTab === "servers" ? "active" : ""}" data-right-tab="servers">Servers</button>
|
|
</div>
|
|
|
|
${activeRightTab === "clone" ? `
|
|
<section class="panel stack">
|
|
<div>
|
|
<div class="label">Repository URL</div>
|
|
<input id="clone-url-input" placeholder="https://git.example.com/org/repo.git" value="${escapeHtml(state.cloneUrlInput)}" />
|
|
</div>
|
|
<div>
|
|
<div class="label">Destination path</div>
|
|
<input id="clone-destination-input" placeholder="/Users/me/code/repo" value="${escapeHtml(state.cloneDestinationInput || state.settings.defaultCloneDirectory)}" />
|
|
</div>
|
|
<button id="clone-btn" class="primary" type="button">Clone</button>
|
|
<span class="muted">Uses system Git from settings.</span>
|
|
</section>
|
|
` : ""}
|
|
|
|
${activeRightTab === "settings" ? `
|
|
<section class="panel stack">
|
|
<div>
|
|
<div class="label">Theme</div>
|
|
<select id="theme-select">
|
|
<option value="dark" ${state.settings.theme === "dark" ? "selected" : ""}>Dark (default)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div class="label">Git executable path</div>
|
|
<input id="git-path-input" value="${escapeHtml(state.settings.gitExecutablePath)}" placeholder="git or /usr/bin/git" />
|
|
</div>
|
|
<div>
|
|
<div class="label">Default clone directory</div>
|
|
<input id="default-clone-dir-input" value="${escapeHtml(state.settings.defaultCloneDirectory)}" placeholder="/Users/me/code" />
|
|
</div>
|
|
<button id="save-basic-settings-btn" class="primary" type="button">Save settings</button>
|
|
${settingsNotice ? `<div class="muted">${escapeHtml(settingsNotice)}</div>` : ""}
|
|
</section>
|
|
` : ""}
|
|
|
|
${activeRightTab === "servers" ? `
|
|
<section class="panel stack">
|
|
${state.settings.servers
|
|
.map((server) => `
|
|
<div class="server-chip stack">
|
|
<strong>${escapeHtml(server.displayName)}</strong>
|
|
<span class="muted">${escapeHtml(server.serverUrl)}</span>
|
|
<div class="row wrap">
|
|
<button class="set-default-server-btn" data-id="${server.id}" type="button">Set default</button>
|
|
<button class="edit-server-btn" data-id="${server.id}" type="button">Edit</button>
|
|
<button class="delete-server-btn danger" data-id="${server.id}" type="button">Remove</button>
|
|
</div>
|
|
</div>
|
|
`).join("")}
|
|
<button id="add-server-btn" type="button">Add new server</button>
|
|
<div id="server-form-slot" class="stack"></div>
|
|
${settingsNotice ? `<div class="muted">${escapeHtml(settingsNotice)}</div>` : ""}
|
|
</section>
|
|
` : ""}
|
|
</aside>
|
|
</div>
|
|
`;
|
|
|
|
bindDashboardEvents();
|
|
}
|
|
|
|
function bindServerFormEvents(existingServer = null) {
|
|
const testButton = document.getElementById("test-server-btn");
|
|
const saveButton = document.getElementById("save-server-btn");
|
|
const formCard = document.getElementById("server-form-card");
|
|
if (!testButton || !saveButton || !formCard) return;
|
|
|
|
const collectPayload = () => {
|
|
const get = (name) => formCard.querySelector(`[name="${name}"]`)?.value?.trim() || "";
|
|
return {
|
|
id: get("id"),
|
|
displayName: get("displayName"),
|
|
serverUrl: get("serverUrl"),
|
|
authMethod: get("authMethod") || "token",
|
|
token: get("token"),
|
|
username: get("username"),
|
|
password: get("password"),
|
|
};
|
|
};
|
|
|
|
testButton.addEventListener("click", async () => {
|
|
const payload = collectPayload();
|
|
try {
|
|
const result = await testGiteaConnection(payload);
|
|
serverTestResult = `${result.ok ? "Connected" : "Failed"}: ${result.message} (${result.apiBaseUrl})`;
|
|
render();
|
|
if (existingServer) {
|
|
openServerForm(existingServer.id);
|
|
}
|
|
} catch (error) {
|
|
serverTestResult = `Connection test failed: ${error.message}`;
|
|
render();
|
|
if (existingServer) {
|
|
openServerForm(existingServer.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
saveButton.addEventListener("click", () => {
|
|
const payload = collectPayload();
|
|
if (!payload.displayName || !payload.serverUrl) {
|
|
settingsNotice = "Display name and server URL are required.";
|
|
render();
|
|
if (existingServer) openServerForm(existingServer.id);
|
|
return;
|
|
}
|
|
|
|
const state = getState();
|
|
let nextServers = [...state.settings.servers];
|
|
const nextId = payload.id || uid();
|
|
const serverData = { ...payload, id: nextId };
|
|
const existingIndex = nextServers.findIndex((item) => item.id === nextId);
|
|
|
|
if (existingIndex >= 0) {
|
|
nextServers[existingIndex] = serverData;
|
|
settingsNotice = `Updated server ${payload.displayName}.`;
|
|
} else {
|
|
nextServers.push(serverData);
|
|
settingsNotice = `Added server ${payload.displayName}.`;
|
|
}
|
|
|
|
const activeServerId = state.settings.activeServerId || nextId;
|
|
setSettings({ ...state.settings, servers: nextServers, activeServerId });
|
|
serverTestResult = "";
|
|
render();
|
|
});
|
|
}
|
|
|
|
function openServerForm(serverId = null) {
|
|
const slot = document.getElementById("server-form-slot");
|
|
if (!slot) return;
|
|
|
|
const server = getState().settings.servers.find((item) => item.id === serverId) || null;
|
|
slot.innerHTML = serverFormTemplate(server);
|
|
bindServerFormEvents(server);
|
|
}
|
|
|
|
async function loadRepositories() {
|
|
const activeServer = getActiveServer();
|
|
if (!activeServer) {
|
|
repositories = [...mockRepos];
|
|
currentUserLogin = "alice"; // mock user matches mock personal repos
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const [user, repos] = await Promise.all([
|
|
fetchCurrentUser(activeServer),
|
|
fetchRepositories(activeServer),
|
|
]);
|
|
currentUserLogin = user.login || "";
|
|
repositories = repos;
|
|
} catch (error) {
|
|
settingsNotice = `Using mock repositories. API fetch failed: ${error.message}`;
|
|
currentUserLogin = "alice";
|
|
repositories = [...mockRepos];
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function selectLocalRepo(path, name = "") {
|
|
const state = getState();
|
|
state.localRepoPathInput = path;
|
|
state.selectedRepoPath = path;
|
|
state.selectedRepoName = name || repoNameFromPath(path);
|
|
addRecentRepo(path);
|
|
}
|
|
|
|
async function scanForLocalRepos() {
|
|
const state = getState();
|
|
const rootInput = document.getElementById("local-repo-scan-root-input")?.value?.trim() || "";
|
|
state.localRepoScanRootInput = rootInput;
|
|
|
|
if (!getActiveServer()) {
|
|
state.localRepoScanResults = [];
|
|
state.localRepoScanError = "Select a Gitea server before scanning local repositories.";
|
|
render();
|
|
return;
|
|
}
|
|
|
|
const allowedRemoteUrls = serverRepoRemoteUrls();
|
|
if (!allowedRemoteUrls.length) {
|
|
state.localRepoScanResults = [];
|
|
state.localRepoScanError = "No repository clone URLs are loaded for the selected Gitea server. Refresh repositories and try again.";
|
|
render();
|
|
return;
|
|
}
|
|
|
|
state.localRepoScanLoading = true;
|
|
state.localRepoScanError = "";
|
|
render();
|
|
|
|
const roots = [rootInput, state.settings.defaultCloneDirectory].filter(Boolean);
|
|
try {
|
|
state.localRepoScanResults = await scanLocalRepos(
|
|
[...new Set(roots)],
|
|
allowedRemoteUrls,
|
|
state.settings.gitExecutablePath,
|
|
4,
|
|
200
|
|
);
|
|
if (!state.localRepoScanResults.length) {
|
|
state.localRepoScanError = "No local repositories from the selected Gitea server were found in the scanned folders.";
|
|
}
|
|
} catch (error) {
|
|
state.localRepoScanResults = [];
|
|
state.localRepoScanError = `Repo scan failed: ${error.message}`;
|
|
} finally {
|
|
state.localRepoScanLoading = false;
|
|
render();
|
|
}
|
|
}
|
|
|
|
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) {
|
|
gitOutput = "Select or enter a local repository path first.";
|
|
render();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await runner();
|
|
gitOutput = `${actionName}: ${result.command}\n\n${result.stdout || "(no stdout)"}\n${result.stderr ? `\n${result.stderr}` : ""}`;
|
|
} catch (error) {
|
|
gitOutput = `${actionName} failed: ${error.message}`;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function bindDashboardEvents() {
|
|
const state = getState();
|
|
|
|
document.getElementById("repo-search-input")?.addEventListener("input", (event) => {
|
|
state.repoSearch = event.target.value;
|
|
render();
|
|
});
|
|
|
|
document.getElementById("refresh-repos-btn")?.addEventListener("click", async () => {
|
|
await loadRepositories();
|
|
render();
|
|
});
|
|
|
|
document.getElementById("open-settings-btn")?.addEventListener("click", () => {
|
|
activeRightTab = "servers";
|
|
render();
|
|
});
|
|
|
|
document.querySelectorAll(".recent-item").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
selectLocalRepo(button.dataset.recentRepoPath || "");
|
|
activeMainTab = "local";
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-right-tab]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
activeRightTab = btn.dataset.rightTab;
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-main-tab]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
activeMainTab = btn.dataset.mainTab;
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-owner-filter]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
repoOwnerFilter = btn.dataset.ownerFilter;
|
|
render();
|
|
});
|
|
});
|
|
|
|
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";
|
|
activeMainTab = "local";
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".clone-repo-btn").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
state.cloneUrlInput = button.dataset.cloneUrl || "";
|
|
activeRightTab = "clone";
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.getElementById("scan-local-repos-btn")?.addEventListener("click", () => {
|
|
scanForLocalRepos();
|
|
});
|
|
|
|
document.querySelectorAll(".use-scanned-repo-btn").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
selectLocalRepo(button.dataset.repoPath || "", button.dataset.repoName || "");
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".view-scanned-repo-btn").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
selectLocalRepo(button.dataset.repoPath || "", button.dataset.repoName || "");
|
|
openLocalViewer();
|
|
});
|
|
});
|
|
|
|
document.getElementById("open-local-repo-btn")?.addEventListener("click", () => {
|
|
const value = document.getElementById("local-repo-path-input")?.value?.trim() || "";
|
|
selectLocalRepo(value);
|
|
render();
|
|
});
|
|
|
|
document.getElementById("view-local-repo-btn")?.addEventListener("click", () => {
|
|
const value = document.getElementById("local-repo-path-input")?.value?.trim() || state.selectedRepoPath;
|
|
if (value) selectLocalRepo(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() || "";
|
|
|
|
if (!state.cloneUrlInput || !state.cloneDestinationInput) {
|
|
gitOutput = "Clone URL and destination path are required.";
|
|
render();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await runGitClone(
|
|
state.cloneUrlInput,
|
|
state.cloneDestinationInput,
|
|
state.settings.gitExecutablePath
|
|
);
|
|
gitOutput = `${result.command}\n\n${result.stdout || "Clone completed."}\n${result.stderr || ""}`;
|
|
addRecentRepo(state.cloneDestinationInput);
|
|
} catch (error) {
|
|
gitOutput = `Clone failed: ${error.message}`;
|
|
}
|
|
render();
|
|
});
|
|
|
|
document.getElementById("git-status-btn")?.addEventListener("click", () => {
|
|
runRepoCommand("Status", () => runGitStatus(state.selectedRepoPath, state.settings.gitExecutablePath));
|
|
});
|
|
document.getElementById("git-branch-btn")?.addEventListener("click", () => {
|
|
runRepoCommand("Branch", () => runGitBranch(state.selectedRepoPath, state.settings.gitExecutablePath));
|
|
});
|
|
document.getElementById("git-pull-btn")?.addEventListener("click", () => {
|
|
runRepoCommand("Pull", () => runGitPull(state.selectedRepoPath, state.settings.gitExecutablePath));
|
|
});
|
|
document.getElementById("git-pull-btn-toolbar")?.addEventListener("click", () => {
|
|
runRepoCommand("Pull", () => runGitPull(state.selectedRepoPath, state.settings.gitExecutablePath));
|
|
});
|
|
document.getElementById("git-push-btn")?.addEventListener("click", () => {
|
|
runRepoCommand("Push", () => runGitPush(state.selectedRepoPath, state.settings.gitExecutablePath));
|
|
});
|
|
|
|
document.getElementById("save-basic-settings-btn")?.addEventListener("click", () => {
|
|
updateSettings({
|
|
theme: document.getElementById("theme-select")?.value || "dark",
|
|
gitExecutablePath: document.getElementById("git-path-input")?.value?.trim() || "",
|
|
defaultCloneDirectory: document.getElementById("default-clone-dir-input")?.value?.trim() || "",
|
|
});
|
|
settingsNotice = "Saved basic settings.";
|
|
render();
|
|
});
|
|
|
|
document.getElementById("add-server-btn")?.addEventListener("click", () => openServerForm());
|
|
|
|
document.querySelectorAll(".set-default-server-btn").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
updateSettings({ activeServerId: button.dataset.id });
|
|
settingsNotice = "Default server updated.";
|
|
await loadRepositories();
|
|
render();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".delete-server-btn").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const nextServers = getState().settings.servers.filter((server) => server.id !== button.dataset.id);
|
|
const nextActive =
|
|
getState().settings.activeServerId === button.dataset.id ? nextServers[0]?.id || null : getState().settings.activeServerId;
|
|
setSettings({ ...getState().settings, servers: nextServers, activeServerId: nextActive });
|
|
settingsNotice = "Server removed.";
|
|
render();
|
|
});
|
|
});
|
|
|
|
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() {
|
|
if (!getState().settings.servers.length) {
|
|
// First launch always lands in setup until at least one backend is saved.
|
|
welcomeView();
|
|
return;
|
|
}
|
|
dashboardView();
|
|
}
|
|
|
|
window.addEventListener("DOMContentLoaded", async () => {
|
|
await loadRepositories();
|
|
render();
|
|
});
|