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";
let activeReposTab = "local"; // "local" | "server"
const maxPreviewBytes = 256 * 1024;
const defaultRepositoryName = "Gitpub-Desktop";
const defaultBranchName = "main";
const DEFAULT_EDITOR_VALUE = "__default_code__";
const CUSTOM_EDITOR_VALUE = "__custom__";
function disableInputAutocomplete(root = document) {
root.querySelectorAll("input, textarea").forEach((field) => {
if (field instanceof HTMLInputElement && ["button", "checkbox", "file", "hidden", "radio", "reset", "submit"].includes(field.type)) {
return;
}
field.setAttribute("autocomplete", field instanceof HTMLInputElement && field.type === "password" ? "new-password" : "off");
});
}
function escapeSvgAttr(value = "") {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function svgAttrs(attrs = {}) {
return Object.entries(attrs)
.filter(([, value]) => value !== undefined && value !== null && value !== false)
.map(([key, value]) => `${key}="${escapeSvgAttr(value)}"`)
.join(" ");
}
function lucideIcon(name, { size = 16, className = "", strokeWidth = 2, attrs = {} } = {}) {
const icon = window.lucide?.icons?.[name];
if (!icon) return "";
const iconAttrs = svgAttrs({
xmlns: "http://www.w3.org/2000/svg",
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": strokeWidth,
"stroke-linecap": "round",
"stroke-linejoin": "round",
"aria-hidden": "true",
focusable: "false",
class: ["gd-lucide-icon", className].filter(Boolean).join(" "),
...attrs,
});
const children = icon.map(([tag, childAttrs]) => `<${tag} ${svgAttrs(childAttrs)} />`).join("");
return `${children} `;
}
const FOLDER_ICON = lucideIcon("Folder", { size: 16, className: "gd-folder-icon" });
const FILE_ICON = lucideIcon("File", { size: 16, className: "gd-file-icon" });
const LOCAL_REPO_ICON = lucideIcon("Monitor", { size: 18 });
const BRANCH_ICON = lucideIcon("GitBranch", { size: 16 });
const SYNC_ICON = lucideIcon("RefreshCw", { size: 15 });
const PULL_ICON = lucideIcon("Download", { size: 15 });
const PUSH_ICON = lucideIcon("Upload", { size: 15 });
const PUBLISH_ICON = lucideIcon("CloudUpload", { size: 15 });
const EXPLORER_ICON = lucideIcon("FolderOpen", { size: 15 });
const EDITOR_ICON = lucideIcon("SquarePen", { size: 15 });
const WINDOW_MINIMIZE_ICON = lucideIcon("Minus", { size: 12 });
const WINDOW_MAXIMIZE_ICON = lucideIcon("Square", { size: 12 });
const WINDOW_CLOSE_ICON = lucideIcon("X", { size: 12 });
const CHEVRON_DOWN_ICON = lucideIcon("ChevronDown", { size: 10, className: "gd-cell-caret" });
const MORE_ICON = lucideIcon("Ellipsis", { size: 15 });
const REFRESH_ICON = lucideIcon("RefreshCw", { size: 14 });
const REPOSITORY_ICON = lucideIcon("BookOpen", { size: 14 });
const CLONE_ICON = lucideIcon("GitFork", { size: 14 });
const SERVER_ICON = lucideIcon("Server", { size: 14 });
const SETTINGS_ICON = lucideIcon("Settings", { size: 14 });
const PLUS_ICON = lucideIcon("Plus", { size: 14 });
const CHECK_ICON = lucideIcon("Check", { size: 13 });
const EMPTY_DIFF_ICON = lucideIcon("FileDiff", { size: 44, strokeWidth: 1.6 });
const EMPTY_FILE_ICON = lucideIcon("File", { size: 44, strokeWidth: 1.6 });
const EMPTY_REPO_ICON = lucideIcon("FolderGit2", { size: 44, strokeWidth: 1.6 });
function errorMessage(error, fallback = "Unknown error.") {
if (typeof error === "string" && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
if (error && typeof error === "object") {
if (typeof error.message === "string" && error.message.trim()) return error.message;
try {
const serialized = JSON.stringify(error);
if (serialized && serialized !== "{}") return serialized;
} catch {
// Fall through to the generic fallback.
}
}
return fallback;
}
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 applyPlatformChrome() {
appRoot.dataset.platform = currentPlatform();
}
function currentTauriWindow() {
const tauriWindow = window.__TAURI__?.window;
if (tauriWindow?.getCurrentWindow) {
return tauriWindow.getCurrentWindow();
}
return tauriWindow?.appWindow || null;
}
async function handleWindowAction(action) {
const appWindow = currentTauriWindow();
if (!appWindow) {
console.warn("Tauri window API not available");
return;
}
try {
if (action === "minimize") {
await appWindow.minimize();
} else if (action === "maximize") {
const isMaximized = await appWindow.isMaximized();
if (isMaximized) {
await appWindow.unmaximize();
} else {
await appWindow.maximize();
}
} else if (action === "close") {
await appWindow.close();
}
} catch (error) {
console.error(`Unable to ${action} window:`, error);
}
}
async function startWindowDrag() {
const appWindow = currentTauriWindow();
if (!appWindow) return;
try {
await appWindow.startDragging();
} catch (error) {
console.error("Unable to start window drag:", error);
}
}
function uid() {
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function escapeHtml(value = "") {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function formatBytes(value) {
if (!Number.isFinite(value)) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function parentPath(path = "") {
const parts = path.split("/").filter(Boolean);
parts.pop();
return parts.join("/");
}
function repoNameFromPath(path = "") {
return path.split(/[/\\]/).filter(Boolean).pop() || path;
}
function parentFolderName(path = "") {
const parts = path.split(/[/\\]/).filter(Boolean);
return parts.length > 1 ? parts[parts.length - 2] : "Local";
}
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 repoOwnerName(repo) {
return repo.owner?.login || repo.full_name.split("/")[0] || "Unknown";
}
function repoRemoteUrls(repo) {
return [repo.clone_url, repo.ssh_url, repo.html_url, repo.original_url]
.map((url) => normalizeRemoteUrl(url || ""))
.filter(Boolean);
}
function matchingServerRepoByRemote(remoteUrl = "") {
const normalizedRemote = normalizeRemoteUrl(remoteUrl);
if (!normalizedRemote) return null;
return repositories.find((repo) => repoRemoteUrls(repo).includes(normalizedRemote)) || null;
}
function matchingServerRepoByName(name = "") {
const normalizedName = name.trim().toLowerCase();
if (!normalizedName) return null;
return repositories.find((repo) => (repo.full_name.split("/").pop() || "").toLowerCase() === normalizedName) || null;
}
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) => `${escapeHtml(editor.name)} `)
.join("");
return `
VS Code command (code)
${detectedOptions}
Custom application…
`;
}
function windowControlsTemplate(isMacos = false) {
const controls = isMacos
? [
{ action: "close", label: "Close", icon: WINDOW_CLOSE_ICON, className: "gd-window-close" },
{ action: "minimize", label: "Minimize", icon: WINDOW_MINIMIZE_ICON, className: "gd-window-minimize" },
{ action: "maximize", label: "Maximize", icon: WINDOW_MAXIMIZE_ICON, className: "gd-window-maximize" },
]
: [
{ action: "minimize", label: "Minimize", icon: WINDOW_MINIMIZE_ICON, className: "gd-window-minimize" },
{ action: "maximize", label: "Maximize", icon: WINDOW_MAXIMIZE_ICON, className: "gd-window-maximize" },
{ action: "close", label: "Close", icon: WINDOW_CLOSE_ICON, className: "gd-window-close" },
];
return `
${controls.map((control) => `
${control.icon}
`).join("")}
`;
}
function statusLabel(status = "") {
const labels = {
modified: "Modified",
added: "Added",
deleted: "Deleted",
renamed: "Renamed",
untracked: "Untracked",
conflicted: "Conflicted",
};
return labels[status] || "Modified";
}
function pluralize(count, singular, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
function remoteDisplayName(sync = {}) {
return sync.upstreamRemote || sync.defaultRemote || "origin";
}
function syncButtonConfig(state) {
if (!state.selectedRepoPath) {
return {
label: "No Repository",
subLabel: "Select a repo to begin",
tooltip: "Select a repository to sync.",
icon: SYNC_ICON,
action: "",
disabled: true,
};
}
const sync = state.sync;
const remote = remoteDisplayName(sync);
const operationLabels = {
fetch: "Fetching…",
pull: "Pulling…",
push: "Pushing…",
sync: "Syncing…",
publish: "Publishing…",
};
if (sync.operation) {
return {
label: operationLabels[sync.operation] || "Working…",
subLabel: "Git operation in progress",
tooltip: "A Git operation is already running.",
icon: sync.operation === "pull" ? PULL_ICON : sync.operation === "publish" || sync.operation === "push" ? PUSH_ICON : SYNC_ICON,
action: sync.operation,
disabled: true,
loading: true,
};
}
if (sync.loading && !sync.lastUpdated) {
return {
label: "Loading…",
subLabel: "Reading repository state",
tooltip: "Reading repository sync state.",
icon: SYNC_ICON,
action: "",
disabled: true,
loading: true,
};
}
if (sync.isDetached) {
return {
label: "Detached HEAD",
subLabel: "Checkout a branch to sync",
tooltip: "Detached HEAD cannot be pushed or pulled. Checkout a branch first.",
icon: BRANCH_ICON,
action: "",
disabled: true,
};
}
if (!sync.hasRemote && sync.lastUpdated) {
return {
label: "No remote",
subLabel: "Add a remote to sync",
tooltip: "This repository does not have a Git remote configured.",
icon: SYNC_ICON,
action: "",
disabled: true,
};
}
if (sync.isUnpublished || (!sync.upstream && sync.hasRemote)) {
return {
label: "Publish branch",
subLabel: `Publish ${sync.branch || "branch"} to ${remote}`,
tooltip: `Publish ${sync.branch || "the current branch"} to ${remote}.`,
icon: PUBLISH_ICON,
action: "publish",
disabled: false,
};
}
if (sync.behind > 0 && sync.ahead > 0) {
return {
label: "Sync changes",
subLabel: `${pluralize(sync.behind, "commit")} to pull · ${pluralize(sync.ahead, "commit")} to push`,
tooltip: `${pluralize(sync.behind, "commit")} to pull and ${pluralize(sync.ahead, "commit")} ready to push.`,
icon: SYNC_ICON,
action: "sync",
disabled: false,
};
}
if (sync.behind > 0) {
return {
label: `Pull ${remote}`,
subLabel: `${pluralize(sync.behind, "commit")} to pull`,
tooltip: `${pluralize(sync.behind, "commit")} to pull from ${remote}.`,
icon: PULL_ICON,
action: "pull",
disabled: false,
};
}
if (sync.ahead > 0) {
return {
label: `Push ${remote}`,
subLabel: `${pluralize(sync.ahead, "commit")} ready to push`,
tooltip: `${pluralize(sync.ahead, "commit")} ready to push to ${remote}.`,
icon: PUSH_ICON,
action: "push",
disabled: false,
};
}
return {
label: `Fetch ${remote}`,
subLabel: sync.error || "Your branch is up to date",
tooltip: sync.error ? `Last sync check failed: ${sync.error}` : "Your branch is up to date.",
icon: SYNC_ICON,
action: "fetch",
disabled: false,
};
}
function groupedChangedFiles(files = []) {
const order = ["modified", "added", "deleted", "renamed", "untracked", "conflicted"];
const groups = new Map(order.map((status) => [status, []]));
files.forEach((file) => {
const key = groups.has(file.status) ? file.status : "modified";
groups.get(key).push(file);
});
return order.map((status) => ({ status, files: groups.get(status) })).filter((group) => group.files.length);
}
function diffBinaryImagePreviewTemplate(diffResult) {
const mime = escapeHtml(diffResult.previewMime || "");
const b64 = diffResult.previewBase64 || "";
const caption = diffResult.diff ? escapeHtml(diffResult.diff) : "";
return `
${caption ? `
${caption} ` : ""}
`;
}
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.previewBase64 && diffResult.previewMime) {
return diffBinaryImagePreviewTemplate(diffResult);
}
if (diffResult.isBinary) {
return workflowEmptyStateTemplate({
title: "Binary file",
message: diffResult.diff || "Diff preview is not available for binary files.",
icon: "file",
actions: false,
});
}
if (diffResult.isDeleted && !diffResult.diff) {
return workflowEmptyStateTemplate({
title: "Deleted file",
message: "This file was removed from the working tree.",
icon: "file",
actions: false,
});
}
return `${renderDiff(diffResult.diff || "")}
`;
}
function branchOptionTemplate(branch) {
return ``;
}
function renderDiff(diff = "") {
const lines = diff.split("\n");
return lines
.map((line) => {
let kind = "context";
if (line.startsWith("+++") || line.startsWith("---")) kind = "meta";
else if (line.startsWith("@@")) kind = "hunk";
else if (line.startsWith("+")) kind = "added";
else if (line.startsWith("-")) kind = "removed";
const sign = line.slice(0, 1) || " ";
return `
${escapeHtml(sign)}
${escapeHtml(line || " ")}
`;
})
.join("");
}
function emptyStateIcon(type = "repo") {
if (type === "diff") {
return EMPTY_DIFF_ICON;
}
if (type === "file") {
return EMPTY_FILE_ICON;
}
return EMPTY_REPO_ICON;
}
function workflowEmptyStateTemplate({ title, message, icon = "repo", actions = true }) {
return `
${emptyStateIcon(icon)}
${escapeHtml(title)}
${escapeHtml(message)}
${actions ? `
Clone Repository
Open Repository
` : ""}
`;
}
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 `
${escapeHtml(title)}
${escapeHtml(description)}
${isDelete ? `
Select branch...
${state.branches.items.filter((branch) => !branch.current).map((branch) => `${escapeHtml(branch.name)} `).join("")}
This action cannot delete the current branch and may fail if the branch is not fully merged.
` : `
`}
${dialog.error ? `
${escapeHtml(dialog.error)}
` : ""}
Cancel
${isDelete ? "Delete Branch" : "Save"}
`;
}
function languageForPath(path = "") {
const extension = path.split(".").pop()?.toLowerCase();
const names = {
js: "JavaScript",
ts: "TypeScript",
tsx: "TypeScript",
jsx: "JavaScript",
rs: "Rust",
json: "JSON",
css: "CSS",
html: "HTML",
htm: "HTML",
md: "Markdown",
toml: "TOML",
yml: "YAML",
yaml: "YAML",
};
return names[extension] || "Text";
}
function highlightCode(content = "", language = "Text") {
let html = escapeHtml(content);
if (["JavaScript", "TypeScript", "Rust"].includes(language)) {
html = html.replace(
/\b(async|await|const|let|var|function|return|if|else|for|while|class|struct|enum|impl|fn|pub|use|mod|match|Ok|Err|true|false|null)\b/g,
'$1 '
);
} else if (language === "JSON") {
html = html.replace(/("[^&]*?")(\s*:)/g, '$1 $2');
} else if (language === "CSS") {
html = html.replace(/([.#]?[a-zA-Z0-9_-]+)(\s*\{)/g, '$1 $2');
}
return html;
}
function isMarkdownPath(path = "") {
return /\.(md|markdown)$/i.test(path);
}
function safeMarkdownHref(value = "") {
const href = value.trim();
const lowerHref = href.toLowerCase();
if (!href || lowerHref.startsWith("javascript:") || lowerHref.startsWith("data:")) {
return "";
}
return href;
}
function renderMarkdownInline(value = "") {
return escapeHtml(value)
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_match, label, href) => {
const safeHref = safeMarkdownHref(href.replaceAll("&", "&"));
return safeHref ? `${label} ` : label;
})
.replace(/\*\*([^*]+)\*\*/g, "$1 ")
.replace(/__([^_]+)__/g, "$1 ")
.replace(/\*([^*]+)\*/g, "$1 ")
.replace(/_([^_]+)_/g, "$1 ");
}
function renderMarkdown(content = "") {
const lines = content.replace(/\r\n/g, "\n").split("\n");
const html = [];
let inFence = false;
let fenceLanguage = "";
let fenceLines = [];
let listType = "";
let listItems = [];
const flushList = () => {
if (!listType) return;
html.push(`<${listType}>${listItems.map((item) => `${renderMarkdownInline(item)} `).join("")}${listType}>`);
listType = "";
listItems = [];
};
const flushFence = () => {
html.push(`${escapeHtml(fenceLines.join("\n"))} `);
inFence = false;
fenceLanguage = "";
fenceLines = [];
};
for (const line of lines) {
const fence = line.match(/^```(\w+)?\s*$/);
if (fence) {
if (inFence) {
flushFence();
} else {
flushList();
inFence = true;
fenceLanguage = fence[1] || "";
fenceLines = [];
}
continue;
}
if (inFence) {
fenceLines.push(line);
continue;
}
if (!line.trim()) {
flushList();
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushList();
const level = heading[1].length;
html.push(`${renderMarkdownInline(heading[2])} `);
continue;
}
if (/^\s*[-*_]{3,}\s*$/.test(line)) {
flushList();
html.push(" ");
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
flushList();
html.push(`${renderMarkdownInline(quote[1])} `);
continue;
}
const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
if (unordered) {
if (listType && listType !== "ul") flushList();
listType = "ul";
listItems.push(unordered[1]);
continue;
}
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
if (ordered) {
if (listType && listType !== "ol") flushList();
listType = "ol";
listItems.push(ordered[1]);
continue;
}
flushList();
html.push(`${renderMarkdownInline(line)}
`);
}
if (inFence) flushFence();
flushList();
return html.join("");
}
function decodeBase64Content(content = "") {
const cleaned = content.replace(/\s/g, "");
const binary = atob(cleaned);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (bytes.byteLength > maxPreviewBytes) {
return { content: "", size: bytes.byteLength, isBinary: false, tooLarge: true };
}
if (bytes.includes(0)) {
return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false };
}
try {
return {
content: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
size: bytes.byteLength,
isBinary: false,
tooLarge: false,
};
} catch {
return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false };
}
}
function normaliseRemoteEntries(contents) {
const items = Array.isArray(contents) ? contents : [contents];
return items
.map((item) => ({
name: item.name,
path: item.path || item.name,
type: item.type === "dir" ? "dir" : "file",
size: item.size,
}))
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
}
function normaliseLocalEntries(entries) {
return entries.map((entry) => ({
name: entry.name,
path: entry.path,
type: entry.entryType === "tree" ? "dir" : "file",
size: entry.size,
}));
}
function serverFormTemplate(server = null) {
const defaults = {
id: "",
displayName: "",
serverUrl: "",
authMethod: "token",
token: "",
username: "",
password: "",
};
const config = { ...defaults, ...(server || {}) };
return `
`;
}
function welcomeView() {
appRoot.innerHTML = `
Desktop
Welcome to Gitpub Desktop
Connect your first Gitea backend. Gitpub Desktop works with any compatible Gitea server.
${serverFormTemplate(null)}
`;
disableInputAutocomplete(appRoot);
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 = new Map();
for (const repo of repos) {
const org = repoOwnerName(repo);
if (!groups.has(org)) groups.set(org, []);
groups.get(org).push(repo);
}
return groups;
}
function localRepoOwnerName({ path = "", name = "", matchedRemoteUrl = "" } = {}) {
const remoteRepo = matchingServerRepoByRemote(matchedRemoteUrl);
if (remoteRepo) return repoOwnerName(remoteRepo);
const repoName = name || repoNameFromPath(path);
const namedRepo = matchingServerRepoByName(repoName);
if (namedRepo) return repoOwnerName(namedRepo);
return parentFolderName(path);
}
function groupedLocalRepos(items, ownerForItem) {
const groups = new Map();
for (const item of items) {
const owner = ownerForItem(item) || "Local";
if (!groups.has(owner)) groups.set(owner, []);
groups.get(owner).push(item);
}
return groups;
}
function serverRepoListTemplate(repos) {
const visibleRepos = repos.slice(0, 60);
if (!visibleRepos.length) {
return `No repositories found
`;
}
return Array.from(groupedByOrg(visibleRepos).entries()).map(([owner, ownerRepos]) => `
${escapeHtml(owner)}
${ownerRepos.map((repo) => `
${escapeHtml(repo.full_name)}
${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")}
View
Clone
`).join("")}
`).join("");
}
function recentLocalReposTemplate(paths = []) {
if (!paths.length) {
return `No recent repositories
`;
}
return Array.from(groupedLocalRepos(paths, (path) => localRepoOwnerName({ path })).entries()).map(([owner, ownerPaths]) => `
${escapeHtml(owner)}
${ownerPaths.map((path) => `
${escapeHtml(repoNameFromPath(path))}
${escapeHtml(path)}
Open
${WINDOW_CLOSE_ICON}
`).join("")}
`).join("");
}
function localRepoScanTemplate() {
const state = getState();
const results = state.localRepoScanResults || [];
if (state.localRepoScanLoading) {
return `Scanning local folders…
`;
}
if (state.localRepoScanError) {
return `${escapeHtml(state.localRepoScanError)}
`;
}
if (!results.length) {
return `No local repositories scanned yet. Only repos with remotes from the selected server will appear.
`;
}
return `
${Array.from(groupedLocalRepos(results, (repo) => localRepoOwnerName(repo)).entries()).map(([owner, ownerRepos]) => `
${escapeHtml(owner)}
${ownerRepos.map((repo) => `
${escapeHtml(repo.name || repoNameFromPath(repo.path))}
${escapeHtml(repo.path)}
${repo.matchedRemoteUrl ? `${escapeHtml(repo.matchedRemoteUrl)} ` : ""}
Open
Viewer
`).join("")}
`).join("")}
`;
}
function breadcrumbTemplate(repoName, path = "") {
const parts = path.split("/").filter(Boolean);
const crumbs = [`${escapeHtml(repoName)} `];
parts.forEach((part, index) => {
const crumbPath = parts.slice(0, index + 1).join("/");
crumbs.push(`/ ${escapeHtml(part)} `);
});
return crumbs.join("");
}
function filePreviewTemplate(file) {
const language = languageForPath(file.path);
const meta = [language, formatBytes(file.size)].filter(Boolean).join(" · ");
const header = `
`;
if (file.previewBase64 && file.previewMime) {
const mime = escapeHtml(file.previewMime);
const b64 = file.previewBase64;
return `${header}`;
}
if (file.tooLarge) {
return header + `File too large to preview
Preview is limited to ${formatBytes(maxPreviewBytes)}.
`;
}
if (file.isBinary) {
return header + `Binary file
Binary content cannot be previewed.
`;
}
if (isMarkdownPath(file.path)) {
return header + `${renderMarkdown(file.content || "")}
`;
}
return header + `${highlightCode(file.content || "", language)} `;
}
// ── Modal content functions ───────────────────────────────────────────────────
function reposModalContent(state) {
const visibleRepos = filteredRepositories();
const activeServer = getActiveServer();
return `
Local Repositories
Server Repositories
${activeReposTab === "local" ? `
Recent Local Repositories
${recentLocalReposTemplate(state.settings.recentRepositories)}
Open from Path
Open
Find Local Repositories
${state.localRepoScanLoading ? "Scanning…" : "Scan"}
${localRepoScanTemplate()}
` : `
`}
`;
}
function cloneModalContent(state) {
const visibleRepos = filteredRepositories();
return `
Server
${state.settings.servers.map((server) => `${escapeHtml(server.displayName)} `).join("")}
Remote repository
Paste URL manually below
${visibleRepos.slice(0, 100).map((repo) => `${escapeHtml(repo.full_name)} `).join("")}
Clone Repository
${gitOutput
? `
${escapeHtml(gitOutput)} `
: `
Clone output will appear here. `}
`;
}
function serversModalContent(state) {
const servers = state.settings.servers;
return `
${settingsNotice ? `
${escapeHtml(settingsNotice)}
` : ""}
${servers.length
? servers.map((server) => `
${escapeHtml(server.displayName)}
${escapeHtml(server.serverUrl)}
${server.id === state.settings.activeServerId ? `${CHECK_ICON} Active` : "Set active"}
Edit
Remove
`).join("")
: `
No servers configured
`}
`;
}
function settingsModalContent(state) {
const editorDropdownValue = selectedEditorDropdownValue(state);
const customEditorPath = editorDropdownValue === CUSTOM_EDITOR_VALUE ? state.settings.externalEditorPath?.trim() || "" : "";
return `
Appearance
Theme
Dark
Light
System
Git
Git executable path
Default clone directory
Code editor
${externalEditorOptionsTemplate(state)}
${state.installedIdeScanLoading ? "Scanning..." : "Rescan"}
${state.installedIdeScanError ? `
${escapeHtml(state.installedIdeScanError)}
` : ""}
${!state.installedIdeScanLoading && !state.installedIdes.length ? `
No installed IDEs detected yet. You can use the default code command or choose a custom app.
` : ""}
Browse
Default server
None
${state.settings.servers.map((server) => `${escapeHtml(server.displayName)} `).join("")}
Auto-fetch when opening a repository
Save Settings
${settingsNotice ? `
${escapeHtml(settingsNotice)}
` : ""}
About
Gitpub Desktop
A focused desktop client for Gitea-compatible Git servers.
Built with Tauri 2 and vanilla JavaScript.
`;
}
function viewerModalContent(state) {
const viewer = state.viewer;
if (!viewer.source) {
return `
No repository open in viewer
Open a repository from the Repositories panel first.
`;
}
const rows = [];
if (viewer.path) {
rows.push(`
${FOLDER_ICON}
..
`);
}
viewer.entries.forEach((entry) => {
const active = viewer.selectedFile?.path === entry.path ? " active" : "";
rows.push(`
${entry.type === "dir" ? FOLDER_ICON : FILE_ICON}
${escapeHtml(entry.name)}
${entry.type === "file" ? formatBytes(entry.size) : ""}
`);
});
const showReadme = viewer.readmeFile && !viewer.selectedFile;
return `
${viewer.error ? `
${escapeHtml(viewer.error)}
` : ""}
${viewer.loading ? `
Loading…
` : ""}
${viewer.selectedFile ? `
${filePreviewTemplate(viewer.selectedFile)}
` : ""}
${showReadme ? `
${renderMarkdown(viewer.readmeFile.content || "")}
` : ""}
${!viewer.selectedFile && !viewer.readmeFile && !viewer.loading
? `
Select a file from the tree to preview
`
: ""}
`;
}
function modalTemplate(state) {
if (!activeModal) return "";
const viewer = state.viewer;
const configs = {
repos: { title: "Repositories", content: reposModalContent(state), wide: true },
clone: { title: "Clone Repository", content: cloneModalContent(state), wide: false },
servers: { title: "Gitea Servers", content: serversModalContent(state), wide: true },
settings: { title: "Settings", content: settingsModalContent(state), wide: true },
viewer: { title: viewer.repoName ? `File Viewer — ${escapeHtml(viewer.repoName)}` : "File Viewer", content: viewerModalContent(state), wide: true, fullHeight: true },
};
const cfg = configs[activeModal];
if (!cfg) return "";
return ``;
}
// ── Main dashboard view ───────────────────────────────────────────────────────
function dashboardView() {
const state = getState();
const displayRepoName = currentRepositoryName();
const displayBranchName = currentBranchName();
const hasLocalRepo = Boolean(state.selectedRepoPath);
const isMacos = currentPlatform() === "macos";
const toolbarPlatformClass = isMacos ? "gd-toolbar-macos" : "gd-toolbar-standard";
const windowControlsHTML = windowControlsTemplate(isMacos);
// ── Repository identity ─────────────────────────────────────────────────
const repoIdentity = (() => {
if (!state.selectedRepoPath && !state.selectedRepoName) {
return { primary: "No repository", secondary: "Select or clone a repository" };
}
const name = state.selectedRepoName || repoNameFromPath(state.selectedRepoPath);
const serverRepo = repositories.find((r) => {
const rName = r.full_name.split("/")[1] || "";
return rName === name || r.full_name === name;
});
if (serverRepo) {
return { primary: serverRepo.full_name, secondary: state.selectedRepoPath || "" };
}
const pathParts = (state.selectedRepoPath || "").split(/[/\\]/).filter(Boolean);
const primary = pathParts.length >= 2 ? pathParts.slice(-2).join("/") : name;
return { primary, secondary: state.selectedRepoPath || "" };
})();
// ── Contextual sync action ──────────────────────────────────────────────
const syncCfg = syncButtonConfig(state);
// ── Toolbar ─────────────────────────────────────────────────────────────
const toolbarHTML = `
${isMacos ? windowControlsHTML : ""}
${isMacos ? "" : windowControlsHTML}
`;
// ── Sidebar content ─────────────────────────────────────────────────────
const filteredFiles = state.workingTree.files.filter((file) =>
file.path.toLowerCase().includes(state.changesFilter.trim().toLowerCase())
);
const selectedCount = filteredFiles.filter((file) => state.workingTree.selectedPaths.has(file.path)).length;
const allSelected = filteredFiles.length > 0 && selectedCount === filteredFiles.length;
let leftContentHTML = "";
if (activeView === "history") {
const historyFilter = state.historyFilter.trim().toLowerCase();
const commits = state.history.commits.filter((commit) =>
[commit.title, commit.author, commit.shortHash].some((v) => (v || "").toLowerCase().includes(historyFilter))
);
leftContentHTML = `
${state.history.loading ? `
Loading…
` : ""}
${state.history.error ? `
${escapeHtml(state.history.error)}
` : ""}
${!hasLocalRepo ? `
No repository selected
` : ""}
${hasLocalRepo && !state.history.loading && commits.length
? commits.map((commit) => `
${escapeHtml(commit.title)}
${escapeHtml(commit.author)} · ${escapeHtml(commit.shortHash)}
`).join("")
: ""}
${hasLocalRepo && !state.history.loading && !commits.length
? `
No commits found
`
: ""}
`;
} else {
leftContentHTML = `
${filteredFiles.length} changed file${filteredFiles.length === 1 ? "" : "s"}
${selectedCount > 0 ? `${selectedCount} staged` : ""}
${state.workingTree.loading ? `
Loading…
` : ""}
${state.workingTree.error ? `
${escapeHtml(state.workingTree.error)}
` : ""}
${!hasLocalRepo ? `
No repository open
` : ""}
${hasLocalRepo && !state.workingTree.loading && !filteredFiles.length && !state.workingTree.error
? `
Working tree is clean
`
: ""}
${groupedChangedFiles(filteredFiles).map((group) => `
${statusLabel(group.status)}
${group.files.map((file) => `
${escapeHtml(file.status[0]?.toUpperCase() || "M")}
${escapeHtml(file.path)}
`).join("")}
`).join("")}
${gitOutput ? `
${escapeHtml(gitOutput)} ` : ""}
`;
}
// ── Main area ───────────────────────────────────────────────────────────
let mainHTML = "";
if (activeView === "history") {
const commit = state.history.selectedCommit;
if (commit) {
mainHTML = `
${escapeHtml(commit.message)}
${(commit.files || []).map((file) => `${escapeHtml(file.status)} ${escapeHtml(file.path)} `).join("")}
${renderDiff(commit.diff || "No diff available.")}
`;
} else {
mainHTML = workflowEmptyStateTemplate({
title: hasLocalRepo ? "No commit selected" : "Open a repository to view history",
message: hasLocalRepo ? "Select a commit from the list to review its files and diff." : "History appears after you open or clone a local repository.",
icon: "diff",
actions: !hasLocalRepo,
});
}
} else {
const selectedChange = state.workingTree.files.find((file) => file.path === state.workingTree.selectedPath);
mainHTML = `
${state.workingTree.diffLoading ? `
Loading diff…
` : ""}
${state.workingTree.diffError ? `
${escapeHtml(state.workingTree.diffError)}
` : ""}
${selectedChange
? diffTemplate(state.workingTree.selectedDiff)
: workflowEmptyStateTemplate({
title: hasLocalRepo ? "You're all caught up" : "Start with a repository",
message: hasLocalRepo
? "There are no uncommitted changes in this repository."
: "Open a local repository or clone one from a Gitea-compatible server.",
icon: hasLocalRepo ? "diff" : "repo",
actions: !hasLocalRepo,
})}
`;
}
// ── Commit panel ────────────────────────────────────────────────────────
const hasCommitSummary = Boolean((state.commitSummary || state.commitMessage || "").trim());
const hasSelectedFiles = state.workingTree.selectedPaths.size > 0;
const canCommit = hasLocalRepo && hasSelectedFiles && hasCommitSummary;
const commitAreaHTML = `
Commit to ${escapeHtml(displayBranchName)}
`;
// ── Assemble ─────────────────────────────────────────────────────────────
appRoot.innerHTML = `
${leftContentHTML}
${activeView === "changes" ? commitAreaHTML : ""}
${mainHTML}
${modalTemplate(state)}
${branchDialogTemplate(state, displayBranchName)}
`;
disableInputAutocomplete(appRoot);
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: ${errorMessage(error)}`;
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);
disableInputAutocomplete(slot);
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: ${errorMessage(error)}`;
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: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
} 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 = errorMessage(error);
} 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: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
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,
previewMime: file.previewMime,
previewBase64: file.previewBase64,
};
}
} catch (error) {
viewer.error = `Unable to preview file: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
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: ${errorMessage(error)}`;
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: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
} 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: ${errorMessage(error)}`;
} 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) {
const message = errorMessage(error);
gitOutput = `${actionName} failed: ${message}`;
state.sync.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();
});
// Repository modal tabs
document.querySelectorAll("[data-repos-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
activeReposTab = btn.dataset.reposTab || "local";
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: ${errorMessage(error)}`;
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: ${errorMessage(error)}`;
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 = errorMessage(error);
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: ${errorMessage(error)}`;
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: ${errorMessage(error)}`;
}
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: ${errorMessage(error)}`;
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();
applyPlatformChrome();
dashboardView();
}
window.addEventListener("DOMContentLoaded", async () => {
applyTheme();
applyPlatformChrome();
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();
});