diff --git a/frontend/css/components.css b/frontend/css/components.css index a531d5e..d51ded1 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -338,17 +338,25 @@ } .recent-item { + display: block; + width: 100%; + border: 0; + border-radius: var(--radius-md); + background: transparent; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; color: var(--text-muted); - padding: 4px 0; + padding: 4px 6px; font-size: 13px; + text-align: left; } .recent-item:hover { + background: var(--bg-hover); color: var(--accent); + border: 0; } /* ── Git output ───────────────────────────────────── */ @@ -380,15 +388,157 @@ color: var(--text-muted); } -/* ── Local repo indicator ─────────────────────────── */ -.selected-repo { - font-size: 13px; - padding: 6px 10px; - background: rgba(47, 129, 247, 0.06); - border: 1px solid rgba(47, 129, 247, 0.2); +/* ── GitHub Desktop style toolbar ─────────────────── */ +.gd-toolbar { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1.5fr) minmax(0, 1.5fr); + align-items: stretch; + width: 100%; + height: 56px; + margin: -16px -16px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg-panel); + flex-shrink: 0; +} + +.main > .gd-toolbar { + margin: 0 0 12px; + border: 1px solid var(--border); border-radius: var(--radius-md); - word-break: break-all; + overflow: hidden; +} + +.gd-toolbar-cell { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + height: 100%; + padding: 0 14px; + border: 0; + border-right: 1px solid var(--border); + border-radius: 0; + background: transparent; + color: var(--text-main); + cursor: pointer; + text-align: left; + transition: background 0.1s ease; +} + +.gd-toolbar-cell:last-child { + border-right: 0; +} + +.gd-toolbar-cell:hover:not(:disabled):not(.gd-toolbar-cell-empty) { + background: rgba(177, 186, 196, 0.08); + border-color: var(--border); +} + +.gd-toolbar-cell:disabled, +.gd-toolbar-cell-empty { + cursor: default; + opacity: 0.7; +} + +.gd-cell-icon { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; color: var(--text-muted); + flex-shrink: 0; +} + +.gd-cell-copy { + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.25; +} + +.gd-cell-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-muted); + font-size: 11px; + font-weight: 400; +} + +.gd-cell-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; + font-size: 14px; + font-weight: 600; + color: var(--text-main); +} + +.gd-cell-value-muted { + color: var(--text-muted); + font-weight: 400; + font-style: italic; +} + +.gd-cell-caret { + color: var(--text-muted); + flex-shrink: 0; +} + +.selected-repo-path-line { + margin-top: -6px; + font-size: 12px; + font-family: ui-monospace, "Cascadia Code", "Fira Code", Consolas, monospace; + word-break: break-all; +} + +.local-scan-panel { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: rgba(33, 38, 45, 0.35); +} + +.local-scan-title { + font-size: 15px; +} + +.local-repo-list { + display: grid; + gap: 8px; + max-height: 320px; + overflow: auto; +} + +.local-repo-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-panel); +} + +.local-repo-info { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.local-repo-info span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.local-scan-empty { + padding: 24px 12px; } /* ── Empty state ──────────────────────────────────── */ diff --git a/frontend/js/app.js b/frontend/js/app.js index a2dc39f..675c8f0 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -9,6 +9,7 @@ import { runGitPull, runGitPush, runGitStatus, + scanLocalRepos, testGiteaConnection, } from "./tauri-api.js"; @@ -34,6 +35,9 @@ const maxPreviewBytes = 256 * 1024; const FOLDER_ICON = ``; const FILE_ICON = ``; +const LOCAL_REPO_ICON = ``; +const BRANCH_ICON = ``; +const SYNC_ICON = ``; function uid() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -60,6 +64,24 @@ function parentPath(path = "") { return parts.join("/"); } +function repoNameFromPath(path = "") { + return path.split(/[/\\]/).filter(Boolean).pop() || path; +} + +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 languageForPath(path = "") { const extension = path.split(".").pop()?.toLowerCase(); const names = { @@ -383,6 +405,46 @@ function repoCardTemplate(repo) { `; } +function localRepoScanTemplate() { + const state = getState(); + const results = state.localRepoScanResults || []; + + if (state.localRepoScanLoading) { + return `
Shows only local repos that belong to the selected Gitea server.
+${escapeHtml(gitOutput)}`
: `Run a git command to see output here.
`} + ` : ""} ` : viewerTemplate()} @@ -810,6 +928,59 @@ async function autoLoadReadme() { } } +function selectLocalRepo(path, name = "") { + const state = getState(); + state.localRepoPathInput = path; + state.selectedRepoPath = path; + state.selectedRepoName = name || repoNameFromPath(path); + addRecentRepo(path); +} + +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 are loaded for the selected Gitea server. Refresh repositories 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 Gitea server were found in the scanned folders."; + } + } 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; @@ -1008,6 +1179,14 @@ function bindDashboardEvents() { render(); }); + document.querySelectorAll(".recent-item").forEach((button) => { + button.addEventListener("click", () => { + selectLocalRepo(button.dataset.recentRepoPath || ""); + activeMainTab = "local"; + render(); + }); + }); + document.querySelectorAll("[data-right-tab]").forEach((btn) => { btn.addEventListener("click", () => { activeRightTab = btn.dataset.rightTab; @@ -1054,22 +1233,33 @@ function bindDashboardEvents() { }); }); + document.getElementById("scan-local-repos-btn")?.addEventListener("click", () => { + scanForLocalRepos(); + }); + + document.querySelectorAll(".use-scanned-repo-btn").forEach((button) => { + button.addEventListener("click", () => { + selectLocalRepo(button.dataset.repoPath || "", button.dataset.repoName || ""); + render(); + }); + }); + + document.querySelectorAll(".view-scanned-repo-btn").forEach((button) => { + button.addEventListener("click", () => { + selectLocalRepo(button.dataset.repoPath || "", button.dataset.repoName || ""); + openLocalViewer(); + }); + }); + document.getElementById("open-local-repo-btn")?.addEventListener("click", () => { - // MVP local detection is user-driven path entry; folder picker can be added next. const value = document.getElementById("local-repo-path-input")?.value?.trim() || ""; - state.localRepoPathInput = value; - state.selectedRepoPath = value; - state.selectedRepoName = value.split(/[/\\]/).filter(Boolean).pop() || value; - addRecentRepo(value); + selectLocalRepo(value); render(); }); document.getElementById("view-local-repo-btn")?.addEventListener("click", () => { const value = document.getElementById("local-repo-path-input")?.value?.trim() || state.selectedRepoPath; - state.localRepoPathInput = value; - state.selectedRepoPath = value; - state.selectedRepoName = value.split(/[/\\]/).filter(Boolean).pop() || value; - if (value) addRecentRepo(value); + if (value) selectLocalRepo(value); openLocalViewer(); }); @@ -1106,6 +1296,9 @@ function bindDashboardEvents() { document.getElementById("git-pull-btn")?.addEventListener("click", () => { runRepoCommand("Pull", () => runGitPull(state.selectedRepoPath, state.settings.gitExecutablePath)); }); + document.getElementById("git-pull-btn-toolbar")?.addEventListener("click", () => { + runRepoCommand("Pull", () => runGitPull(state.selectedRepoPath, state.settings.gitExecutablePath)); + }); document.getElementById("git-push-btn")?.addEventListener("click", () => { runRepoCommand("Push", () => runGitPush(state.selectedRepoPath, state.settings.gitExecutablePath)); }); diff --git a/frontend/js/state.js b/frontend/js/state.js index 7d65464..ff9061a 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -6,6 +6,10 @@ const state = { selectedRepoName: "", repoSearch: "", localRepoPathInput: "", + localRepoScanRootInput: "", + localRepoScanResults: [], + localRepoScanLoading: false, + localRepoScanError: "", cloneUrlInput: "", cloneDestinationInput: "", commitMessage: "", diff --git a/frontend/js/tauri-api.js b/frontend/js/tauri-api.js index 68fb431..7f2d73f 100644 --- a/frontend/js/tauri-api.js +++ b/frontend/js/tauri-api.js @@ -35,6 +35,17 @@ export async function runGitBranch(repoPath, gitPath) { return invoke("git_branch", { repoPath, gitPath: gitPath || null }); } +export async function scanLocalRepos(roots = [], allowedRemoteUrls = [], gitPath = "", maxDepth = 4, maxResults = 200) { + ensureInvoke(); + return invoke("scan_local_repos", { + roots, + allowedRemoteUrls, + gitPath: gitPath || null, + maxDepth, + maxResults, + }); +} + export async function listLocalRepoBranches(repoPath, gitPath) { ensureInvoke(); return invoke("local_repo_branches", { repoPath, gitPath: gitPath || null }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 24f959d..897dff5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,11 @@ use reqwest::blocking::Client; use reqwest::header::{ACCEPT, AUTHORIZATION}; use serde::Serialize; +use std::collections::HashSet; +use std::env; +use std::fs; use std::path::Path; +use std::path::PathBuf; use std::process::Command; #[derive(Serialize)] @@ -47,6 +51,14 @@ struct LocalRepoFile { too_large: bool, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalRepoCandidate { + name: String, + path: String, + matched_remote_url: String, +} + fn normalize_api_base_url(server_url: &str) -> Result