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 = ``; const FILE_ICON = ``; const LOCAL_REPO_ICON = ``; const BRANCH_ICON = ``; const SYNC_ICON = ``; 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, '$1' ); } else if (language === "JSON") { html = html.replace(/("[^&]*?")(\s*:)/g, '$1$2'); } else if (language === "CSS") { html = html.replace(/([.#]?[a-zA-Z0-9_-]+)(\s*\{)/g, '$1$2'); } return html; } function isMarkdownPath(path = "") { return /\.(md|markdown)$/i.test(path); } function safeMarkdownHref(value = "") { const href = value.trim(); const lowerHref = href.toLowerCase(); if (!href || lowerHref.startsWith("javascript:") || lowerHref.startsWith("data:")) { return ""; } return href; } function renderMarkdownInline(value = "") { return escapeHtml(value) .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_match, label, href) => { const safeHref = safeMarkdownHref(href.replaceAll("&", "&")); return safeHref ? `${label}` : label; }) .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/__([^_]+)__/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/_([^_]+)_/g, "$1"); } function renderMarkdown(content = "") { const lines = content.replace(/\r\n/g, "\n").split("\n"); const html = []; let inFence = false; let fenceLanguage = ""; let fenceLines = []; let listType = ""; let listItems = []; const flushList = () => { if (!listType) return; html.push(`<${listType}>${listItems.map((item) => `
  • ${renderMarkdownInline(item)}
  • `).join("")}`); listType = ""; listItems = []; }; const flushFence = () => { html.push(`
    ${escapeHtml(fenceLines.join("\n"))}
    `); inFence = false; fenceLanguage = ""; fenceLines = []; }; for (const line of lines) { const fence = line.match(/^```(\w+)?\s*$/); if (fence) { if (inFence) { flushFence(); } else { flushList(); inFence = true; fenceLanguage = fence[1] || ""; fenceLines = []; } continue; } if (inFence) { fenceLines.push(line); continue; } if (!line.trim()) { flushList(); continue; } const heading = line.match(/^(#{1,6})\s+(.+)$/); if (heading) { flushList(); const level = heading[1].length; html.push(`${renderMarkdownInline(heading[2])}`); continue; } if (/^\s*[-*_]{3,}\s*$/.test(line)) { flushList(); html.push("
    "); continue; } const quote = line.match(/^>\s?(.*)$/); if (quote) { flushList(); html.push(`
    ${renderMarkdownInline(quote[1])}
    `); continue; } const unordered = line.match(/^\s*[-*+]\s+(.+)$/); if (unordered) { if (listType && listType !== "ul") flushList(); listType = "ul"; listItems.push(unordered[1]); continue; } const ordered = line.match(/^\s*\d+\.\s+(.+)$/); if (ordered) { if (listType && listType !== "ol") flushList(); listType = "ol"; listItems.push(ordered[1]); continue; } flushList(); html.push(`

    ${renderMarkdownInline(line)}

    `); } if (inFence) flushFence(); flushList(); return html.join(""); } function decodeBase64Content(content = "") { const cleaned = content.replace(/\s/g, ""); const binary = atob(cleaned); const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); if (bytes.byteLength > maxPreviewBytes) { return { content: "", size: bytes.byteLength, isBinary: false, tooLarge: true }; } if (bytes.includes(0)) { return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false }; } try { return { content: new TextDecoder("utf-8", { fatal: true }).decode(bytes), size: bytes.byteLength, isBinary: false, tooLarge: false, }; } catch { return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false }; } } function normaliseRemoteEntries(contents) { const items = Array.isArray(contents) ? contents : [contents]; return items .map((item) => ({ name: item.name, path: item.path || item.name, type: item.type === "dir" ? "dir" : "file", size: item.size, })) .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1)); } function normaliseLocalEntries(entries) { return entries.map((entry) => ({ name: entry.name, path: entry.path, type: entry.entryType === "tree" ? "dir" : "file", size: entry.size, })); } function serverFormTemplate(server = null) { // Reused in first-launch setup and in Settings server management. const defaults = { id: "", displayName: "", serverUrl: "", authMethod: "token", token: "", username: "", password: "", }; const config = { ...defaults, ...(server || {}) }; return `

    ${server ? "Edit Server" : "Add Server"}

    Display name
    Gitea server URL
    Auth method
    Access token
    Username
    Password
    ${escapeHtml(serverTestResult || settingsNotice)}
    `; } function welcomeView() { appRoot.innerHTML = `

    Welcome to Gitpub Desktop

    Connect your first Gitea backend. Gitpub Desktop works with any compatible Gitea server.

    ${serverFormTemplate(null)}
    `; 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 `
    ${escapeHtml(repo.full_name)}
    ${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}
    `; } function localRepoScanTemplate() { const state = getState(); const results = state.localRepoScanResults || []; if (state.localRepoScanLoading) { return `
    Scanning local folders for repositories from the selected Gitea server...
    `; } if (state.localRepoScanError) { return `
    ${escapeHtml(state.localRepoScanError)}
    `; } if (!results.length) { return `
    No local repositories scanned yet
    Only local repos with remotes from the selected Gitea server will be shown.
    `; } return `
    ${results .map( (repo) => `
    ${escapeHtml(repo.name || repoNameFromPath(repo.path))} ${escapeHtml(repo.path)} ${repo.matchedRemoteUrl ? `Remote: ${escapeHtml(repo.matchedRemoteUrl)}` : ""}
    ` ) .join("")}
    `; } function breadcrumbTemplate(repoName, path = "") { const parts = path.split("/").filter(Boolean); const crumbs = [``]; parts.forEach((part, index) => { const crumbPath = parts.slice(0, index + 1).join("/"); crumbs.push(`/`); }); return crumbs.join(""); } function filePreviewTemplate(file) { const language = languageForPath(file.path); const meta = [language, formatBytes(file.size)].filter(Boolean).join(" · "); const header = `
    ${FILE_ICON} ${escapeHtml(file.path.split("/").pop())} ${escapeHtml(meta)}
    `; if (file.tooLarge) { return header + `
    File too large to preview
    Preview is limited to ${formatBytes(maxPreviewBytes)}.
    `; } if (file.isBinary) { return header + `
    Binary file
    Binary content cannot be previewed.
    `; } if (isMarkdownPath(file.path)) { return header + `
    ${renderMarkdown(file.content || "")}
    `; } return header + `
    ${highlightCode(file.content || "", language)}
    `; } function viewerTemplate() { const { viewer } = getState(); if (!viewer.source) { return `

    Repository Viewer

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

    Find local repositories

    Shows only local repos that belong to the selected Gitea server.

    ${localRepoScanTemplate()}

    Repositories

    ${visibleRepos.length} shown
    ${repoOwnerFilter === "orgs" ? (() => { const groups = groupedByOrg(visibleRepos); const orgNames = Object.keys(groups).sort(); if (!orgNames.length) { return `
    No organisation repositories found
    Try refreshing or check your server connection.
    `; } return orgNames.map((org) => `
    ${escapeHtml(org)} ${groups[org].length} repo${groups[org].length !== 1 ? "s" : ""}
    ${groups[org].map((repo) => repoCardTemplate(repo)).join("")}
    `).join(""); })() : `
    ${visibleRepos.length ? visibleRepos.map((repo) => repoCardTemplate(repo)).join("") : `
    No repositories found
    Try refreshing or check your server connection.
    `}
    `}
    ` : activeMainTab === "local" ? `
    ${state.selectedRepoName ? `` : `
    ${LOCAL_REPO_ICON} Current repository No repository selected
    `}

    ${escapeHtml(state.selectedRepoName || "Local Repository")}

    ${state.selectedRepoName ? `
    ${escapeHtml(state.selectedRepoPath)}
    ` : `
    No repository selected
    Pick a repository from Recent repositories on the left, or scan for local repos on the Repositories tab.
    `} ${state.selectedRepoName ? `
    Commit message
    Commit support coming in the next milestone.
    ${gitOutput ? `
    ${escapeHtml(gitOutput)}
    ` : `

    Run a git command to see output here.

    `} ` : ""}
    ` : viewerTemplate()}
    `; 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(); });