2612 lines
110 KiB
JavaScript
2612 lines
110 KiB
JavaScript
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 currentTauriWindow() {
|
||
const tauriWindow = window.__TAURI__?.window;
|
||
if (tauriWindow?.getCurrentWindow) {
|
||
return tauriWindow.getCurrentWindow();
|
||
}
|
||
return tauriWindow?.appWindow || null;
|
||
}
|
||
|
||
async function handleWindowAction(action) {
|
||
const appWindow = currentTauriWindow();
|
||
if (!appWindow) {
|
||
console.warn("Tauri window API not available");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (action === "minimize") {
|
||
await appWindow.minimize();
|
||
} else if (action === "maximize") {
|
||
const isMaximized = await appWindow.isMaximized();
|
||
if (isMaximized) {
|
||
await appWindow.unmaximize();
|
||
} else {
|
||
await appWindow.maximize();
|
||
}
|
||
} else if (action === "close") {
|
||
await appWindow.close();
|
||
}
|
||
} catch (error) {
|
||
console.error(`Unable to ${action} window:`, error);
|
||
}
|
||
}
|
||
|
||
async function startWindowDrag() {
|
||
const appWindow = currentTauriWindow();
|
||
if (!appWindow) return;
|
||
|
||
try {
|
||
await appWindow.startDragging();
|
||
} catch (error) {
|
||
console.error("Unable to start window drag:", error);
|
||
}
|
||
}
|
||
|
||
function uid() {
|
||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
}
|
||
|
||
function escapeHtml(value = "") {
|
||
return value
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
if (!Number.isFinite(value)) return "";
|
||
if (value < 1024) return `${value} B`;
|
||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
function parentPath(path = "") {
|
||
const parts = path.split("/").filter(Boolean);
|
||
parts.pop();
|
||
return parts.join("/");
|
||
}
|
||
|
||
function repoNameFromPath(path = "") {
|
||
return path.split(/[/\\]/).filter(Boolean).pop() || path;
|
||
}
|
||
|
||
function repoNameFromUrl(url = "") {
|
||
const cleanUrl = url.trim().split(/[?#]/)[0].replace(/\/+$/, "").replace(/\.git$/i, "");
|
||
return cleanUrl.split(/[/\\:]/).filter(Boolean).pop() || "";
|
||
}
|
||
|
||
function joinDirectoryPath(parent = "", child = "") {
|
||
if (!parent || !child) return parent || child;
|
||
const separator = parent.includes("\\") ? "\\" : "/";
|
||
return `${parent.replace(/[\\/]+$/, "")}${separator}${child}`;
|
||
}
|
||
|
||
function currentRepositoryName() {
|
||
return getState().selectedRepoName || defaultRepositoryName;
|
||
}
|
||
|
||
function currentBranchName() {
|
||
return getState().workingTree.branch || getState().viewer.branch || defaultBranchName;
|
||
}
|
||
|
||
function normalizeRemoteUrl(value = "") {
|
||
return value.trim().replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
|
||
}
|
||
|
||
function serverRepoRemoteUrls() {
|
||
const urls = repositories.flatMap((repo) => [
|
||
repo.clone_url,
|
||
repo.ssh_url,
|
||
repo.html_url,
|
||
repo.original_url,
|
||
]);
|
||
return [...new Set(urls.map((url) => normalizeRemoteUrl(url || "")).filter(Boolean))];
|
||
}
|
||
|
||
function applyTheme() {
|
||
const theme = getState().settings.theme || "dark";
|
||
const systemPrefersLight = window.matchMedia?.("(prefers-color-scheme: light)")?.matches;
|
||
document.documentElement.dataset.theme = theme === "system" && systemPrefersLight ? "light" : theme;
|
||
}
|
||
|
||
function selectedGitPath() {
|
||
return getState().settings.gitExecutablePath;
|
||
}
|
||
|
||
function isDetectedEditorPath(value = "", detectedEditors = []) {
|
||
const normalizedValue = value.trim().toLowerCase();
|
||
return detectedEditors.some((editor) => editor.executablePath.toLowerCase() === normalizedValue);
|
||
}
|
||
|
||
function selectedEditorDropdownValue(state) {
|
||
const editorPath = state.settings.externalEditorPath?.trim() || "";
|
||
if (!editorPath) return DEFAULT_EDITOR_VALUE;
|
||
return isDetectedEditorPath(editorPath, state.installedIdes) ? editorPath : CUSTOM_EDITOR_VALUE;
|
||
}
|
||
|
||
function externalEditorOptionsTemplate(state) {
|
||
const editorPath = state.settings.externalEditorPath?.trim() || "";
|
||
const selectedValue = selectedEditorDropdownValue(state);
|
||
const detectedOptions = state.installedIdes
|
||
.map((editor) => `<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 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(/("[^&]*?")(\s*:)/g, '<span class="syntax-key">$1</span>$2');
|
||
} else if (language === "CSS") {
|
||
html = html.replace(/([.#]?[a-zA-Z0-9_-]+)(\s*\{)/g, '<span class="syntax-key">$1</span>$2');
|
||
}
|
||
return html;
|
||
}
|
||
|
||
function isMarkdownPath(path = "") {
|
||
return /\.(md|markdown)$/i.test(path);
|
||
}
|
||
|
||
function safeMarkdownHref(value = "") {
|
||
const href = value.trim();
|
||
const lowerHref = href.toLowerCase();
|
||
if (!href || lowerHref.startsWith("javascript:") || lowerHref.startsWith("data:")) {
|
||
return "";
|
||
}
|
||
return href;
|
||
}
|
||
|
||
function renderMarkdownInline(value = "") {
|
||
return escapeHtml(value)
|
||
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
||
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^&]*")?\)/g, (_match, label, href) => {
|
||
const safeHref = safeMarkdownHref(href.replaceAll("&", "&"));
|
||
return safeHref ? `<a href="${escapeHtml(safeHref)}">${label}</a>` : label;
|
||
})
|
||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||
.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||
}
|
||
|
||
function renderMarkdown(content = "") {
|
||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||
const html = [];
|
||
let inFence = false;
|
||
let fenceLanguage = "";
|
||
let fenceLines = [];
|
||
let listType = "";
|
||
let listItems = [];
|
||
|
||
const flushList = () => {
|
||
if (!listType) return;
|
||
html.push(`<${listType}>${listItems.map((item) => `<li>${renderMarkdownInline(item)}</li>`).join("")}</${listType}>`);
|
||
listType = "";
|
||
listItems = [];
|
||
};
|
||
|
||
const flushFence = () => {
|
||
html.push(`<pre class="markdown-code" data-language="${escapeHtml(fenceLanguage)}"><code>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
||
inFence = false;
|
||
fenceLanguage = "";
|
||
fenceLines = [];
|
||
};
|
||
|
||
for (const line of lines) {
|
||
const fence = line.match(/^```(\w+)?\s*$/);
|
||
if (fence) {
|
||
if (inFence) {
|
||
flushFence();
|
||
} else {
|
||
flushList();
|
||
inFence = true;
|
||
fenceLanguage = fence[1] || "";
|
||
fenceLines = [];
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (inFence) {
|
||
fenceLines.push(line);
|
||
continue;
|
||
}
|
||
|
||
if (!line.trim()) {
|
||
flushList();
|
||
continue;
|
||
}
|
||
|
||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||
if (heading) {
|
||
flushList();
|
||
const level = heading[1].length;
|
||
html.push(`<h${level}>${renderMarkdownInline(heading[2])}</h${level}>`);
|
||
continue;
|
||
}
|
||
|
||
if (/^\s*[-*_]{3,}\s*$/.test(line)) {
|
||
flushList();
|
||
html.push("<hr>");
|
||
continue;
|
||
}
|
||
|
||
const quote = line.match(/^>\s?(.*)$/);
|
||
if (quote) {
|
||
flushList();
|
||
html.push(`<blockquote>${renderMarkdownInline(quote[1])}</blockquote>`);
|
||
continue;
|
||
}
|
||
|
||
const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
|
||
if (unordered) {
|
||
if (listType && listType !== "ul") flushList();
|
||
listType = "ul";
|
||
listItems.push(unordered[1]);
|
||
continue;
|
||
}
|
||
|
||
const ordered = line.match(/^\s*\d+\.\s+(.+)$/);
|
||
if (ordered) {
|
||
if (listType && listType !== "ol") flushList();
|
||
listType = "ol";
|
||
listItems.push(ordered[1]);
|
||
continue;
|
||
}
|
||
|
||
flushList();
|
||
html.push(`<p>${renderMarkdownInline(line)}</p>`);
|
||
}
|
||
|
||
if (inFence) flushFence();
|
||
flushList();
|
||
return html.join("");
|
||
}
|
||
|
||
function decodeBase64Content(content = "") {
|
||
const cleaned = content.replace(/\s/g, "");
|
||
const binary = atob(cleaned);
|
||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||
if (bytes.byteLength > maxPreviewBytes) {
|
||
return { content: "", size: bytes.byteLength, isBinary: false, tooLarge: true };
|
||
}
|
||
if (bytes.includes(0)) {
|
||
return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false };
|
||
}
|
||
try {
|
||
return {
|
||
content: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||
size: bytes.byteLength,
|
||
isBinary: false,
|
||
tooLarge: false,
|
||
};
|
||
} catch {
|
||
return { content: "", size: bytes.byteLength, isBinary: true, tooLarge: false };
|
||
}
|
||
}
|
||
|
||
function normaliseRemoteEntries(contents) {
|
||
const items = Array.isArray(contents) ? contents : [contents];
|
||
return items
|
||
.map((item) => ({
|
||
name: item.name,
|
||
path: item.path || item.name,
|
||
type: item.type === "dir" ? "dir" : "file",
|
||
size: item.size,
|
||
}))
|
||
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
||
}
|
||
|
||
function normaliseLocalEntries(entries) {
|
||
return entries.map((entry) => ({
|
||
name: entry.name,
|
||
path: entry.path,
|
||
type: entry.entryType === "tree" ? "dir" : "file",
|
||
size: entry.size,
|
||
}));
|
||
}
|
||
|
||
function serverFormTemplate(server = null) {
|
||
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);
|
||
|
||
// ── 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 = `
|
||
<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>
|
||
<div class="gd-window-controls" role="group" aria-label="Window controls">
|
||
<button class="gd-window-control" type="button" data-window-action="minimize" title="Minimize" aria-label="Minimize window">${WINDOW_MINIMIZE_ICON}</button>
|
||
<button class="gd-window-control" type="button" data-window-action="maximize" title="Maximize" aria-label="Maximize window">${WINDOW_MAXIMIZE_ICON}</button>
|
||
<button class="gd-window-control gd-window-close" type="button" data-window-action="close" title="Close" aria-label="Close window">${WINDOW_CLOSE_ICON}</button>
|
||
</div>
|
||
`;
|
||
|
||
// ── 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">
|
||
${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();
|
||
});
|