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:
@@ -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 {
|
||||||
|
|||||||
+130
-36
@@ -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>`).join("")}
|
||||||
</div>
|
</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();
|
||||||
|
|||||||
Reference in New Issue
Block a user