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
+72
View File
@@ -1233,6 +1233,43 @@
min-height: 0; min-height: 0;
} }
.gd-modal-tabs {
display: flex;
gap: 6px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel-alt);
}
.gd-modal-tab {
flex: 1;
justify-content: center;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
}
.gd-modal-tab:hover {
background: var(--bg-hover);
color: var(--text-main);
}
.gd-modal-tab.active {
border-color: transparent;
background: var(--accent-strong);
color: #fff;
}
.gd-modal-tab-panel {
min-height: 430px;
display: flex;
flex-direction: column;
}
.gd-modal-two-col { .gd-modal-two-col {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -1252,6 +1289,10 @@
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
} }
.gd-modal-tab-panel .gd-modal-section-alt {
border-left: 0;
}
.gd-modal-section-header { .gd-modal-section-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1318,6 +1359,10 @@
max-height: 300px; max-height: 300px;
} }
.gd-modal-tab-panel .gd-modal-scroll-list {
max-height: none;
}
.gd-modal-scan-list { .gd-modal-scan-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1435,6 +1480,33 @@
color: #fff; color: #fff;
} }
.repo-owner-group {
padding-bottom: 6px;
}
.repo-owner-group + .repo-owner-group {
margin-top: 10px;
}
.repo-owner-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 2px 0 4px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.repo-owner-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
/* ── Branch dialog ────────────────────────────────────────────────────────── */ /* ── Branch dialog ────────────────────────────────────────────────────────── */
.modal-backdrop { .modal-backdrop {
+131 -37
View File
@@ -50,6 +50,7 @@ let activeView = "changes"; // "changes" | "history"
let activeModal = ""; // "" | "repos" | "clone" | "servers" | "settings" | "viewer" let activeModal = ""; // "" | "repos" | "clone" | "servers" | "settings" | "viewer"
let utilityMenuOpen = false; let utilityMenuOpen = false;
let repoOwnerFilter = "all"; let repoOwnerFilter = "all";
let activeReposTab = "local"; // "local" | "server"
const maxPreviewBytes = 256 * 1024; const maxPreviewBytes = 256 * 1024;
const defaultRepositoryName = "Gitpub-Desktop"; const defaultRepositoryName = "Gitpub-Desktop";
const defaultBranchName = "main"; const defaultBranchName = "main";
@@ -207,6 +208,11 @@ function repoNameFromPath(path = "") {
return path.split(/[/\\]/).filter(Boolean).pop() || 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 = "") { function repoNameFromUrl(url = "") {
const cleanUrl = url.trim().split(/[?#]/)[0].replace(/\/+$/, "").replace(/\.git$/i, ""); const cleanUrl = url.trim().split(/[?#]/)[0].replace(/\/+$/, "").replace(/\.git$/i, "");
return cleanUrl.split(/[/\\:]/).filter(Boolean).pop() || ""; return cleanUrl.split(/[/\\:]/).filter(Boolean).pop() || "";
@@ -240,6 +246,28 @@ function serverRepoRemoteUrls() {
return [...new Set(urls.map((url) => normalizeRemoteUrl(url || "")).filter(Boolean))]; 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() { function applyTheme() {
const theme = getState().settings.theme || "dark"; const theme = getState().settings.theme || "dark";
const systemPrefersLight = window.matchMedia?.("(prefers-color-scheme: light)")?.matches; const systemPrefersLight = window.matchMedia?.("(prefers-color-scheme: light)")?.matches;
@@ -881,15 +909,87 @@ function filteredRepositories() {
} }
function groupedByOrg(repos) { function groupedByOrg(repos) {
const groups = {}; const groups = new Map();
for (const repo of repos) { for (const repo of repos) {
const org = repo.owner?.login || "Unknown"; const org = repoOwnerName(repo);
if (!groups[org]) groups[org] = []; if (!groups.has(org)) groups.set(org, []);
groups[org].push(repo); groups.get(org).push(repo);
} }
return groups; 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() { function localRepoScanTemplate() {
const state = getState(); const state = getState();
const results = state.localRepoScanResults || []; const results = state.localRepoScanResults || [];
@@ -907,9 +1007,12 @@ function localRepoScanTemplate() {
} }
return `<div class="gd-modal-scan-list"> return `<div class="gd-modal-scan-list">
${results ${Array.from(groupedLocalRepos(results, (repo) => localRepoOwnerName(repo)).entries()).map(([owner, ownerRepos]) => `
.map( <div class="repo-owner-group">
(repo) => ` <div class="repo-owner-divider">
<span>${escapeHtml(owner)}</span>
</div>
${ownerRepos.map((repo) => `
<div class="gd-modal-list-item"> <div class="gd-modal-list-item">
<div class="gd-modal-item-info"> <div class="gd-modal-item-info">
<strong>${escapeHtml(repo.name || repoNameFromPath(repo.path))}</strong> <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="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> <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>
</div> </div>`).join("")}
` </div>
) `).join("")}
.join("")}
</div>`; </div>`;
} }
@@ -970,22 +1072,15 @@ function reposModalContent(state) {
const visibleRepos = filteredRepositories(); const visibleRepos = filteredRepositories();
const activeServer = getActiveServer(); const activeServer = getActiveServer();
return ` 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"> <div class="gd-modal-section">
<h4 class="gd-modal-section-title">Recent Local Repositories</h4> <h4 class="gd-modal-section-title">Recent Local Repositories</h4>
${state.settings.recentRepositories.length ${recentLocalReposTemplate(state.settings.recentRepositories)}
? 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>`}
<div class="gd-modal-divider"></div> <div class="gd-modal-divider"></div>
@@ -1004,7 +1099,7 @@ function reposModalContent(state) {
</div> </div>
${localRepoScanTemplate()} ${localRepoScanTemplate()}
</div> </div>
` : `
<div class="gd-modal-section gd-modal-section-alt"> <div class="gd-modal-section gd-modal-section-alt">
<div class="gd-modal-section-header"> <div class="gd-modal-section-header">
<h4 class="gd-modal-section-title" style="margin:0">Server Repositories</h4> <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> <button class="pill-btn ${repoOwnerFilter === "orgs" ? "active" : ""}" data-owner-filter="orgs">Organizations</button>
</div> </div>
<div class="gd-modal-scroll-list"> <div class="gd-modal-scroll-list">
${visibleRepos.slice(0, 60).map((repo) => ` ${serverRepoListTemplate(visibleRepos)}
<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> </div>
`}
</div> </div>
`; `;
} }
@@ -2281,6 +2367,14 @@ function bindDashboardEvents() {
render(); render();
}); });
// Repository modal tabs
document.querySelectorAll("[data-repos-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
activeReposTab = btn.dataset.reposTab || "local";
render();
});
});
// Refresh repos (in modal) // Refresh repos (in modal)
document.getElementById("refresh-repos-btn")?.addEventListener("click", async () => { document.getElementById("refresh-repos-btn")?.addEventListener("click", async () => {
await loadRepositories(); await loadRepositories();