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 `
Scanning local folders for repositories from the selected Gitea server...
`; + } + + if (state.localRepoScanError) { + return `
${escapeHtml(state.localRepoScanError)}
`; + } + + if (!results.length) { + return `
+
No local repositories scanned yet
+
Only local repos with remotes from the selected Gitea server will be shown.
+
`; + } + + return `
+ ${results + .map( + (repo) => ` +
+
+ ${escapeHtml(repo.name || repoNameFromPath(repo.path))} + ${escapeHtml(repo.path)} + ${repo.matchedRemoteUrl ? `Remote: ${escapeHtml(repo.matchedRemoteUrl)}` : ""} +
+
+ + +
+
+ ` + ) + .join("")} +
`; +} + function breadcrumbTemplate(repoName, path = "") { const parts = path.split("/").filter(Boolean); const crumbs = [``]; @@ -525,7 +587,12 @@ function dashboardView() { @@ -544,6 +611,23 @@ function dashboardView() { ${activeMainTab === "repos" ? `
+
+
+
+

Find local repositories

+

Shows only local repos that belong to the selected Gitea server.

+
+ +
+
+ + + +
+ ${localRepoScanTemplate()} +

Repositories

${visibleRepos.length} shown @@ -582,15 +666,48 @@ function dashboardView() {
`}
` : activeMainTab === "local" ? ` -
-

Local Repository

-
- - -
+
${state.selectedRepoName - ? `
Active: ${escapeHtml(state.selectedRepoName)}
` - : ""} + ? `` + : `
+ ${LOCAL_REPO_ICON} + + Current repository + No repository selected + +
`} + + +
+
+

${escapeHtml(state.selectedRepoName || "Local Repository")}

+ ${state.selectedRepoName + ? `
${escapeHtml(state.selectedRepoPath)}
` + : `
+
No repository selected
+
Pick a repository from Recent repositories on the left, or scan for local repos on the Repositories tab.
+
`} + ${state.selectedRepoName ? `
@@ -606,6 +723,7 @@ function dashboardView() { ${gitOutput ? `
${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 { // Normalize user input so every backend consistently resolves to /api/v1. let trimmed = server_url.trim().trim_end_matches('/'); @@ -164,6 +176,206 @@ fn treeish(reference: &str, path: &str) -> String { } } +fn dedupe_key(path: &Path) -> String { + let path = path + .canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .replace('\\', "/") + .trim_end_matches('/') + .to_string(); + + #[cfg(windows)] + { + path.to_lowercase() + } + + #[cfg(not(windows))] + { + path + } +} + +fn add_if_exists(paths: &mut Vec, path: PathBuf) { + if !path.exists() { + return; + } + + let key = dedupe_key(&path); + if !paths.iter().any(|existing| dedupe_key(existing) == key) { + paths.push(path); + } +} + +fn default_repo_scan_roots() -> Vec { + let mut roots = Vec::new(); + + if let Some(home) = env::var_os("USERPROFILE").or_else(|| env::var_os("HOME")) { + let home = PathBuf::from(home); + add_if_exists(&mut roots, home.join("Repos")); + add_if_exists(&mut roots, home.join("repos")); + add_if_exists(&mut roots, home.join("Code")); + add_if_exists(&mut roots, home.join("code")); + add_if_exists(&mut roots, home.join("Source")); + add_if_exists(&mut roots, home.join("source")); + add_if_exists(&mut roots, home.join("Projects")); + add_if_exists(&mut roots, home.join("GitHub")); + add_if_exists(&mut roots, home.join("Documents").join("GitHub")); + add_if_exists(&mut roots, home.join("Documents").join("Repos")); + add_if_exists(&mut roots, home.join("Desktop")); + } + + #[cfg(windows)] + { + for drive in b'A'..=b'Z' { + let root = format!("{}:\\", drive as char); + let root_path = PathBuf::from(&root); + if !root_path.exists() { + continue; + } + + for folder in [ + "Repos", "repos", "Code", "code", "Source", "source", "Projects", "GitHub", "Dev", + ] { + add_if_exists(&mut roots, root_path.join(folder)); + } + } + } + + roots +} + +fn is_skippable_dir(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return false; + }; + + matches!( + name, + ".cache" + | ".cargo" + | ".gradle" + | ".npm" + | ".rustup" + | ".svn" + | ".tauri" + | "AppData" + | "Library" + | "node_modules" + | "target" + | "vendor" + ) +} + +fn is_git_repo(path: &Path) -> bool { + path.join(".git").exists() +} + +fn normalize_git_remote_url(value: &str) -> String { + let mut remote = value.trim().trim_end_matches('/').to_lowercase(); + if let Some(stripped) = remote.strip_suffix(".git") { + remote = stripped.to_string(); + } + remote +} + +fn local_remote_urls(repo_path: &Path, git_path: Option) -> Vec { + let git_binary = resolve_git_binary(git_path); + let Ok(output) = Command::new(&git_binary) + .current_dir(repo_path) + .args(["config", "--get-regexp", r"^remote\..*\.url$"]) + .output() + else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.split_once(' ').map(|(_, url)| url.trim().to_string())) + .filter(|url| !url.is_empty()) + .collect() +} + +fn matched_server_remote( + repo_path: &Path, + git_path: Option, + allowed_remote_urls: &HashSet, +) -> Option { + local_remote_urls(repo_path, git_path) + .into_iter() + .find(|remote| allowed_remote_urls.contains(&normalize_git_remote_url(remote))) +} + +fn collect_repo_candidates( + root: &Path, + depth: u8, + max_depth: u8, + results: &mut Vec, + seen: &mut HashSet, + allowed_remote_urls: &HashSet, + git_path: Option, + max_results: usize, +) { + if results.len() >= max_results || is_skippable_dir(root) { + return; + } + + if is_git_repo(root) { + if seen.insert(dedupe_key(root)) { + let display_path = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + let path = display_path.to_string_lossy().to_string(); + let Some(matched_remote_url) = + matched_server_remote(root, git_path.clone(), allowed_remote_urls) + else { + return; + }; + let name = root + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(&path) + .to_string(); + results.push(LocalRepoCandidate { + name, + path, + matched_remote_url, + }); + } + return; + } + + if depth >= max_depth { + return; + } + + let Ok(entries) = fs::read_dir(root) else { + return; + }; + + for entry in entries.flatten() { + if results.len() >= max_results { + return; + } + + let path = entry.path(); + if path.is_dir() { + collect_repo_candidates( + &path, + depth + 1, + max_depth, + results, + seen, + allowed_remote_urls, + git_path.clone(), + max_results, + ); + } + } +} + #[tauri::command] fn git_clone( repo_url: String, @@ -203,7 +415,11 @@ fn git_status(repo_path: String, git_path: Option) -> Result) -> Result>, + allowed_remote_urls: Vec, + git_path: Option, + max_depth: Option, + max_results: Option, +) -> Result, String> { + let max_depth = max_depth.unwrap_or(4).clamp(1, 8); + let max_results = max_results.unwrap_or(200).clamp(1, 500); + let allowed_remote_urls = allowed_remote_urls + .into_iter() + .map(|url| normalize_git_remote_url(&url)) + .filter(|url| !url.is_empty()) + .collect::>(); + + if allowed_remote_urls.is_empty() { + return Err( + "No selected server repository URLs are available to match against.".to_string(), + ); + } + + let root_paths = roots + .unwrap_or_default() + .into_iter() + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .collect::>(); + let root_paths = if root_paths.is_empty() { + default_repo_scan_roots() + } else { + root_paths + }; + + if root_paths.is_empty() { + return Err( + "No scan roots found. Set a default clone directory or enter a scan root.".to_string(), + ); + } + + let mut results = Vec::new(); + let mut seen = HashSet::new(); + for root in root_paths { + if root.exists() && root.is_dir() { + collect_repo_candidates( + &root, + 0, + max_depth, + &mut results, + &mut seen, + &allowed_remote_urls, + git_path.clone(), + max_results, + ); + } + if results.len() >= max_results { + break; + } + } + + results.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then_with(|| a.path.to_lowercase().cmp(&b.path.to_lowercase())) + }); + + Ok(results) +} + #[tauri::command] fn local_repo_branches( repo_path: String, @@ -275,7 +562,11 @@ fn local_repo_tree( let output = run_git_output( repo_path.trim(), git_path, - vec!["ls-tree".to_string(), "-l".to_string(), treeish(&reference, &path)], + vec![ + "ls-tree".to_string(), + "-l".to_string(), + treeish(&reference, &path), + ], )?; let mut entries = Vec::new(); @@ -447,6 +738,7 @@ pub fn run() { git_push, git_status, git_branch, + scan_local_repos, local_repo_branches, local_repo_tree, local_repo_file,