import { fetchCurrentUser, fetchRepoBranches, fetchRepoContents, fetchRepositories } from "./gitea-api.js"; import { getActiveServer, getState, setSettings, updateSettings, addRecentRepo } from "./state.js"; import { browseApplication, browseDirectory, checkoutBranch, commitChanges, createBranch, deleteBranch, getCommitDetail, getCommitHistory, getFileDiff, getRepositorySyncStatus, getWorkingTreeStatus, listLocalRepoBranches, listLocalRepoTree, openInExternalEditor, openInFileExplorer, readLocalRepoFile, renameBranch, runGitBranch, runGitClone, runGitFetch, runGitPull, runGitPublishBranch, runGitPush, runGitSync, runGitStatus, scanInstalledIdes, 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 = ""; let serverTestResult = ""; let settingsNotice = ""; let gitOutput = ""; let activeView = "changes"; // "changes" | "history" let activeModal = ""; // "" | "repos" | "clone" | "servers" | "settings" | "viewer" let utilityMenuOpen = false; let repoOwnerFilter = "all"; const maxPreviewBytes = 256 * 1024; const defaultRepositoryName = "Gitpub-Desktop"; const defaultBranchName = "main"; const DEFAULT_EDITOR_VALUE = "__default_code__"; const CUSTOM_EDITOR_VALUE = "__custom__"; const FOLDER_ICON = ``; const FILE_ICON = ``; const LOCAL_REPO_ICON = ``; const BRANCH_ICON = ``; const SYNC_ICON = ``; const PULL_ICON = ``; const PUSH_ICON = ``; const PUBLISH_ICON = ``; const EXPLORER_ICON = ``; const EDITOR_ICON = ``; const WINDOW_MINIMIZE_ICON = ``; const WINDOW_MAXIMIZE_ICON = ``; const WINDOW_CLOSE_ICON = ``; function currentPlatform() { const platform = navigator.userAgentData?.platform || navigator.platform || ""; const userAgent = navigator.userAgent || ""; const value = `${platform} ${userAgent}`.toLowerCase(); if (value.includes("mac")) return "macos"; if (value.includes("win")) return "windows"; return "linux"; } function currentTauriWindow() { const tauriWindow = window.__TAURI__?.window; if (tauriWindow?.getCurrentWindow) { return tauriWindow.getCurrentWindow(); } return tauriWindow?.appWindow || null; } async function handleWindowAction(action) { const appWindow = currentTauriWindow(); if (!appWindow) { console.warn("Tauri window API not available"); return; } try { if (action === "minimize") { await appWindow.minimize(); } else if (action === "maximize") { const isMaximized = await appWindow.isMaximized(); if (isMaximized) { await appWindow.unmaximize(); } else { await appWindow.maximize(); } } else if (action === "close") { await appWindow.close(); } } catch (error) { console.error(`Unable to ${action} window:`, error); } } async function startWindowDrag() { const appWindow = currentTauriWindow(); if (!appWindow) return; try { await appWindow.startDragging(); } catch (error) { console.error("Unable to start window drag:", error); } } 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 repoNameFromUrl(url = "") { const cleanUrl = url.trim().split(/[?#]/)[0].replace(/\/+$/, "").replace(/\.git$/i, ""); return cleanUrl.split(/[/\\:]/).filter(Boolean).pop() || ""; } function joinDirectoryPath(parent = "", child = "") { if (!parent || !child) return parent || child; const separator = parent.includes("\\") ? "\\" : "/"; return `${parent.replace(/[\\/]+$/, "")}${separator}${child}`; } function currentRepositoryName() { return getState().selectedRepoName || defaultRepositoryName; } function currentBranchName() { return getState().workingTree.branch || getState().viewer.branch || defaultBranchName; } 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 applyTheme() { const theme = getState().settings.theme || "dark"; const systemPrefersLight = window.matchMedia?.("(prefers-color-scheme: light)")?.matches; document.documentElement.dataset.theme = theme === "system" && systemPrefersLight ? "light" : theme; } function selectedGitPath() { return getState().settings.gitExecutablePath; } function isDetectedEditorPath(value = "", detectedEditors = []) { const normalizedValue = value.trim().toLowerCase(); return detectedEditors.some((editor) => editor.executablePath.toLowerCase() === normalizedValue); } function selectedEditorDropdownValue(state) { const editorPath = state.settings.externalEditorPath?.trim() || ""; if (!editorPath) return DEFAULT_EDITOR_VALUE; return isDetectedEditorPath(editorPath, state.installedIdes) ? editorPath : CUSTOM_EDITOR_VALUE; } function externalEditorOptionsTemplate(state) { const editorPath = state.settings.externalEditorPath?.trim() || ""; const selectedValue = selectedEditorDropdownValue(state); const detectedOptions = state.installedIdes .map((editor) => ``) .join(""); return ` ${detectedOptions} `; } function windowControlsTemplate(isMacos = false) { const controls = isMacos ? [ { action: "close", label: "Close", icon: WINDOW_CLOSE_ICON, className: "gd-window-close" }, { action: "minimize", label: "Minimize", icon: WINDOW_MINIMIZE_ICON, className: "gd-window-minimize" }, { action: "maximize", label: "Maximize", icon: WINDOW_MAXIMIZE_ICON, className: "gd-window-maximize" }, ] : [ { action: "minimize", label: "Minimize", icon: WINDOW_MINIMIZE_ICON, className: "gd-window-minimize" }, { action: "maximize", label: "Maximize", icon: WINDOW_MAXIMIZE_ICON, className: "gd-window-maximize" }, { action: "close", label: "Close", icon: WINDOW_CLOSE_ICON, className: "gd-window-close" }, ]; return `
${controls.map((control) => ` `).join("")}
`; } function statusLabel(status = "") { const labels = { modified: "Modified", added: "Added", deleted: "Deleted", renamed: "Renamed", untracked: "Untracked", conflicted: "Conflicted", }; return labels[status] || "Modified"; } function pluralize(count, singular, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } function remoteDisplayName(sync = {}) { return sync.upstreamRemote || sync.defaultRemote || "origin"; } function syncButtonConfig(state) { if (!state.selectedRepoPath) { return { label: "No Repository", subLabel: "Select a repo to begin", tooltip: "Select a repository to sync.", icon: SYNC_ICON, action: "", disabled: true, }; } const sync = state.sync; const remote = remoteDisplayName(sync); const operationLabels = { fetch: "Fetching…", pull: "Pulling…", push: "Pushing…", sync: "Syncing…", publish: "Publishing…", }; if (sync.operation) { return { label: operationLabels[sync.operation] || "Working…", subLabel: "Git operation in progress", tooltip: "A Git operation is already running.", icon: sync.operation === "pull" ? PULL_ICON : sync.operation === "publish" || sync.operation === "push" ? PUSH_ICON : SYNC_ICON, action: sync.operation, disabled: true, loading: true, }; } if (sync.loading && !sync.lastUpdated) { return { label: "Loading…", subLabel: "Reading repository state", tooltip: "Reading repository sync state.", icon: SYNC_ICON, action: "", disabled: true, loading: true, }; } if (sync.isDetached) { return { label: "Detached HEAD", subLabel: "Checkout a branch to sync", tooltip: "Detached HEAD cannot be pushed or pulled. Checkout a branch first.", icon: BRANCH_ICON, action: "", disabled: true, }; } if (!sync.hasRemote && sync.lastUpdated) { return { label: "No remote", subLabel: "Add a remote to sync", tooltip: "This repository does not have a Git remote configured.", icon: SYNC_ICON, action: "", disabled: true, }; } if (sync.isUnpublished || (!sync.upstream && sync.hasRemote)) { return { label: "Publish branch", subLabel: `Publish ${sync.branch || "branch"} to ${remote}`, tooltip: `Publish ${sync.branch || "the current branch"} to ${remote}.`, icon: PUBLISH_ICON, action: "publish", disabled: false, }; } if (sync.behind > 0 && sync.ahead > 0) { return { label: "Sync changes", subLabel: `${pluralize(sync.behind, "commit")} to pull · ${pluralize(sync.ahead, "commit")} to push`, tooltip: `${pluralize(sync.behind, "commit")} to pull and ${pluralize(sync.ahead, "commit")} ready to push.`, icon: SYNC_ICON, action: "sync", disabled: false, }; } if (sync.behind > 0) { return { label: `Pull ${remote}`, subLabel: `${pluralize(sync.behind, "commit")} to pull`, tooltip: `${pluralize(sync.behind, "commit")} to pull from ${remote}.`, icon: PULL_ICON, action: "pull", disabled: false, }; } if (sync.ahead > 0) { return { label: `Push ${remote}`, subLabel: `${pluralize(sync.ahead, "commit")} ready to push`, tooltip: `${pluralize(sync.ahead, "commit")} ready to push to ${remote}.`, icon: PUSH_ICON, action: "push", disabled: false, }; } return { label: `Fetch ${remote}`, subLabel: sync.error || "Your branch is up to date", tooltip: sync.error ? `Last sync check failed: ${sync.error}` : "Your branch is up to date.", icon: SYNC_ICON, action: "fetch", disabled: false, }; } function groupedChangedFiles(files = []) { const order = ["modified", "added", "deleted", "renamed", "untracked", "conflicted"]; const groups = new Map(order.map((status) => [status, []])); files.forEach((file) => { const key = groups.has(file.status) ? file.status : "modified"; groups.get(key).push(file); }); return order.map((status) => ({ status, files: groups.get(status) })).filter((group) => group.files.length); } function diffTemplate(diffResult) { if (!diffResult) { return workflowEmptyStateTemplate({ title: "Select a changed file", message: "Choose a file from the changes list to inspect its diff before committing.", icon: "diff", actions: false, }); } if (diffResult.isBinary) { return workflowEmptyStateTemplate({ title: "Binary file", message: diffResult.diff || "Diff preview is not available for binary files.", icon: "file", actions: false, }); } if (diffResult.isDeleted && !diffResult.diff) { return workflowEmptyStateTemplate({ title: "Deleted file", message: "This file was removed from the working tree.", icon: "file", actions: false, }); } return `
${renderDiff(diffResult.diff || "")}
`; } function branchOptionTemplate(branch) { return ``; } function renderDiff(diff = "") { const lines = diff.split("\n"); return lines .map((line) => { let kind = "context"; if (line.startsWith("+++") || line.startsWith("---")) kind = "meta"; else if (line.startsWith("@@")) kind = "hunk"; else if (line.startsWith("+")) kind = "added"; else if (line.startsWith("-")) kind = "removed"; const sign = line.slice(0, 1) || " "; return `
${escapeHtml(sign)} ${escapeHtml(line || " ")}
`; }) .join(""); } function emptyStateIcon(type = "repo") { if (type === "diff") { return ``; } if (type === "file") { return ``; } return ``; } function workflowEmptyStateTemplate({ title, message, icon = "repo", actions = true }) { return `
${emptyStateIcon(icon)}

${escapeHtml(title)}

${escapeHtml(message)}

${actions ? `
` : ""}
`; } function openBranchDialog(mode, target = "") { const dialog = getState().branches.dialog; dialog.mode = mode; dialog.target = target; dialog.value = mode === "rename" ? target : ""; dialog.error = ""; getState().branches.menuOpen = false; render(); } function closeBranchDialog() { Object.assign(getState().branches.dialog, { mode: "", target: "", value: "", error: "" }); render(); } function branchDialogTemplate(state, displayBranchName) { const dialog = state.branches.dialog; if (!dialog.mode) return ""; const isDelete = dialog.mode === "delete"; const title = dialog.mode === "create" ? "Create Branch" : dialog.mode === "rename" ? "Rename Branch" : "Delete Branch"; const description = isDelete ? "Choose a local branch to delete. This only removes the local branch." : dialog.mode === "create" ? `Create a new branch from ${displayBranchName}.` : `Rename ${dialog.target}.`; return ``; } 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) { 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]; 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 localRepoScanTemplate() { const state = getState(); const results = state.localRepoScanResults || []; if (state.localRepoScanLoading) { return `
    Scanning local folders…
    `; } if (state.localRepoScanError) { return `
    ${escapeHtml(state.localRepoScanError)}
    `; } if (!results.length) { return `
    No local repositories scanned yet. Only repos with remotes from the selected server will appear.
    `; } return `
    ${results .map( (repo) => `
    ${escapeHtml(repo.name || repoNameFromPath(repo.path))} ${escapeHtml(repo.path)} ${repo.matchedRemoteUrl ? `${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)}
    `; } // ── Modal content functions ─────────────────────────────────────────────────── function reposModalContent(state) { const visibleRepos = filteredRepositories(); const activeServer = getActiveServer(); return `

    Recent Local Repositories

    ${state.settings.recentRepositories.length ? state.settings.recentRepositories.map((path) => `
    ${escapeHtml(repoNameFromPath(path))} ${escapeHtml(path)}
    `).join("") : `
    No recent repositories
    `}

    Open from Path

    Find Local Repositories

    ${localRepoScanTemplate()}

    Server Repositories

    ${escapeHtml(activeServer?.displayName || "No server")}
    ${visibleRepos.slice(0, 60).map((repo) => `
    ${escapeHtml(repo.full_name)} ${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}
    `).join("") || `
    No repositories found
    `}
    `; } function cloneModalContent(state) { const visibleRepos = filteredRepositories(); return `
    Server
    Remote repository
    Repository URL
    Destination path
    Choose a parent folder and Gitpub will create the repository folder inside it.
    ${gitOutput ? `
    ${escapeHtml(gitOutput)}
    ` : `Clone output will appear here.`}
    `; } function serversModalContent(state) { const servers = state.settings.servers; return `

    Connected Servers

    ${settingsNotice ? `
    ${escapeHtml(settingsNotice)}
    ` : ""} ${servers.length ? servers.map((server) => `
    ${escapeHtml(server.displayName)}
    ${escapeHtml(server.serverUrl)}
    `).join("") : `
    No servers configured
    `}
    Select a server to edit, or add a new one.
    `; } function settingsModalContent(state) { const editorDropdownValue = selectedEditorDropdownValue(state); const customEditorPath = editorDropdownValue === CUSTOM_EDITOR_VALUE ? state.settings.externalEditorPath?.trim() || "" : ""; return `

    Appearance

    Theme

    Git

    Git executable path
    Default clone directory
    Code editor
    ${state.installedIdeScanError ? `
    ${escapeHtml(state.installedIdeScanError)}
    ` : ""} ${!state.installedIdeScanLoading && !state.installedIdes.length ? `
    No installed IDEs detected yet. You can use the default code command or choose a custom app.
    ` : ""}
    Default server
    ${settingsNotice ? `
    ${escapeHtml(settingsNotice)}
    ` : ""}

    About

    Gitpub Desktop

    A focused desktop client for Gitea-compatible Git servers.

    Built with Tauri 2 and vanilla JavaScript.

    `; } function viewerModalContent(state) { const viewer = state.viewer; if (!viewer.source) { return `

    No repository open in viewer

    Open a repository from the Repositories panel first.

    `; } const rows = []; if (viewer.path) { rows.push(``); } viewer.entries.forEach((entry) => { const active = viewer.selectedFile?.path === entry.path ? " active" : ""; rows.push(``); }); const showReadme = viewer.readmeFile && !viewer.selectedFile; return `
    ${breadcrumbTemplate(viewer.repoName || "repo", viewer.path)}
    ${rows.join("")} ${!rows.length && !viewer.loading ? `
    Empty folder
    ` : ""}
    ${viewer.error ? `
    ${escapeHtml(viewer.error)}
    ` : ""} ${viewer.loading ? `
    Loading…
    ` : ""} ${viewer.selectedFile ? `
    ${filePreviewTemplate(viewer.selectedFile)}
    ` : ""} ${showReadme ? `
    ${FILE_ICON}${escapeHtml(viewer.readmeFile.path.split("/").pop())}
    ${renderMarkdown(viewer.readmeFile.content || "")}
    ` : ""} ${!viewer.selectedFile && !viewer.readmeFile && !viewer.loading ? `
    Select a file from the tree to preview
    ` : ""}
    `; } function modalTemplate(state) { if (!activeModal) return ""; const viewer = state.viewer; const configs = { repos: { title: "Repositories", content: reposModalContent(state), wide: true }, clone: { title: "Clone Repository", content: cloneModalContent(state), wide: false }, servers: { title: "Gitea Servers", content: serversModalContent(state), wide: true }, settings: { title: "Settings", content: settingsModalContent(state), wide: true }, viewer: { title: viewer.repoName ? `File Viewer — ${escapeHtml(viewer.repoName)}` : "File Viewer", content: viewerModalContent(state), wide: true, fullHeight: true }, }; const cfg = configs[activeModal]; if (!cfg) return ""; return ``; } // ── Main dashboard view ─────────────────────────────────────────────────────── function dashboardView() { const state = getState(); const displayRepoName = currentRepositoryName(); const displayBranchName = currentBranchName(); const hasLocalRepo = Boolean(state.selectedRepoPath); const isMacos = currentPlatform() === "macos"; const toolbarPlatformClass = isMacos ? "gd-toolbar-macos" : "gd-toolbar-standard"; const windowControlsHTML = windowControlsTemplate(isMacos); // ── Repository identity ───────────────────────────────────────────────── const repoIdentity = (() => { if (!state.selectedRepoPath && !state.selectedRepoName) { return { primary: "No repository", secondary: "Select or clone a repository" }; } const name = state.selectedRepoName || repoNameFromPath(state.selectedRepoPath); const serverRepo = repositories.find((r) => { const rName = r.full_name.split("/")[1] || ""; return rName === name || r.full_name === name; }); if (serverRepo) { return { primary: serverRepo.full_name, secondary: state.selectedRepoPath || "" }; } const pathParts = (state.selectedRepoPath || "").split(/[/\\]/).filter(Boolean); const primary = pathParts.length >= 2 ? pathParts.slice(-2).join("/") : name; return { primary, secondary: state.selectedRepoPath || "" }; })(); // ── Contextual sync action ────────────────────────────────────────────── const syncCfg = syncButtonConfig(state); // ── Toolbar ───────────────────────────────────────────────────────────── const toolbarHTML = ` ${isMacos ? windowControlsHTML : ""}
    ${state.branches.menuOpen ? `
    Switch Branch
    ${(state.branches.items.length ? state.branches.items : [{ name: displayBranchName, current: true }]).map(branchOptionTemplate).join("")}
    ` : ""}
    ${utilityMenuOpen ? `` : ""}
    ${isMacos ? "" : windowControlsHTML} `; // ── Sidebar content ───────────────────────────────────────────────────── const filteredFiles = state.workingTree.files.filter((file) => file.path.toLowerCase().includes(state.changesFilter.trim().toLowerCase()) ); const selectedCount = filteredFiles.filter((file) => state.workingTree.selectedPaths.has(file.path)).length; const allSelected = filteredFiles.length > 0 && selectedCount === filteredFiles.length; let leftContentHTML = ""; if (activeView === "history") { const historyFilter = state.historyFilter.trim().toLowerCase(); const commits = state.history.commits.filter((commit) => [commit.title, commit.author, commit.shortHash].some((v) => (v || "").toLowerCase().includes(historyFilter)) ); leftContentHTML = `
    ${state.history.loading ? `
    Loading…
    ` : ""} ${state.history.error ? `
    ${escapeHtml(state.history.error)}
    ` : ""} ${!hasLocalRepo ? `
    No repository selected
    ` : ""} ${hasLocalRepo && !state.history.loading && commits.length ? commits.map((commit) => ` `).join("") : ""} ${hasLocalRepo && !state.history.loading && !commits.length ? `
    No commits found
    ` : ""}
    `; } else { leftContentHTML = `
    ${selectedCount > 0 ? `${selectedCount} staged` : ""}
    ${state.workingTree.loading ? `
    Loading…
    ` : ""} ${state.workingTree.error ? `
    ${escapeHtml(state.workingTree.error)}
    ` : ""} ${!hasLocalRepo ? `
    No repository open
    ` : ""} ${hasLocalRepo && !state.workingTree.loading && !filteredFiles.length && !state.workingTree.error ? `
    Working tree is clean
    ` : ""} ${groupedChangedFiles(filteredFiles).map((group) => `
    ${statusLabel(group.status)}
    ${group.files.map((file) => ` `).join("")}
    `).join("")} ${gitOutput ? `
    ${escapeHtml(gitOutput)}
    ` : ""}
    `; } // ── Main area ─────────────────────────────────────────────────────────── let mainHTML = ""; if (activeView === "history") { const commit = state.history.selectedCommit; if (commit) { mainHTML = `

    ${escapeHtml(commit.title)}

    ${escapeHtml(commit.author)} · ${escapeHtml(commit.date)} · ${escapeHtml(commit.shortHash)}

    ${escapeHtml(commit.message)}
    ${(commit.files || []).map((file) => `${escapeHtml(file.status)} ${escapeHtml(file.path)}`).join("")}
    ${renderDiff(commit.diff || "No diff available.")}
    `; } else { mainHTML = workflowEmptyStateTemplate({ title: hasLocalRepo ? "No commit selected" : "Open a repository to view history", message: hasLocalRepo ? "Select a commit from the list to review its files and diff." : "History appears after you open or clone a local repository.", icon: "diff", actions: !hasLocalRepo, }); } } else { const selectedChange = state.workingTree.files.find((file) => file.path === state.workingTree.selectedPath); mainHTML = `

    ${selectedChange ? escapeHtml(selectedChange.path) : hasLocalRepo ? "No local changes" : "No repository selected"}

    ${selectedChange ? `${statusLabel(selectedChange.status)} file` : hasLocalRepo ? "The working tree is clean." : "Open or clone a repository to begin."}

    ${state.workingTree.diffLoading ? `
    Loading diff…
    ` : ""} ${state.workingTree.diffError ? `
    ${escapeHtml(state.workingTree.diffError)}
    ` : ""} ${selectedChange ? diffTemplate(state.workingTree.selectedDiff) : workflowEmptyStateTemplate({ title: hasLocalRepo ? "You're all caught up" : "Start with a repository", message: hasLocalRepo ? "There are no uncommitted changes in this repository." : "Open a local repository or clone one from a Gitea-compatible server.", icon: hasLocalRepo ? "diff" : "repo", actions: !hasLocalRepo, })}
    `; } // ── Commit panel ──────────────────────────────────────────────────────── const hasCommitSummary = Boolean((state.commitSummary || state.commitMessage || "").trim()); const hasSelectedFiles = state.workingTree.selectedPaths.size > 0; const canCommit = hasLocalRepo && hasSelectedFiles && hasCommitSummary; const commitAreaHTML = `
    `; // ── Assemble ───────────────────────────────────────────────────────────── appRoot.innerHTML = `
    ${toolbarHTML}
    ${mainHTML}
    ${modalTemplate(state)} ${branchDialogTemplate(state, displayBranchName)} `; bindDashboardEvents(); } // ── Server form events ──────────────────────────────────────────────────────── 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(); openServerForm(existingServer?.id || null); } catch (error) { serverTestResult = `Connection test failed: ${error.message}`; render(); openServerForm(existingServer?.id || null); } }); 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); } // ── Data loading ────────────────────────────────────────────────────────────── async function loadRepositories() { const activeServer = getActiveServer(); if (!activeServer) { repositories = [...mockRepos]; currentUserLogin = "alice"; 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 { // non-fatal } } function selectLocalRepo(path, name = "") { const state = getState(); state.localRepoPathInput = path; state.selectedRepoPath = path; state.selectedRepoName = name || repoNameFromPath(path); state.workingTree.selectedPath = ""; state.workingTree.selectedDiff = null; state.history.selectedHash = ""; state.history.selectedCommit = null; Object.assign(state.sync, { loading: false, error: "", operation: "", branch: "", upstream: "", upstreamRemote: "", defaultRemote: "", ahead: 0, behind: 0, hasRemote: false, isDetached: false, isUnpublished: false, lastUpdated: 0, }); addRecentRepo(path); } async function refreshBranches() { const state = getState(); if (!state.selectedRepoPath) { state.branches.items = []; return; } state.branches.loading = true; state.branches.error = ""; render(); try { state.branches.items = await listLocalRepoBranches(state.selectedRepoPath, selectedGitPath()); } catch (error) { state.branches.error = `Unable to load branches: ${error.message}`; } finally { state.branches.loading = false; render(); } } async function refreshWorkingTree() { const state = getState(); const tree = state.workingTree; if (!state.selectedRepoPath) { Object.assign(tree, { loading: false, error: "", branch: "", upstream: "", ahead: 0, behind: 0, files: [], selectedPath: "", selectedDiff: null }); render(); return; } tree.loading = true; tree.error = ""; render(); try { const status = await getWorkingTreeStatus(state.selectedRepoPath, selectedGitPath()); const previousSelection = new Set(tree.selectedPaths); tree.branch = status.branch || ""; tree.upstream = status.upstream || ""; tree.ahead = status.ahead || 0; tree.behind = status.behind || 0; tree.files = status.files || []; tree.selectedPaths = new Set( tree.files .map((file) => file.path) .filter((path) => previousSelection.size ? previousSelection.has(path) : true) ); if (!tree.files.some((file) => file.path === tree.selectedPath)) { tree.selectedPath = tree.files[0]?.path || ""; tree.selectedDiff = null; } if (tree.selectedPath) { await loadSelectedDiff(tree.selectedPath, false); } } catch (error) { tree.error = `Unable to load changes: ${error.message}`; } finally { tree.loading = false; render(); } } async function refreshSyncState({ silent = false } = {}) { const state = getState(); const sync = state.sync; const repoPath = state.selectedRepoPath; if (!repoPath) { Object.assign(sync, { loading: false, error: "", branch: "", upstream: "", upstreamRemote: "", defaultRemote: "", ahead: 0, behind: 0, hasRemote: false, isDetached: false, isUnpublished: false, lastUpdated: 0, }); if (!silent) render(); return; } sync.loading = true; sync.error = ""; if (!silent) render(); try { const status = await getRepositorySyncStatus(repoPath, selectedGitPath()); if (getState().selectedRepoPath !== repoPath) return; Object.assign(sync, { branch: status.branch || "", upstream: status.upstream || "", upstreamRemote: status.upstreamRemote || "", defaultRemote: status.defaultRemote || "", ahead: status.ahead || 0, behind: status.behind || 0, hasRemote: Boolean(status.hasRemote), isDetached: Boolean(status.isDetached), isUnpublished: Boolean(status.isUnpublished), lastUpdated: Date.now(), }); state.workingTree.branch = sync.branch; state.workingTree.upstream = sync.upstream; state.workingTree.ahead = sync.ahead; state.workingTree.behind = sync.behind; } catch (error) { if (getState().selectedRepoPath !== repoPath) return; sync.error = error.message; } finally { if (getState().selectedRepoPath === repoPath) { sync.loading = false; render(); } } } async function loadSelectedDiff(path, shouldRender = true) { const state = getState(); const tree = state.workingTree; const file = tree.files.find((item) => item.path === path); if (!state.selectedRepoPath || !file) return; tree.selectedPath = path; tree.diffLoading = true; tree.diffError = ""; if (shouldRender) render(); try { tree.selectedDiff = await getFileDiff(state.selectedRepoPath, file.path, file.status, selectedGitPath()); } catch (error) { tree.selectedDiff = null; tree.diffError = `Unable to load diff: ${error.message}`; } finally { tree.diffLoading = false; if (shouldRender) render(); } } async function refreshHistory() { const state = getState(); const history = state.history; if (!state.selectedRepoPath) { Object.assign(history, { loading: false, error: "", commits: [], selectedHash: "", selectedCommit: null }); render(); return; } history.loading = true; history.error = ""; render(); try { history.commits = await getCommitHistory(state.selectedRepoPath, 100, selectedGitPath()); if (!history.selectedHash || !history.commits.some((commit) => commit.hash === history.selectedHash)) { history.selectedHash = history.commits[0]?.hash || ""; history.selectedCommit = null; } if (history.selectedHash) { await loadCommitDetail(history.selectedHash, false); } } catch (error) { history.error = `Unable to load history: ${error.message}`; } finally { history.loading = false; render(); } } async function loadCommitDetail(hash, shouldRender = true) { const state = getState(); if (!state.selectedRepoPath || !hash) return; state.history.selectedHash = hash; state.history.error = ""; if (shouldRender) render(); try { state.history.selectedCommit = await getCommitDetail(state.selectedRepoPath, hash, selectedGitPath()); } catch (error) { state.history.error = `Unable to load commit: ${error.message}`; } finally { if (shouldRender) render(); } } async function refreshRepoData() { await Promise.all([refreshWorkingTree(), refreshSyncState({ silent: true }), refreshBranches(), refreshHistory()]); } 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 loaded for the selected server. Refresh 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 server were found."; } } 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: "", }); activeModal = "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."; 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: "", }); activeModal = "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 refreshInstalledIdes() { const state = getState(); state.installedIdeScanLoading = true; state.installedIdeScanError = ""; render(); try { state.installedIdes = await scanInstalledIdes(); } catch (error) { state.installedIdes = []; state.installedIdeScanError = `Unable to scan installed IDEs: ${error.message}`; } finally { state.installedIdeScanLoading = false; render(); } } async function openSelectedRepoInFileExplorer() { const state = getState(); if (!state.selectedRepoPath) { gitOutput = "Select or enter a local repository path first."; render(); return; } try { await openInFileExplorer(state.selectedRepoPath); gitOutput = `Opened in File Explorer:\n${state.selectedRepoPath}`; } catch (error) { gitOutput = `Open in File Explorer failed: ${error.message}`; } finally { utilityMenuOpen = false; render(); } } async function openSelectedRepoInCodeEditor() { const state = getState(); if (!state.selectedRepoPath) { gitOutput = "Select or enter a local repository path first."; render(); return; } const editorCommand = state.settings.externalEditorPath?.trim() || "code"; try { await openInExternalEditor(state.selectedRepoPath, editorCommand); gitOutput = `Opened in Code Editor with "${editorCommand}":\n${state.selectedRepoPath}`; } catch (error) { gitOutput = `Open in Code Editor failed: ${error.message}`; } finally { utilityMenuOpen = false; render(); } } async function runRepoCommand(actionName, runner, operation = "") { const state = getState(); if (!state.selectedRepoPath) { gitOutput = "Select or enter a local repository path first."; render(); return; } state.sync.operation = operation; state.sync.error = ""; render(); 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}`; state.sync.error = error.message; } finally { state.sync.operation = ""; } render(); } // ── Event bindings ──────────────────────────────────────────────────────────── function bindDashboardEvents() { const state = getState(); // View toggle (Changes / History) document.querySelectorAll("[data-view]").forEach((btn) => { btn.addEventListener("click", () => { const view = btn.dataset.view; if (view === activeView) return; activeView = view; render(); if (activeView === "changes") refreshWorkingTree(); if (activeView === "history") refreshHistory(); }); }); // Open modal buttons (data-open-modal anywhere in the page) document.querySelectorAll("[data-open-modal]").forEach((btn) => { btn.addEventListener("click", () => { activeModal = btn.dataset.openModal; utilityMenuOpen = false; render(); if (activeModal === "settings" && !state.installedIdeScanLoading && !state.installedIdes.length) { refreshInstalledIdes(); } }); }); // Utility menu toggle document.getElementById("utility-menu-btn")?.addEventListener("click", (event) => { event.stopPropagation(); utilityMenuOpen = !utilityMenuOpen; render(); }); document.getElementById("open-file-explorer-btn")?.addEventListener("click", openSelectedRepoInFileExplorer); document.getElementById("open-file-explorer-menu-btn")?.addEventListener("click", openSelectedRepoInFileExplorer); document.getElementById("open-code-editor-btn")?.addEventListener("click", openSelectedRepoInCodeEditor); document.getElementById("open-code-editor-menu-btn")?.addEventListener("click", openSelectedRepoInCodeEditor); document.querySelectorAll("[data-window-action]").forEach((button) => { button.addEventListener("click", () => handleWindowAction(button.dataset.windowAction || "")); }); document.querySelector(".gd-toolbar-drag-space")?.addEventListener("mousedown", startWindowDrag); document.querySelector(".gd-toolbar-drag-space")?.addEventListener("dblclick", () => handleWindowAction("maximize")); // Modal close document.getElementById("modal-close-btn")?.addEventListener("click", () => { activeModal = ""; render(); }); document.getElementById("modal-backdrop")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) { activeModal = ""; render(); } }); // Contextual sync button document.getElementById("git-sync-action-btn")?.addEventListener("click", () => { const action = document.getElementById("git-sync-action-btn")?.dataset.syncAction || "fetch"; if (action === "pull") { runRepoCommand("Pull", () => runGitPull(state.selectedRepoPath, selectedGitPath()), "pull").then(refreshRepoData); } else if (action === "push") { runRepoCommand("Push", () => runGitPush(state.selectedRepoPath, selectedGitPath()), "push").then(refreshRepoData); } else if (action === "publish") { runRepoCommand( "Publish", () => runGitPublishBranch(state.selectedRepoPath, state.sync.defaultRemote || "origin", selectedGitPath()), "publish" ).then(refreshRepoData); } else if (action === "sync") { runRepoCommand("Sync", () => runGitSync(state.selectedRepoPath, selectedGitPath()), "sync").then(refreshRepoData); } else { runRepoCommand("Fetch", () => runGitFetch(state.selectedRepoPath, selectedGitPath()), "fetch").then(refreshRepoData); } }); // Changes filter document.getElementById("changes-filter-input")?.addEventListener("input", (event) => { state.changesFilter = event.target.value; render(); }); // History filter document.getElementById("history-filter-input")?.addEventListener("input", (event) => { state.historyFilter = event.target.value; render(); }); // Repo search (in modal) document.getElementById("repo-search-input")?.addEventListener("input", (event) => { state.repoSearch = event.target.value; render(); }); // Refresh repos (in modal) document.getElementById("refresh-repos-btn")?.addEventListener("click", async () => { await loadRepositories(); render(); }); // Owner filter pills document.querySelectorAll("[data-owner-filter]").forEach((btn) => { btn.addEventListener("click", () => { repoOwnerFilter = btn.dataset.ownerFilter; render(); }); }); // View repo (opens file viewer modal) 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); }); }); // Clone repo (prefill clone modal) document.querySelectorAll(".clone-repo-btn").forEach((button) => { button.addEventListener("click", () => { state.cloneUrlInput = button.dataset.cloneUrl || ""; activeModal = "clone"; render(); }); }); // Scan local repos document.getElementById("scan-local-repos-btn")?.addEventListener("click", () => { scanForLocalRepos(); }); // Use a scanned repo document.querySelectorAll(".use-scanned-repo-btn").forEach((button) => { button.addEventListener("click", () => { selectLocalRepo(button.dataset.repoPath || "", button.dataset.repoName || ""); activeModal = ""; activeView = "changes"; render(); refreshRepoData(); }); }); // View files of a scanned repo document.querySelectorAll(".view-scanned-repo-btn").forEach((button) => { button.addEventListener("click", () => { selectLocalRepo(button.dataset.repoPath || "", button.dataset.repoName || ""); openLocalViewer(); }); }); // Open recent repo document.querySelectorAll(".open-recent-repo-btn").forEach((button) => { button.addEventListener("click", () => { selectLocalRepo(button.dataset.recentRepoPath || ""); activeModal = ""; activeView = "changes"; render(); refreshRepoData(); }); }); // Remove recent repo document.querySelectorAll(".remove-recent-repo-btn").forEach((button) => { button.addEventListener("click", () => { const path = button.dataset.recentRepoPath || ""; updateSettings({ recentRepositories: state.settings.recentRepositories.filter((item) => item !== path), lastSelectedRepoPath: state.settings.lastSelectedRepoPath === path ? "" : state.settings.lastSelectedRepoPath, }); if (state.selectedRepoPath === path) { state.selectedRepoPath = ""; state.selectedRepoName = ""; } render(); }); }); // Open local repo by path (repos modal) document.getElementById("open-local-repo-btn")?.addEventListener("click", () => { const value = document.getElementById("local-repo-path-input")?.value?.trim() || ""; if (!value) return; selectLocalRepo(value); activeModal = ""; activeView = "changes"; render(); refreshRepoData(); }); // Select all changes document.getElementById("select-all-changes")?.addEventListener("change", (event) => { const visiblePaths = state.workingTree.files .filter((file) => file.path.toLowerCase().includes(state.changesFilter.trim().toLowerCase())) .map((file) => file.path); if (event.target.checked) { visiblePaths.forEach((path) => state.workingTree.selectedPaths.add(path)); } else { visiblePaths.forEach((path) => state.workingTree.selectedPaths.delete(path)); } render(); }); // File checkboxes document.querySelectorAll(".change-file-checkbox").forEach((checkbox) => { checkbox.addEventListener("click", (event) => event.stopPropagation()); checkbox.addEventListener("change", (event) => { const path = checkbox.dataset.changeCheckbox; if (event.target.checked) { state.workingTree.selectedPaths.add(path); } else { state.workingTree.selectedPaths.delete(path); } render(); }); }); // Change file row click (load diff) document.querySelectorAll(".change-file-row").forEach((button) => { button.addEventListener("click", () => { loadSelectedDiff(button.dataset.changePath || ""); }); }); // Commit summary input document.getElementById("commit-summary-input")?.addEventListener("input", (event) => { state.commitSummary = event.target.value; state.commitMessage = event.target.value; // Re-render to update button disabled state const btn = document.getElementById("commit-btn"); if (btn) { const hasSelectedFiles = state.workingTree.selectedPaths.size > 0; const hasRepo = Boolean(state.selectedRepoPath); const hasSummary = Boolean(event.target.value.trim()); btn.disabled = !(hasRepo && hasSelectedFiles && hasSummary); } }); // Commit description input document.getElementById("commit-description-input")?.addEventListener("input", (event) => { state.commitDescription = event.target.value; }); // Commit button document.getElementById("commit-btn")?.addEventListener("click", async () => { state.commitSummary = document.getElementById("commit-summary-input")?.value?.trim() || ""; state.commitDescription = document.getElementById("commit-description-input")?.value || ""; const selectedPaths = [...state.workingTree.selectedPaths]; if (!state.commitSummary) { gitOutput = "Commit summary is required."; render(); return; } if (!selectedPaths.length) { gitOutput = "Select at least one changed file before committing."; render(); return; } try { const result = await commitChanges( state.selectedRepoPath, selectedPaths, state.commitSummary, state.commitDescription, selectedGitPath() ); gitOutput = `${result.command}\n\n${result.stdout || "Commit created."}\n${result.stderr || ""}`; state.commitSummary = ""; state.commitMessage = ""; state.commitDescription = ""; await refreshRepoData(); } catch (error) { gitOutput = `Commit failed: ${error.message}`; render(); } }); // Branch menu button document.getElementById("branch-menu-btn")?.addEventListener("click", () => { state.branches.menuOpen = !state.branches.menuOpen; render(); }); // Branch menu items document.querySelectorAll(".branch-menu-item").forEach((button) => { button.addEventListener("click", async () => { const branch = button.dataset.branchName || ""; if (!branch || branch === state.workingTree.branch) { state.branches.menuOpen = false; render(); return; } try { const result = await checkoutBranch(state.selectedRepoPath, branch, selectedGitPath()); gitOutput = `${result.command}\n\n${result.stdout || "Branch switched."}\n${result.stderr || ""}`; state.branches.menuOpen = false; await refreshRepoData(); } catch (error) { gitOutput = `Branch switch failed: ${error.message}`; state.branches.menuOpen = false; render(); } }); }); // Branch menu actions (create/rename/delete dialog) document.querySelectorAll(".branch-menu-action").forEach((button) => { button.addEventListener("click", () => { openBranchDialog(button.dataset.branchAction, button.dataset.branchName || ""); }); }); // Branch dialog document.getElementById("branch-dialog-cancel")?.addEventListener("click", () => closeBranchDialog()); document.getElementById("branch-dialog-input")?.addEventListener("input", (event) => { state.branches.dialog.value = event.target.value; }); document.getElementById("branch-dialog-confirm")?.addEventListener("click", async () => { const dialog = state.branches.dialog; const value = document.getElementById("branch-dialog-input")?.value?.trim() || ""; if (!value) { dialog.error = dialog.mode === "delete" ? "Choose a branch to delete." : "Branch name is required."; render(); return; } try { const runner = dialog.mode === "create" ? () => createBranch(state.selectedRepoPath, value, selectedGitPath()) : dialog.mode === "rename" ? () => renameBranch(state.selectedRepoPath, dialog.target, value, selectedGitPath()) : () => deleteBranch(state.selectedRepoPath, value, false, selectedGitPath()); const result = await runner(); gitOutput = `${result.command}\n\n${result.stdout || "Branch updated."}\n${result.stderr || ""}`; Object.assign(dialog, { mode: "", target: "", value: "", error: "" }); await refreshRepoData(); } catch (error) { dialog.error = error.message; render(); } }); // Commit items in history document.querySelectorAll(".commit-item").forEach((button) => { button.addEventListener("click", () => loadCommitDetail(button.dataset.commitHash || "")); }); // Refresh buttons document.getElementById("refresh-changes-btn")?.addEventListener("click", () => Promise.all([refreshWorkingTree(), refreshSyncState({ silent: true })])); document.getElementById("refresh-changes-main-btn")?.addEventListener("click", () => Promise.all([refreshWorkingTree(), refreshSyncState({ silent: true })])); document.getElementById("refresh-history-btn")?.addEventListener("click", () => refreshHistory()); // Clone modal handlers document.getElementById("clone-server-select")?.addEventListener("change", async (event) => { updateSettings({ activeServerId: event.target.value || null }); await loadRepositories(); render(); }); document.getElementById("clone-repo-select")?.addEventListener("change", (event) => { state.cloneUrlInput = event.target.value; render(); }); document.getElementById("clone-destination-browse-btn")?.addEventListener("click", async () => { state.cloneUrlInput = document.getElementById("clone-url-input")?.value?.trim() || ""; let selectedDirectory = ""; try { selectedDirectory = await browseDirectory(state.settings.defaultCloneDirectory || ""); } catch (error) { gitOutput = `Could not open folder picker: ${error.message}`; render(); return; } if (!selectedDirectory) return; const repoName = repoNameFromUrl(state.cloneUrlInput); state.cloneDestinationInput = repoName ? joinDirectoryPath(selectedDirectory, repoName) : selectedDirectory; render(); }); 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 || ""}`; selectLocalRepo(state.cloneDestinationInput); activeModal = ""; activeView = "changes"; await refreshRepoData(); } catch (error) { gitOutput = `Clone failed: ${error.message}`; } render(); }); // Settings modal handlers document.getElementById("external-editor-select")?.addEventListener("change", (event) => { document.getElementById("custom-editor-row")?.classList.toggle("hidden", event.target.value !== CUSTOM_EDITOR_VALUE); }); document.getElementById("rescan-editors-btn")?.addEventListener("click", () => refreshInstalledIdes()); document.getElementById("custom-editor-browse-btn")?.addEventListener("click", async () => { const currentValue = document.getElementById("custom-editor-input")?.value?.trim() || ""; let selectedApplication = ""; try { selectedApplication = await browseApplication(currentValue); } catch (error) { settingsNotice = `Could not open application picker: ${error.message}`; render(); return; } if (!selectedApplication) return; const input = document.getElementById("custom-editor-input"); if (input) input.value = selectedApplication; }); document.getElementById("save-basic-settings-btn")?.addEventListener("click", () => { const editorSelection = document.getElementById("external-editor-select")?.value || DEFAULT_EDITOR_VALUE; const customEditorPath = document.getElementById("custom-editor-input")?.value?.trim() || ""; const externalEditorPath = editorSelection === DEFAULT_EDITOR_VALUE ? "" : editorSelection === CUSTOM_EDITOR_VALUE ? customEditorPath : editorSelection; 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() || "", externalEditorPath, activeServerId: document.getElementById("default-server-select")?.value || null, autoFetchOnRepoOpen: Boolean(document.getElementById("auto-fetch-checkbox")?.checked), }); applyTheme(); settingsNotice = "Saved."; render(); }); // Servers modal handlers 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 = "Active 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)); }); // Viewer handlers (inside viewer modal) 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() { applyTheme(); dashboardView(); } window.addEventListener("DOMContentLoaded", async () => { applyTheme(); await loadRepositories(); if (getState().selectedRepoPath) { await refreshRepoData(); if (getState().settings.autoFetchOnRepoOpen) { runRepoCommand("Fetch", () => runGitFetch(getState().selectedRepoPath, selectedGitPath()), "fetch").then(refreshRepoData); } } window.setInterval(() => { const state = getState(); if (!state.selectedRepoPath || state.sync.operation || state.sync.loading) return; refreshSyncState({ silent: true }); }, 30000); render(); });