Add repo modal tabs and grouped repo lists

Introduce a tabbed repository modal with new styling and grouped lists. Adds CSS for modal tabs and repo-owner group/divider visuals. In JS, add activeReposTab state, helper functions to infer repo owners and match remotes, and utilities to group server and local repos. Replace flat lists with serverRepoListTemplate/recentLocalReposTemplate and group local scan results; update reposModalContent to render tabbed local/server panels and bind tab click events.
This commit is contained in:
Andrew Zambazos
2026-05-13 18:36:21 +12:00
parent 37fc1dc626
commit 19f7631263
2 changed files with 203 additions and 37 deletions
+131 -37
View File
@@ -50,6 +50,7 @@ let activeView = "changes"; // "changes" | "history"
let activeModal = ""; // "" | "repos" | "clone" | "servers" | "settings" | "viewer"
let utilityMenuOpen = false;
let repoOwnerFilter = "all";
let activeReposTab = "local"; // "local" | "server"
const maxPreviewBytes = 256 * 1024;
const defaultRepositoryName = "Gitpub-Desktop";
const defaultBranchName = "main";
@@ -207,6 +208,11 @@ function repoNameFromPath(path = "") {
return path.split(/[/\\]/).filter(Boolean).pop() || path;
}
function parentFolderName(path = "") {
const parts = path.split(/[/\\]/).filter(Boolean);
return parts.length > 1 ? parts[parts.length - 2] : "Local";
}
function repoNameFromUrl(url = "") {
const cleanUrl = url.trim().split(/[?#]/)[0].replace(/\/+$/, "").replace(/\.git$/i, "");
return cleanUrl.split(/[/\\:]/).filter(Boolean).pop() || "";
@@ -240,6 +246,28 @@ function serverRepoRemoteUrls() {
return [...new Set(urls.map((url) => normalizeRemoteUrl(url || "")).filter(Boolean))];
}
function repoOwnerName(repo) {
return repo.owner?.login || repo.full_name.split("/")[0] || "Unknown";
}
function repoRemoteUrls(repo) {
return [repo.clone_url, repo.ssh_url, repo.html_url, repo.original_url]
.map((url) => normalizeRemoteUrl(url || ""))
.filter(Boolean);
}
function matchingServerRepoByRemote(remoteUrl = "") {
const normalizedRemote = normalizeRemoteUrl(remoteUrl);
if (!normalizedRemote) return null;
return repositories.find((repo) => repoRemoteUrls(repo).includes(normalizedRemote)) || null;
}
function matchingServerRepoByName(name = "") {
const normalizedName = name.trim().toLowerCase();
if (!normalizedName) return null;
return repositories.find((repo) => (repo.full_name.split("/").pop() || "").toLowerCase() === normalizedName) || null;
}
function applyTheme() {
const theme = getState().settings.theme || "dark";
const systemPrefersLight = window.matchMedia?.("(prefers-color-scheme: light)")?.matches;
@@ -881,15 +909,87 @@ function filteredRepositories() {
}
function groupedByOrg(repos) {
const groups = {};
const groups = new Map();
for (const repo of repos) {
const org = repo.owner?.login || "Unknown";
if (!groups[org]) groups[org] = [];
groups[org].push(repo);
const org = repoOwnerName(repo);
if (!groups.has(org)) groups.set(org, []);
groups.get(org).push(repo);
}
return groups;
}
function localRepoOwnerName({ path = "", name = "", matchedRemoteUrl = "" } = {}) {
const remoteRepo = matchingServerRepoByRemote(matchedRemoteUrl);
if (remoteRepo) return repoOwnerName(remoteRepo);
const repoName = name || repoNameFromPath(path);
const namedRepo = matchingServerRepoByName(repoName);
if (namedRepo) return repoOwnerName(namedRepo);
return parentFolderName(path);
}
function groupedLocalRepos(items, ownerForItem) {
const groups = new Map();
for (const item of items) {
const owner = ownerForItem(item) || "Local";
if (!groups.has(owner)) groups.set(owner, []);
groups.get(owner).push(item);
}
return groups;
}
function serverRepoListTemplate(repos) {
const visibleRepos = repos.slice(0, 60);
if (!visibleRepos.length) {
return `<div class="gd-modal-empty">No repositories found</div>`;
}
return Array.from(groupedByOrg(visibleRepos).entries()).map(([owner, ownerRepos]) => `
<div class="repo-owner-group">
<div class="repo-owner-divider">
<span>${escapeHtml(owner)}</span>
</div>
${ownerRepos.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>
`).join("");
}
function recentLocalReposTemplate(paths = []) {
if (!paths.length) {
return `<div class="gd-modal-empty">No recent repositories</div>`;
}
return Array.from(groupedLocalRepos(paths, (path) => localRepoOwnerName({ path })).entries()).map(([owner, ownerPaths]) => `
<div class="repo-owner-group">
<div class="repo-owner-divider">
<span>${escapeHtml(owner)}</span>
</div>
${ownerPaths.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" aria-label="Remove from list">${WINDOW_CLOSE_ICON}</button>
</div>
</div>`).join("")}
</div>
`).join("");
}
function localRepoScanTemplate() {
const state = getState();
const results = state.localRepoScanResults || [];
@@ -907,9 +1007,12 @@ function localRepoScanTemplate() {
}
return `<div class="gd-modal-scan-list">
${results
.map(
(repo) => `
${Array.from(groupedLocalRepos(results, (repo) => localRepoOwnerName(repo)).entries()).map(([owner, ownerRepos]) => `
<div class="repo-owner-group">
<div class="repo-owner-divider">
<span>${escapeHtml(owner)}</span>
</div>
${ownerRepos.map((repo) => `
<div class="gd-modal-list-item">
<div class="gd-modal-item-info">
<strong>${escapeHtml(repo.name || repoNameFromPath(repo.path))}</strong>
@@ -920,10 +1023,9 @@ function localRepoScanTemplate() {
<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>`).join("")}
</div>
`).join("")}
</div>`;
}
@@ -970,22 +1072,15 @@ function reposModalContent(state) {
const visibleRepos = filteredRepositories();
const activeServer = getActiveServer();
return `
<div class="gd-modal-two-col">
<div class="gd-modal-tabs" role="tablist" aria-label="Repository sources">
<button class="gd-modal-tab ${activeReposTab === "local" ? "active" : ""}" data-repos-tab="local" type="button" role="tab" aria-selected="${activeReposTab === "local"}">Local Repositories</button>
<button class="gd-modal-tab ${activeReposTab === "server" ? "active" : ""}" data-repos-tab="server" type="button" role="tab" aria-selected="${activeReposTab === "server"}">Server Repositories</button>
</div>
<div class="gd-modal-tab-panel">
${activeReposTab === "local" ? `
<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" aria-label="Remove from list">${WINDOW_CLOSE_ICON}</button>
</div>
</div>`).join("")
: `<div class="gd-modal-empty">No recent repositories</div>`}
${recentLocalReposTemplate(state.settings.recentRepositories)}
<div class="gd-modal-divider"></div>
@@ -1004,7 +1099,7 @@ function reposModalContent(state) {
</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>
@@ -1020,19 +1115,10 @@ function reposModalContent(state) {
<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>`}
${serverRepoListTemplate(visibleRepos)}
</div>
</div>
`}
</div>
`;
}
@@ -2281,6 +2367,14 @@ function bindDashboardEvents() {
render();
});
// Repository modal tabs
document.querySelectorAll("[data-repos-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
activeReposTab = btn.dataset.reposTab || "local";
render();
});
});
// Refresh repos (in modal)
document.getElementById("refresh-repos-btn")?.addEventListener("click", async () => {
await loadRepositories();