diff --git a/frontend/css/components.css b/frontend/css/components.css index 95b3ab5..2cc5000 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -1233,6 +1233,43 @@ 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 { display: grid; grid-template-columns: 1fr 1fr; @@ -1252,6 +1289,10 @@ border-left: 1px solid var(--border); } +.gd-modal-tab-panel .gd-modal-section-alt { + border-left: 0; +} + .gd-modal-section-header { display: flex; align-items: center; @@ -1318,6 +1359,10 @@ max-height: 300px; } +.gd-modal-tab-panel .gd-modal-scroll-list { + max-height: none; +} + .gd-modal-scan-list { display: flex; flex-direction: column; @@ -1435,6 +1480,33 @@ 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 ────────────────────────────────────────────────────────── */ .modal-backdrop { diff --git a/frontend/js/app.js b/frontend/js/app.js index a47d7ed..40a1e94 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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 `
No repositories found
`; + } + + return Array.from(groupedByOrg(visibleRepos).entries()).map(([owner, ownerRepos]) => ` +
+
+ ${escapeHtml(owner)} +
+ ${ownerRepos.map((repo) => ` +
+
+ ${escapeHtml(repo.full_name)} + ${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")} +
+
+ + +
+
`).join("")} +
+ `).join(""); +} + +function recentLocalReposTemplate(paths = []) { + if (!paths.length) { + return `
No recent repositories
`; + } + + return Array.from(groupedLocalRepos(paths, (path) => localRepoOwnerName({ path })).entries()).map(([owner, ownerPaths]) => ` +
+
+ ${escapeHtml(owner)} +
+ ${ownerPaths.map((path) => ` +
+
+ ${escapeHtml(repoNameFromPath(path))} + ${escapeHtml(path)} +
+
+ + +
+
`).join("")} +
+ `).join(""); +} + function localRepoScanTemplate() { const state = getState(); const results = state.localRepoScanResults || []; @@ -907,9 +1007,12 @@ function localRepoScanTemplate() { } return `
- ${results - .map( - (repo) => ` + ${Array.from(groupedLocalRepos(results, (repo) => localRepoOwnerName(repo)).entries()).map(([owner, ownerRepos]) => ` +
+
+ ${escapeHtml(owner)} +
+ ${ownerRepos.map((repo) => `
${escapeHtml(repo.name || repoNameFromPath(repo.path))} @@ -920,10 +1023,9 @@ function localRepoScanTemplate() {
-
- ` - ) - .join("")} +
`).join("")} +
+ `).join("")} `; } @@ -970,22 +1072,15 @@ function reposModalContent(state) { const visibleRepos = filteredRepositories(); const activeServer = getActiveServer(); return ` -
+
+ + +
+
+ ${activeReposTab === "local" ? `

Recent Local Repositories

- ${state.settings.recentRepositories.length - ? state.settings.recentRepositories.map((path) => ` -
-
- ${escapeHtml(repoNameFromPath(path))} - ${escapeHtml(path)} -
-
- - -
-
`).join("") - : `
No recent repositories
`} + ${recentLocalReposTemplate(state.settings.recentRepositories)}
@@ -1004,7 +1099,7 @@ function reposModalContent(state) {
${localRepoScanTemplate()}
- + ` : `

Server Repositories

@@ -1020,19 +1115,10 @@ function reposModalContent(state) {
- ${visibleRepos.slice(0, 60).map((repo) => ` -
-
- ${escapeHtml(repo.full_name)} - ${repo.private ? "Private" : "Public"} · ${escapeHtml(repo.updated_at || "recently")} -
-
- - -
-
`).join("") || `
No repositories found
`} + ${serverRepoListTemplate(visibleRepos)}
+ `}
`; } @@ -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();