Files
Gitpub-Desktop/frontend/js/app.js
T
andrew 558cd3ddd7 Updated taskbar to be OS agnostic
Updated task bar to use MacOS traffic lights on left when on MacOS and the regular buttons on the right when it is Windows or Linux
2026-05-13 13:28:11 +12:00

2642 lines
111 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="#54aeff" d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>`;
const FILE_ICON = `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="#848d97" d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>`;
const LOCAL_REPO_ICON = `<svg width="18" height="18" viewBox="0 0 16 16" aria-hidden="true" fill="none"><rect x="1.5" y="3" width="13" height="8.5" rx="1.25" stroke="currentColor" stroke-width="1.3"/><path d="M6 13.75h4M8 11.5v2.25" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`;
const BRANCH_ICON = `<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>`;
const SYNC_ICON = `<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7 7 0 0 1 14.95 7.16a.75.75 0 1 1-1.49.178A5.501 5.501 0 0 0 8 2.5ZM1.705 8.005a.75.75 0 0 1 .834.656 5.501 5.501 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7 7 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834Z"/></svg>`;
const PULL_ICON = `<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M7.25 1.75a.75.75 0 0 1 1.5 0v8.69l2.72-2.72a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734l-4 4a.75.75 0 0 1-1.06 0l-4-4A.749.749 0 0 1 4.53 7.72l2.72 2.72V1.75ZM2.75 14a.75.75 0 0 1 .75-.75h9a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1-.75-.75Z"/></svg>`;
const PUSH_ICON = `<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M7.47 3.22a.75.75 0 0 1 1.06 0l4 4A.749.749 0 0 1 12 8.5a.749.749 0 0 1-.53-.22L8.75 5.56v8.69a.75.75 0 0 1-1.5 0V5.56L4.53 8.28A.749.749 0 0 1 3.255 7.954a.749.749 0 0 1 .215-.734l4-4ZM2.75 2a.75.75 0 0 1 .75-.75h9a.75.75 0 0 1 0 1.5h-9A.75.75 0 0 1 2.75 2Z"/></svg>`;
const PUBLISH_ICON = `<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 1.25a.75.75 0 0 1 .75.75v1.13A3.001 3.001 0 0 1 11 6v.5h1.25A1.75 1.75 0 0 1 14 8.25v4A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-4A1.75 1.75 0 0 1 3.75 6.5H5V6a3.001 3.001 0 0 1 2.25-2.87V2A.75.75 0 0 1 8 1.25ZM6.5 6v.5h3V6a1.5 1.5 0 0 0-3 0Zm-.53 4.28 1.5-1.5a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 1 1-1.06 1.06l-.22-.22v1.13a.75.75 0 0 1-1.5 0v-1.13l-.22.22a.75.75 0 0 1-1.06-1.06Z"/></svg>`;
const EXPLORER_ICON = `<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M1.75 2A1.75 1.75 0 0 0 0 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0 0 16 12.25v-6.5A1.75 1.75 0 0 0 14.25 4H7.5a.25.25 0 0 1-.2-.1l-.9-1.2A1.75 1.75 0 0 0 5 2H1.75Zm0 1.5H5a.25.25 0 0 1 .2.1l.9 1.2c.331.441.85.7 1.4.7h6.75a.25.25 0 0 1 .25.25v6.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25Z"/></svg>`;
const EDITOR_ICON = `<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-7.7 7.7a1.75 1.75 0 0 1-.744.44l-3.018.862a.75.75 0 0 1-.927-.927l.862-3.018a1.75 1.75 0 0 1 .44-.744l7.527-7.873ZM12.427 2.487a.25.25 0 0 0-.354 0l-.963.963 1.44 1.44.963-.963a.25.25 0 0 0 0-.354l-1.086-1.086ZM11.49 5.95l-1.44-1.44-5.503 5.503a.25.25 0 0 0-.063.106l-.558 1.955 1.955-.558a.25.25 0 0 0 .106-.063L11.49 5.95ZM1 14.25a.75.75 0 0 1 .75-.75h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1-.75-.75Z"/></svg>`;
const WINDOW_MINIMIZE_ICON = `<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M2 6.75h8v1.5H2z"/></svg>`;
const WINDOW_MAXIMIZE_ICON = `<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M2.75 2h6.5c.414 0 .75.336.75.75v6.5a.75.75 0 0 1-.75.75h-6.5A.75.75 0 0 1 2 9.25v-6.5c0-.414.336-.75.75-.75Zm.75 1.5v5h5v-5h-5Z"/></svg>`;
const WINDOW_CLOSE_ICON = `<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M3.28 2.22a.75.75 0 0 0-1.06 1.06L4.94 6 2.22 8.72a.75.75 0 1 0 1.06 1.06L6 7.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L7.06 6l2.72-2.72a.75.75 0 0 0-1.06-1.06L6 4.94 3.28 2.22Z"/></svg>`;
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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) => `<option value="${escapeHtml(editor.executablePath)}" ${selectedValue === editor.executablePath ? "selected" : ""}>${escapeHtml(editor.name)}</option>`)
.join("");
return `
<option value="${DEFAULT_EDITOR_VALUE}" ${selectedValue === DEFAULT_EDITOR_VALUE ? "selected" : ""}>VS Code command (code)</option>
${detectedOptions}
<option value="${CUSTOM_EDITOR_VALUE}" ${selectedValue === CUSTOM_EDITOR_VALUE ? "selected" : ""}>Custom application…</option>
`;
}
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 `<div class="gd-window-controls" role="group" aria-label="Window controls">
${controls.map((control) => `
<button class="gd-window-control ${control.className}" type="button" data-window-action="${control.action}" title="${control.label}" aria-label="${control.label} window">${control.icon}</button>
`).join("")}
</div>`;
}
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 `<div class="diff-preview diff-preview-inline">${renderDiff(diffResult.diff || "")}</div>`;
}
function branchOptionTemplate(branch) {
return `<button class="branch-menu-item ${branch.current ? "active" : ""}" data-branch-name="${escapeHtml(branch.name)}" type="button">
<span>${escapeHtml(branch.name)}</span>
${branch.current ? `<span class="branch-current-mark">Current</span>` : ""}
</button>`;
}
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 `<div class="diff-line diff-line-${kind}">
<span class="diff-gutter">${escapeHtml(sign)}</span>
<code>${escapeHtml(line || " ")}</code>
</div>`;
})
.join("");
}
function emptyStateIcon(type = "repo") {
if (type === "diff") {
return `<svg viewBox="0 0 96 96" aria-hidden="true"><rect x="19" y="14" width="58" height="68" rx="8"/><path d="M34 34h28M34 48h18M34 62h28"/></svg>`;
}
if (type === "file") {
return `<svg viewBox="0 0 96 96" aria-hidden="true"><path d="M27 12h28l18 18v54H27z"/><path d="M55 12v20h18"/></svg>`;
}
return `<svg viewBox="0 0 96 96" aria-hidden="true"><path d="M18 30h60v42a8 8 0 0 1-8 8H26a8 8 0 0 1-8-8z"/><path d="M26 22h20l8 8"/></svg>`;
}
function workflowEmptyStateTemplate({ title, message, icon = "repo", actions = true }) {
return `<div class="workflow-empty-state">
<div class="workflow-empty-icon">${emptyStateIcon(icon)}</div>
<h2>${escapeHtml(title)}</h2>
<p>${escapeHtml(message)}</p>
${actions ? `<div class="workflow-empty-actions">
<button class="primary-blue" type="button" data-open-modal="clone">Clone Repository</button>
<button type="button" data-open-modal="repos">Open Repository</button>
</div>` : ""}
</div>`;
}
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 `<div class="modal-backdrop" role="presentation">
<div class="modal-card" role="dialog" aria-modal="true" aria-label="${escapeHtml(title)}">
<h3>${escapeHtml(title)}</h3>
<p class="muted">${escapeHtml(description)}</p>
${isDelete ? `
<select id="branch-dialog-input">
<option value="">Select branch...</option>
${state.branches.items.filter((branch) => !branch.current).map((branch) => `<option value="${escapeHtml(branch.name)}" ${branch.name === dialog.target ? "selected" : ""}>${escapeHtml(branch.name)}</option>`).join("")}
</select>
<div class="danger-note">This action cannot delete the current branch and may fail if the branch is not fully merged.</div>
` : `
<input id="branch-dialog-input" value="${escapeHtml(dialog.value)}" placeholder="Branch name" />
`}
${dialog.error ? `<div class="viewer-error">${escapeHtml(dialog.error)}</div>` : ""}
<div class="modal-actions">
<button id="branch-dialog-cancel" type="button">Cancel</button>
<button id="branch-dialog-confirm" class="${isDelete ? "danger" : "primary"}" type="button">${isDelete ? "Delete Branch" : "Save"}</button>
</div>
</div>
</div>`;
}
function languageForPath(path = "") {
const extension = path.split(".").pop()?.toLowerCase();
const names = {
js: "JavaScript",
ts: "TypeScript",
tsx: "TypeScript",
jsx: "JavaScript",
rs: "Rust",
json: "JSON",
css: "CSS",
html: "HTML",
htm: "HTML",
md: "Markdown",
toml: "TOML",
yml: "YAML",
yaml: "YAML",
};
return names[extension] || "Text";
}
function highlightCode(content = "", language = "Text") {
let html = escapeHtml(content);
if (["JavaScript", "TypeScript", "Rust"].includes(language)) {
html = html.replace(
/\b(async|await|const|let|var|function|return|if|else|for|while|class|struct|enum|impl|fn|pub|use|mod|match|Ok|Err|true|false|null)\b/g,
'<span class="syntax-keyword">$1</span>'
);
} else if (language === "JSON") {
html = html.replace(/(&quot;[^&]*?&quot;)(\s*:)/g, '<span class="syntax-key">$1</span>$2');
} else if (language === "CSS") {
html = html.replace(/([.#]?[a-zA-Z0-9_-]+)(\s*\{)/g, '<span class="syntax-key">$1</span>$2');
}
return html;
}
function isMarkdownPath(path = "") {
return /\.(md|markdown)$/i.test(path);
}
function safeMarkdownHref(value = "") {
const href = value.trim();
const lowerHref = href.toLowerCase();
if (!href || lowerHref.startsWith("javascript:") || lowerHref.startsWith("data:")) {
return "";
}
return href;
}
function renderMarkdownInline(value = "") {
return escapeHtml(value)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, label, href) => {
const safeHref = safeMarkdownHref(href.replaceAll("&amp;", "&"));
return safeHref ? `<a href="${escapeHtml(safeHref)}">${label}</a>` : label;
})
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/_([^_]+)_/g, "<em>$1</em>");
}
function renderMarkdown(content = "") {
const lines = content.replace(/\r\n/g, "\n").split("\n");
const html = [];
let inFence = false;
let fenceLanguage = "";
let fenceLines = [];
let listType = "";
let listItems = [];
const flushList = () => {
if (!listType) return;
html.push(`<${listType}>${listItems.map((item) => `<li>${renderMarkdownInline(item)}</li>`).join("")}</${listType}>`);
listType = "";
listItems = [];
};
const flushFence = () => {
html.push(`<pre class="markdown-code" data-language="${escapeHtml(fenceLanguage)}"><code>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
inFence = false;
fenceLanguage = "";
fenceLines = [];
};
for (const line of lines) {
const fence = line.match(/^```(\w+)?\s*$/);
if (fence) {
if (inFence) {
flushFence();
} else {
flushList();
inFence = true;
fenceLanguage = fence[1] || "";
fenceLines = [];
}
continue;
}
if (inFence) {
fenceLines.push(line);
continue;
}
if (!line.trim()) {
flushList();
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushList();
const level = heading[1].length;
html.push(`<h${level}>${renderMarkdownInline(heading[2])}</h${level}>`);
continue;
}
if (/^\s*[-*_]{3,}\s*$/.test(line)) {
flushList();
html.push("<hr>");
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
flushList();
html.push(`<blockquote>${renderMarkdownInline(quote[1])}</blockquote>`);
continue;
}
const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
if (unordered) {
if (listType && listType !== "ul") flushList();
listType = "ul";
listItems.push(unordered[1]);
continue;
}
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
if (ordered) {
if (listType && listType !== "ol") flushList();
listType = "ol";
listItems.push(ordered[1]);
continue;
}
flushList();
html.push(`<p>${renderMarkdownInline(line)}</p>`);
}
if (inFence) flushFence();
flushList();
return html.join("");
}
function decodeBase64Content(content = "") {
const cleaned = content.replace(/\s/g, "");
const binary = atob(cleaned);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (bytes.byteLength > maxPreviewBytes) {
return { content: "", size: bytes.byteLength, isBinary: false, tooLarge: true };
}
if (bytes.includes(0)) {
return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false };
}
try {
return {
content: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
size: bytes.byteLength,
isBinary: false,
tooLarge: false,
};
} catch {
return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false };
}
}
function normaliseRemoteEntries(contents) {
const items = Array.isArray(contents) ? contents : [contents];
return items
.map((item) => ({
name: item.name,
path: item.path || item.name,
type: item.type === "dir" ? "dir" : "file",
size: item.size,
}))
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
}
function normaliseLocalEntries(entries) {
return entries.map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.entryType === "tree" ? "dir" : "file",
size: entry.size,
}));
}
function serverFormTemplate(server = null) {
const defaults = {
id: "",
displayName: "",
serverUrl: "",
authMethod: "token",
token: "",
username: "",
password: "",
};
const config = { ...defaults, ...(server || {}) };
return `
<div class="stack" id="server-form-card">
<h4 class="gd-modal-section-title">${server ? "Edit Server" : "Add Server"}</h4>
<input type="hidden" name="id" value="${escapeHtml(config.id)}" />
<div>
<div class="label">Display name</div>
<input name="displayName" value="${escapeHtml(config.displayName)}" placeholder="Gitpub Main" />
</div>
<div>
<div class="label">Gitea server URL</div>
<input name="serverUrl" value="${escapeHtml(config.serverUrl)}" placeholder="https://git.example.com" />
</div>
<div>
<div class="label">Auth method</div>
<select name="authMethod">
<option value="token" ${config.authMethod === "token" ? "selected" : ""}>Access Token</option>
<option value="password" ${config.authMethod === "password" ? "selected" : ""}>Username / Password</option>
</select>
</div>
<div>
<div class="label">Access token</div>
<input name="token" value="${escapeHtml(config.token)}" placeholder="gitea token" />
</div>
<div class="row">
<div style="flex:1">
<div class="label">Username</div>
<input name="username" value="${escapeHtml(config.username)}" placeholder="username" />
</div>
<div style="flex:1">
<div class="label">Password</div>
<input type="password" name="password" value="${escapeHtml(config.password)}" placeholder="password" />
</div>
</div>
<div class="row wrap">
<button id="test-server-btn" type="button">Test connection</button>
<button id="save-server-btn" class="primary" type="button">Save server</button>
</div>
<div class="muted">${escapeHtml(serverTestResult || settingsNotice)}</div>
</div>
`;
}
function welcomeView() {
appRoot.innerHTML = `
<div class="welcome-wrap">
<div class="welcome-card stack">
<div class="welcome-logo">
<img src="/assets/logos/Gitpub-Word-Logo-2-White.svg" alt="Gitpub" class="welcome-logo-img" />
<span class="welcome-logo-desktop">Desktop</span>
</div>
<div class="panel stack">
<h1 class="title">Welcome to Gitpub Desktop</h1>
<p class="subtitle">
Connect your first Gitea backend. Gitpub Desktop works with any compatible Gitea server.
</p>
${serverFormTemplate(null)}
</div>
</div>
</div>
`;
bindServerFormEvents();
}
function isOrgRepo(repo) {
const ownerLogin = repo.owner?.login || repo.full_name.split("/")[0];
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 `<div class="viewer-loading">Scanning local folders…</div>`;
}
if (state.localRepoScanError) {
return `<div class="viewer-error">${escapeHtml(state.localRepoScanError)}</div>`;
}
if (!results.length) {
return `<div class="gd-modal-empty">No local repositories scanned yet. Only repos with remotes from the selected server will appear.</div>`;
}
return `<div class="gd-modal-scan-list">
${results
.map(
(repo) => `
<div class="gd-modal-list-item">
<div class="gd-modal-item-info">
<strong>${escapeHtml(repo.name || repoNameFromPath(repo.path))}</strong>
<span class="muted gd-path-text" title="${escapeHtml(repo.path)}">${escapeHtml(repo.path)}</span>
${repo.matchedRemoteUrl ? `<span class="muted" style="font-size:11px">${escapeHtml(repo.matchedRemoteUrl)}</span>` : ""}
</div>
<div class="row" style="gap:5px;flex-shrink:0">
<button class="use-scanned-repo-btn" data-repo-path="${escapeHtml(repo.path)}" data-repo-name="${escapeHtml(repo.name || repoNameFromPath(repo.path))}" type="button">Open</button>
<button class="view-scanned-repo-btn" data-repo-path="${escapeHtml(repo.path)}" data-repo-name="${escapeHtml(repo.name || repoNameFromPath(repo.path))}" type="button">Viewer</button>
</div>
</div>
`
)
.join("")}
</div>`;
}
function breadcrumbTemplate(repoName, path = "") {
const parts = path.split("/").filter(Boolean);
const crumbs = [`<button class="viewer-crumb-btn" data-viewer-path="" type="button">${escapeHtml(repoName)}</button>`];
parts.forEach((part, index) => {
const crumbPath = parts.slice(0, index + 1).join("/");
crumbs.push(`<span class="viewer-crumb-sep">/</span><button class="viewer-crumb-btn" data-viewer-path="${escapeHtml(crumbPath)}" type="button">${escapeHtml(part)}</button>`);
});
return crumbs.join("");
}
function filePreviewTemplate(file) {
const language = languageForPath(file.path);
const meta = [language, formatBytes(file.size)].filter(Boolean).join(" · ");
const header = `
<div class="viewer-file-panel-header">
<span class="viewer-file-name">${FILE_ICON} ${escapeHtml(file.path.split("/").pop())}</span>
<span class="muted">${escapeHtml(meta)}</span>
</div>`;
if (file.tooLarge) {
return header + `<div class="empty-state viewer-empty"><div>File too large to preview</div><div class="muted">Preview is limited to ${formatBytes(maxPreviewBytes)}.</div></div>`;
}
if (file.isBinary) {
return header + `<div class="empty-state viewer-empty"><div>Binary file</div><div class="muted">Binary content cannot be previewed.</div></div>`;
}
if (isMarkdownPath(file.path)) {
return header + `<div class="markdown-body">${renderMarkdown(file.content || "")}</div>`;
}
return header + `<pre class="code-preview" data-language="${escapeHtml(language)}"><code>${highlightCode(file.content || "", language)}</code></pre>`;
}
// ── Modal content functions ───────────────────────────────────────────────────
function reposModalContent(state) {
const visibleRepos = filteredRepositories();
const activeServer = getActiveServer();
return `
<div class="gd-modal-two-col">
<div class="gd-modal-section">
<h4 class="gd-modal-section-title">Recent Local Repositories</h4>
${state.settings.recentRepositories.length
? state.settings.recentRepositories.map((path) => `
<div class="gd-modal-list-item">
<div class="gd-modal-item-info">
<strong>${escapeHtml(repoNameFromPath(path))}</strong>
<span class="muted gd-path-text" title="${escapeHtml(path)}">${escapeHtml(path)}</span>
</div>
<div class="row" style="gap:5px;flex-shrink:0">
<button class="open-recent-repo-btn" data-recent-repo-path="${escapeHtml(path)}" type="button">Open</button>
<button class="remove-recent-repo-btn danger" data-recent-repo-path="${escapeHtml(path)}" type="button" title="Remove from list">×</button>
</div>
</div>`).join("")
: `<div class="gd-modal-empty">No recent repositories</div>`}
<div class="gd-modal-divider"></div>
<h4 class="gd-modal-section-title">Open from Path</h4>
<div class="row" style="gap:6px">
<input id="local-repo-path-input" placeholder="Paste a repository path…" value="${escapeHtml(state.localRepoPathInput)}" />
<button id="open-local-repo-btn" type="button" style="white-space:nowrap">Open</button>
</div>
<div class="gd-modal-divider"></div>
<h4 class="gd-modal-section-title">Find Local Repositories</h4>
<div class="row" style="gap:6px;margin-bottom:6px">
<input id="local-repo-scan-root-input" placeholder="Folder to scan, e.g. F:\\Repos" value="${escapeHtml(state.localRepoScanRootInput || state.settings.defaultCloneDirectory)}" />
<button id="scan-local-repos-btn" type="button" ${state.localRepoScanLoading ? "disabled" : ""} style="white-space:nowrap">${state.localRepoScanLoading ? "Scanning…" : "Scan"}</button>
</div>
${localRepoScanTemplate()}
</div>
<div class="gd-modal-section gd-modal-section-alt">
<div class="gd-modal-section-header">
<h4 class="gd-modal-section-title" style="margin:0">Server Repositories</h4>
<div class="row" style="gap:8px;align-items:center">
<span class="muted" style="font-size:12px">${escapeHtml(activeServer?.displayName || "No server")}</span>
<button id="refresh-repos-btn" type="button" style="font-size:12px;padding:3px 8px">Refresh</button>
</div>
</div>
<input id="repo-search-input" placeholder="Search repositories…" value="${escapeHtml(state.repoSearch)}" style="margin-bottom:8px" />
<div class="repo-filter-pills" style="margin-bottom:10px">
<button class="pill-btn ${repoOwnerFilter === "all" ? "active" : ""}" data-owner-filter="all">All</button>
<button class="pill-btn ${repoOwnerFilter === "personal" ? "active" : ""}" data-owner-filter="personal">Personal</button>
<button class="pill-btn ${repoOwnerFilter === "orgs" ? "active" : ""}" data-owner-filter="orgs">Organizations</button>
</div>
<div class="gd-modal-scroll-list">
${visibleRepos.slice(0, 60).map((repo) => `
<div class="gd-modal-list-item">
<div class="gd-modal-item-info">
<strong class="accent">${escapeHtml(repo.full_name)}</strong>
<span class="muted">${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}</span>
</div>
<div class="row" style="gap:5px;flex-shrink:0">
<button class="view-repo-btn" data-repo-name="${escapeHtml(repo.full_name)}" type="button" style="font-size:12px;padding:3px 8px">View</button>
<button class="clone-repo-btn primary-blue" data-clone-url="${escapeHtml(repo.clone_url || "")}" type="button" style="font-size:12px;padding:3px 8px">Clone</button>
</div>
</div>`).join("") || `<div class="gd-modal-empty">No repositories found</div>`}
</div>
</div>
</div>
`;
}
function cloneModalContent(state) {
const visibleRepos = filteredRepositories();
return `
<div class="gd-modal-narrow stack">
<div>
<div class="label">Server</div>
<select id="clone-server-select">
${state.settings.servers.map((server) => `<option value="${server.id}" ${server.id === state.settings.activeServerId ? "selected" : ""}>${escapeHtml(server.displayName)}</option>`).join("")}
</select>
</div>
<div>
<div class="label">Remote repository</div>
<select id="clone-repo-select">
<option value="">Paste URL manually below</option>
${visibleRepos.slice(0, 100).map((repo) => `<option value="${escapeHtml(repo.clone_url || "")}" ${repo.clone_url === state.cloneUrlInput ? "selected" : ""}>${escapeHtml(repo.full_name)}</option>`).join("")}
</select>
</div>
<div>
<div class="label">Repository URL</div>
<input id="clone-url-input" placeholder="https://git.example.com/org/repo.git" value="${escapeHtml(state.cloneUrlInput)}" />
</div>
<div>
<div class="label">Destination path</div>
<div class="gd-path-picker">
<input id="clone-destination-input" placeholder="${escapeHtml(state.settings.defaultCloneDirectory || "/Users/me/code/repo")}" value="${escapeHtml(state.cloneDestinationInput || state.settings.defaultCloneDirectory)}" />
<button id="clone-destination-browse-btn" type="button">Browse</button>
</div>
<span class="muted" style="font-size:12px">Choose a parent folder and Gitpub will create the repository folder inside it.</span>
</div>
<button id="clone-btn" class="primary" type="button">Clone Repository</button>
${gitOutput
? `<pre class="git-output" style="max-height:180px;overflow:auto;font-size:11px">${escapeHtml(gitOutput)}</pre>`
: `<span class="muted" style="font-size:12px">Clone output will appear here.</span>`}
</div>
`;
}
function serversModalContent(state) {
const servers = state.settings.servers;
return `
<div class="gd-modal-two-col">
<div class="gd-modal-section">
<div class="gd-modal-section-header">
<h4 class="gd-modal-section-title" style="margin:0">Connected Servers</h4>
<button id="add-server-btn" type="button" class="primary">+ Add Server</button>
</div>
${settingsNotice ? `<div class="muted" style="font-size:12px;margin-bottom:8px">${escapeHtml(settingsNotice)}</div>` : ""}
${servers.length
? servers.map((server) => `
<div class="gd-server-item ${server.id === state.settings.activeServerId ? "gd-server-active" : ""}">
<div class="gd-server-name">${escapeHtml(server.displayName)}</div>
<div class="gd-server-url muted">${escapeHtml(server.serverUrl)}</div>
<div class="row" style="gap:4px;margin-top:6px">
<button class="set-default-server-btn" data-id="${server.id}" type="button" style="font-size:12px;padding:2px 8px">${server.id === state.settings.activeServerId ? "✓ Active" : "Set active"}</button>
<button class="edit-server-btn" data-id="${server.id}" type="button" style="font-size:12px;padding:2px 8px">Edit</button>
<button class="delete-server-btn danger" data-id="${server.id}" type="button" style="font-size:12px;padding:2px 8px">Remove</button>
</div>
</div>`).join("")
: `<div class="gd-modal-empty">No servers configured</div>`}
</div>
<div class="gd-modal-section gd-modal-section-alt" id="server-form-slot">
<div class="gd-modal-empty">Select a server to edit, or add a new one.</div>
</div>
</div>
`;
}
function settingsModalContent(state) {
const editorDropdownValue = selectedEditorDropdownValue(state);
const customEditorPath = editorDropdownValue === CUSTOM_EDITOR_VALUE ? state.settings.externalEditorPath?.trim() || "" : "";
return `
<div class="gd-modal-two-col">
<div class="gd-modal-section">
<h4 class="gd-modal-section-title">Appearance</h4>
<div class="label">Theme</div>
<select id="theme-select">
<option value="dark" ${state.settings.theme === "dark" ? "selected" : ""}>Dark</option>
<option value="light" ${state.settings.theme === "light" ? "selected" : ""}>Light</option>
<option value="system" ${state.settings.theme === "system" ? "selected" : ""}>System</option>
</select>
<div class="gd-modal-divider"></div>
<h4 class="gd-modal-section-title">Git</h4>
<div class="label">Git executable path</div>
<input id="git-path-input" value="${escapeHtml(state.settings.gitExecutablePath)}" placeholder="git or /usr/bin/git" />
<div class="label">Default clone directory</div>
<input id="default-clone-dir-input" value="${escapeHtml(state.settings.defaultCloneDirectory)}" placeholder="/Users/me/code" />
<div class="label">Code editor</div>
<div class="gd-editor-picker">
<select id="external-editor-select">
${externalEditorOptionsTemplate(state)}
</select>
<button id="rescan-editors-btn" type="button" ${state.installedIdeScanLoading ? "disabled" : ""}>${state.installedIdeScanLoading ? "Scanning..." : "Rescan"}</button>
</div>
${state.installedIdeScanError ? `<div class="viewer-error" style="margin-top:6px">${escapeHtml(state.installedIdeScanError)}</div>` : ""}
${!state.installedIdeScanLoading && !state.installedIdes.length ? `<div class="muted" style="font-size:12px;margin-top:4px">No installed IDEs detected yet. You can use the default <code>code</code> command or choose a custom app.</div>` : ""}
<div id="custom-editor-row" class="gd-path-picker ${editorDropdownValue === CUSTOM_EDITOR_VALUE ? "" : "hidden"}" style="margin-top:8px">
<input id="custom-editor-input" value="${escapeHtml(customEditorPath)}" placeholder="Choose an application or executable" />
<button id="custom-editor-browse-btn" type="button">Browse</button>
</div>
<div class="gd-modal-divider"></div>
<div class="label">Default server</div>
<select id="default-server-select">
<option value="">None</option>
${state.settings.servers.map((server) => `<option value="${server.id}" ${server.id === state.settings.activeServerId ? "selected" : ""}>${escapeHtml(server.displayName)}</option>`).join("")}
</select>
<label class="gd-check-all" style="margin-top:6px">
<input id="auto-fetch-checkbox" type="checkbox" ${state.settings.autoFetchOnRepoOpen ? "checked" : ""} />
<span>Auto-fetch when opening a repository</span>
</label>
<button id="save-basic-settings-btn" class="primary" type="button" style="margin-top:4px">Save Settings</button>
${settingsNotice ? `<div class="muted" style="font-size:12px;margin-top:4px">${escapeHtml(settingsNotice)}</div>` : ""}
</div>
<div class="gd-modal-section gd-modal-section-alt">
<h4 class="gd-modal-section-title">About</h4>
<div class="muted" style="font-size:13px;line-height:1.75">
<p style="margin:0 0 8px"><strong style="color:var(--text-main)">Gitpub Desktop</strong></p>
<p style="margin:0 0 8px">A focused desktop client for Gitea-compatible Git servers.</p>
<p style="margin:0">Built with Tauri 2 and vanilla JavaScript.</p>
</div>
</div>
</div>
`;
}
function viewerModalContent(state) {
const viewer = state.viewer;
if (!viewer.source) {
return `<div class="gd-modal-empty" style="padding:48px;text-align:center">
<p style="font-size:15px;margin:0 0 8px">No repository open in viewer</p>
<p class="muted" style="font-size:13px;margin:0">Open a repository from the Repositories panel first.</p>
</div>`;
}
const rows = [];
if (viewer.path) {
rows.push(`<button class="viewer-row" data-entry-type="dir" data-entry-path="${escapeHtml(parentPath(viewer.path))}" type="button">
<span class="viewer-row-icon">${FOLDER_ICON}</span>
<span class="viewer-row-name">..</span>
<span class="viewer-row-size"></span>
</button>`);
}
viewer.entries.forEach((entry) => {
const active = viewer.selectedFile?.path === entry.path ? " active" : "";
rows.push(`<button class="viewer-row${active}" data-entry-type="${entry.type}" data-entry-path="${escapeHtml(entry.path)}" type="button">
<span class="viewer-row-icon">${entry.type === "dir" ? FOLDER_ICON : FILE_ICON}</span>
<span class="viewer-row-name ${entry.type === "dir" ? "viewer-dir-name" : ""}">${escapeHtml(entry.name)}</span>
<span class="viewer-row-size muted">${entry.type === "file" ? formatBytes(entry.size) : ""}</span>
</button>`);
});
const showReadme = viewer.readmeFile && !viewer.selectedFile;
return `
<div class="gd-modal-viewer">
<div class="gd-modal-viewer-sidebar">
<div class="gd-modal-viewer-controls">
<div class="viewer-crumb-row">
${breadcrumbTemplate(viewer.repoName || "repo", viewer.path)}
</div>
<div class="row" style="gap:6px;align-items:center;flex-shrink:0">
<select id="viewer-branch-select" style="width:auto;font-size:12px;padding:3px 8px" ${viewer.loading ? "disabled" : ""}>
${viewer.branches.map((b) => `<option value="${escapeHtml(b.name)}" ${b.name === viewer.branch ? "selected" : ""}>${escapeHtml(b.name)}${b.current ? " ✓" : ""}</option>`).join("")}
</select>
<button id="viewer-refresh-btn" type="button" style="padding:3px 8px;font-size:12px">↻</button>
</div>
</div>
<div class="gd-viewer-tree">
${rows.join("")}
${!rows.length && !viewer.loading ? `<div class="gd-left-empty"><span class="muted">Empty folder</span></div>` : ""}
</div>
</div>
<div class="gd-modal-viewer-main">
${viewer.error ? `<div class="viewer-error" style="margin:12px">${escapeHtml(viewer.error)}</div>` : ""}
${viewer.loading ? `<div class="viewer-loading" style="margin:12px">Loading…</div>` : ""}
${viewer.selectedFile ? `<div style="height:100%;display:flex;flex-direction:column">${filePreviewTemplate(viewer.selectedFile)}</div>` : ""}
${showReadme ? `
<div class="viewer-readme-panel" style="border:0;border-radius:0;margin:0">
<div class="viewer-readme-header">${FILE_ICON}<span>${escapeHtml(viewer.readmeFile.path.split("/").pop())}</span></div>
<div class="viewer-readme-body markdown-body">${renderMarkdown(viewer.readmeFile.content || "")}</div>
</div>` : ""}
${!viewer.selectedFile && !viewer.readmeFile && !viewer.loading
? `<div class="gd-modal-empty" style="padding:48px;text-align:center">Select a file from the tree to preview</div>`
: ""}
</div>
</div>
`;
}
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 `<div class="gd-modal-backdrop" id="modal-backdrop">
<div class="gd-modal ${cfg.wide ? "gd-modal-wide" : "gd-modal-narrow-dialog"} ${cfg.fullHeight ? "gd-modal-full-height" : ""}" role="dialog" aria-modal="true" aria-label="${cfg.title}">
<div class="gd-modal-header">
<h3 class="gd-modal-title">${cfg.title}</h3>
<button id="modal-close-btn" type="button" class="gd-modal-close" aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>
</button>
</div>
<div class="gd-modal-body">
${cfg.content}
</div>
</div>
</div>`;
}
// ── 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 : ""}
<div class="gd-toolbar-left">
<button class="gd-toolbar-cell gd-repo-cell" type="button" data-open-modal="repos" title="${escapeHtml(state.selectedRepoPath || repoIdentity.primary)}">
<span class="gd-cell-icon">${LOCAL_REPO_ICON}</span>
<span class="gd-cell-copy">
<span class="gd-cell-label">Current repository</span>
<span class="gd-cell-value">${escapeHtml(repoIdentity.primary)}</span>
</span>
</button>
<div class="gd-branch-wrap">
<button id="branch-menu-btn" class="gd-toolbar-cell gd-branch-toolbar" type="button" ${!hasLocalRepo || state.branches.loading ? "disabled" : ""} title="${escapeHtml(displayBranchName)}">
<span class="gd-cell-icon">${BRANCH_ICON}</span>
<span class="gd-cell-copy">
<span class="gd-cell-label">Current branch</span>
<span class="gd-cell-value">${escapeHtml(displayBranchName)}</span>
</span>
<svg class="gd-cell-caret" viewBox="0 0 16 16" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M4.427 7.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z"/></svg>
</button>
${state.branches.menuOpen ? `<div class="branch-menu">
<div class="branch-menu-section">
<div class="branch-menu-label">Switch Branch</div>
${(state.branches.items.length ? state.branches.items : [{ name: displayBranchName, current: true }]).map(branchOptionTemplate).join("")}
</div>
<div class="branch-menu-section">
<button class="branch-menu-action" data-branch-action="create" type="button">Create new branch…</button>
<button class="branch-menu-action" data-branch-action="rename" data-branch-name="${escapeHtml(displayBranchName)}" type="button" ${!displayBranchName ? "disabled" : ""}>Rename current branch…</button>
<button class="branch-menu-action danger-subtle" data-branch-action="delete" type="button">Delete branch…</button>
</div>
</div>` : ""}
</div>
</div>
<div class="gd-toolbar-center">
<button class="gd-sync-btn ${syncCfg.loading ? "is-loading" : ""}" id="git-sync-action-btn" type="button" ${syncCfg.disabled ? "disabled" : ""} data-sync-action="${escapeHtml(syncCfg.action || "")}" title="${escapeHtml(syncCfg.tooltip)}" aria-label="${escapeHtml(syncCfg.tooltip)}">
<span class="gd-sync-icon">${syncCfg.icon}</span>
<span class="gd-cell-copy">
<span class="gd-cell-value gd-sync-label">${escapeHtml(syncCfg.label)}</span>
<span class="gd-cell-label">${escapeHtml(syncCfg.subLabel)}</span>
</span>
</button>
<div class="gd-toolbar-drag-space" data-tauri-drag-region aria-hidden="true"></div>
</div>
<div class="gd-toolbar-right">
<div class="gd-external-actions" role="group" aria-label="Repository actions">
<button id="open-file-explorer-btn" class="gd-icon-action" type="button" ${!hasLocalRepo ? "disabled" : ""} title="Open in File Explorer" aria-label="Open in File Explorer">
${EXPLORER_ICON}
</button>
<button id="open-code-editor-btn" class="gd-icon-action" type="button" ${!hasLocalRepo ? "disabled" : ""} title="Open in Code Editor" aria-label="Open in Code Editor">
${EDITOR_ICON}
</button>
</div>
<div class="gd-view-toggle" role="group" aria-label="View mode">
<button class="${activeView === "changes" ? "active" : ""}" data-view="changes" type="button">Changes</button>
<button class="${activeView === "history" ? "active" : ""}" data-view="history" type="button">History</button>
</div>
<div class="gd-utility-wrap">
<button id="utility-menu-btn" class="gd-utility-btn" type="button" title="More options" aria-label="More options">
<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/></svg>
</button>
${utilityMenuOpen ? `<div class="gd-utility-menu" role="menu">
<button class="gd-utility-menu-item" data-open-modal="repos" type="button" role="menuitem">
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8Z"/></svg>
Open Repository
</button>
<button class="gd-utility-menu-item" data-open-modal="clone" type="button" role="menuitem">
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z"/></svg>
Clone Repository
</button>
<div class="gd-utility-separator"></div>
<button class="gd-utility-menu-item" data-open-modal="viewer" type="button" role="menuitem">
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
File Viewer
</button>
<button class="gd-utility-menu-item" id="open-file-explorer-menu-btn" type="button" role="menuitem" ${!hasLocalRepo ? "disabled" : ""}>
${EXPLORER_ICON}
Open in File Explorer
</button>
<button class="gd-utility-menu-item" id="open-code-editor-menu-btn" type="button" role="menuitem" ${!hasLocalRepo ? "disabled" : ""}>
${EDITOR_ICON}
Open in Code Editor
</button>
<div class="gd-utility-separator"></div>
<button class="gd-utility-menu-item" data-open-modal="servers" type="button" role="menuitem">
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M1.5 1.75a.25.25 0 0 1 .25-.25h12.5a.25.25 0 0 1 .25.25v4a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-4Zm.25-1.75A1.75 1.75 0 0 0 0 1.75v4C0 6.716.784 7.5 1.75 7.5h12.5A1.75 1.75 0 0 0 16 5.75v-4A1.75 1.75 0 0 0 14.25 0H1.75ZM0 10.25C0 9.284.784 8.5 1.75 8.5h12.5A1.75 1.75 0 0 1 16 10.25v4A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25v-4Zm1.5.25a.25.25 0 0 0-.25.25v4c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-4a.25.25 0 0 0-.25-.25H1.75Z"/></svg>
Servers
</button>
<button class="gd-utility-menu-item" data-open-modal="settings" type="button" role="menuitem">
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.068.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.039.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.156-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.068-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.156.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.531.01 7.765 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM8 5.25a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5ZM4.75 8a3.25 3.25 0 1 1 6.5 0 3.25 3.25 0 0 1-6.5 0Z"/></svg>
Settings
</button>
</div>` : ""}
</div>
</div>
${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 = `
<div class="gd-changes-search-row gd-history-search-row">
<input id="history-filter-input" class="gd-filter-input" placeholder="Filter history…" value="${escapeHtml(state.historyFilter)}" style="grid-column:1/-1" />
</div>
<div class="gd-history-list">
${state.history.loading ? `<div class="viewer-loading" style="padding:10px 12px">Loading…</div>` : ""}
${state.history.error ? `<div class="viewer-error" style="margin:8px">${escapeHtml(state.history.error)}</div>` : ""}
${!hasLocalRepo ? `<div class="gd-left-empty-minimal"><span class="muted">No repository selected</span></div>` : ""}
${hasLocalRepo && !state.history.loading && commits.length
? commits.map((commit) => `
<button class="gd-history-item commit-item ${state.history.selectedHash === commit.hash ? "active" : ""}" data-commit-hash="${escapeHtml(commit.hash)}" type="button">
<span class="gd-history-info">
<span class="gd-history-name">${escapeHtml(commit.title)}</span>
<span class="gd-history-path muted">${escapeHtml(commit.author)} · ${escapeHtml(commit.shortHash)}</span>
</span>
</button>`).join("")
: ""}
${hasLocalRepo && !state.history.loading && !commits.length
? `<div class="gd-left-empty-minimal"><span class="muted">No commits found</span></div>`
: ""}
</div>
`;
} else {
leftContentHTML = `
<div class="gd-changes-search-row">
<button class="gd-filter-menu-btn" id="refresh-changes-btn" type="button" aria-label="Refresh" title="Refresh changes">
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 2.5a5.5 5.5 0 1 0 5.5 5.5.75.75 0 0 1 1.5 0 7 7 0 1 1-2.05-4.95V1.75a.75.75 0 0 1 1.5 0v3.5a.75.75 0 0 1-.75.75h-3.5a.75.75 0 0 1 0-1.5h2.19A5.48 5.48 0 0 0 8 2.5Z"/></svg>
</button>
<input id="changes-filter-input" class="gd-filter-input" placeholder="Filter changed files" value="${escapeHtml(state.changesFilter)}" />
</div>
<div class="gd-changes-filter-row">
<label class="gd-check-all">
<input id="select-all-changes" type="checkbox" ${allSelected ? "checked" : ""} ${!filteredFiles.length ? "disabled" : ""} />
<span>${filteredFiles.length} changed file${filteredFiles.length === 1 ? "" : "s"}</span>
</label>
<span class="muted" style="font-size:11px">${selectedCount > 0 ? `${selectedCount} staged` : ""}</span>
</div>
<div class="gd-changes-list">
${state.workingTree.loading ? `<div class="viewer-loading" style="padding:10px 12px">Loading…</div>` : ""}
${state.workingTree.error ? `<div class="viewer-error" style="margin:8px">${escapeHtml(state.workingTree.error)}</div>` : ""}
${!hasLocalRepo ? `<div class="gd-left-empty-minimal"><span class="muted">No repository open</span></div>` : ""}
${hasLocalRepo && !state.workingTree.loading && !filteredFiles.length && !state.workingTree.error
? `<div class="gd-left-empty-minimal"><span class="muted">Working tree is clean</span></div>`
: ""}
${groupedChangedFiles(filteredFiles).map((group) => `
<div class="change-group">
<div class="change-group-title">${statusLabel(group.status)}</div>
${group.files.map((file) => `
<button class="change-file-row ${state.workingTree.selectedPath === file.path ? "active" : ""}" data-change-path="${escapeHtml(file.path)}" type="button">
<input class="change-file-checkbox" data-change-checkbox="${escapeHtml(file.path)}" type="checkbox" ${state.workingTree.selectedPaths.has(file.path) ? "checked" : ""} />
<span class="change-status change-status-${escapeHtml(file.status)}">${escapeHtml(file.status[0]?.toUpperCase() || "M")}</span>
<span class="change-file-name" title="${escapeHtml(file.originalPath ? `${file.originalPath}${file.path}` : file.path)}">${escapeHtml(file.path)}</span>
</button>
`).join("")}
</div>
`).join("")}
${gitOutput ? `<pre class="git-output gd-git-out">${escapeHtml(gitOutput)}</pre>` : ""}
</div>
`;
}
// ── Main area ───────────────────────────────────────────────────────────
let mainHTML = "";
if (activeView === "history") {
const commit = state.history.selectedCommit;
if (commit) {
mainHTML = `
<div class="gd-main-pad stack">
<div class="section-header">
<div>
<h3 class="title">${escapeHtml(commit.title)}</h3>
<p class="subtitle">${escapeHtml(commit.author)} · ${escapeHtml(commit.date)} · ${escapeHtml(commit.shortHash)}</p>
</div>
<button id="refresh-history-btn" type="button">Refresh</button>
</div>
<pre class="commit-message-view">${escapeHtml(commit.message)}</pre>
<div class="changed-files-chip-row">
${(commit.files || []).map((file) => `<span class="changed-file-chip">${escapeHtml(file.status)} ${escapeHtml(file.path)}</span>`).join("")}
</div>
<div class="diff-preview diff-preview-inline">${renderDiff(commit.diff || "No diff available.")}</div>
</div>`;
} 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 = `
<div class="gd-main-pad stack">
<div class="section-header">
<div>
<h3 class="title">${selectedChange ? escapeHtml(selectedChange.path) : hasLocalRepo ? "No local changes" : "No repository selected"}</h3>
<p class="subtitle">${selectedChange ? `${statusLabel(selectedChange.status)} file` : hasLocalRepo ? "The working tree is clean." : "Open or clone a repository to begin."}</p>
</div>
<button id="refresh-changes-main-btn" type="button" ${!hasLocalRepo ? "disabled" : ""}>Refresh</button>
</div>
${state.workingTree.diffLoading ? `<div class="viewer-loading">Loading diff…</div>` : ""}
${state.workingTree.diffError ? `<div class="viewer-error">${escapeHtml(state.workingTree.diffError)}</div>` : ""}
${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,
})}
</div>`;
}
// ── Commit panel ────────────────────────────────────────────────────────
const hasCommitSummary = Boolean((state.commitSummary || state.commitMessage || "").trim());
const hasSelectedFiles = state.workingTree.selectedPaths.size > 0;
const canCommit = hasLocalRepo && hasSelectedFiles && hasCommitSummary;
const commitAreaHTML = `
<div class="gd-commit-area">
<input id="commit-summary-input" class="gd-commit-summary" placeholder="Summary (required)" value="${escapeHtml(state.commitSummary || state.commitMessage)}" />
<textarea id="commit-description-input" class="commit-desc-input" placeholder="Description (optional)">${escapeHtml(state.commitDescription)}</textarea>
<button id="commit-btn" class="primary gd-commit-btn" type="button" ${!canCommit ? "disabled" : ""}>
Commit to ${escapeHtml(displayBranchName)}
</button>
</div>`;
// ── Assemble ─────────────────────────────────────────────────────────────
appRoot.innerHTML = `
<div class="layout">
<header class="gd-toolbar ${toolbarPlatformClass}">
${toolbarHTML}
</header>
<aside class="gd-left">
<div class="gd-left-content">
${leftContentHTML}
</div>
${activeView === "changes" ? commitAreaHTML : ""}
</aside>
<main class="gd-main">
${mainHTML}
</main>
</div>
${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();
});