From aaad19adb5f3bc59629e9d4e45df4515c35e3dd1 Mon Sep 17 00:00:00 2001 From: "itpkcn@gmail.com" Date: Sat, 21 Mar 2026 13:20:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=9E=E6=97=B6=E5=A4=84?= =?UTF-8?q?=E7=90=86Git=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/folders.rs | 110 ++++- src-tauri/src/models/mod.rs | 2 +- src-tauri/src/models/system.rs | 7 + src/app/folder/layout.tsx | 7 +- src/app/welcome/layout.tsx | 8 + src/components/layout/branch-dropdown.tsx | 30 +- src/components/welcome/clone-dialog.tsx | 7 +- src/contexts/git-credential-context.tsx | 543 ++++++++++++++++++++++ src/i18n/messages/ar.json | 20 + src/i18n/messages/de.json | 20 + src/i18n/messages/en.json | 20 + src/i18n/messages/es.json | 20 + src/i18n/messages/fr.json | 20 + src/i18n/messages/ja.json | 20 + src/i18n/messages/ko.json | 20 + src/i18n/messages/pt.json | 20 + src/i18n/messages/zh-CN.json | 20 + src/i18n/messages/zh-TW.json | 20 + src/lib/tauri.ts | 40 +- src/lib/types.ts | 5 + 20 files changed, 919 insertions(+), 40 deletions(-) create mode 100644 src/app/welcome/layout.tsx create mode 100644 src/contexts/git-credential-context.tsx diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index ae6b9d5..c08b659 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -20,32 +20,98 @@ use crate::app_error::AppCommandError; use crate::db::error::DbError; use crate::db::service::folder_service; use crate::db::AppDatabase; -use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation}; +use crate::models::{FolderDetail, FolderHistoryEntry, GitCredentials, OpenedConversation}; -/// Inject stored GitHub credentials into a git command for a given repository. -async fn inject_repo_credentials( +/// Configure a git command for remote operations: +/// - Always disable interactive prompts (prevent hanging in a GUI app) +/// - If explicit credentials are provided, use them directly +/// - Otherwise, try to inject stored account credentials +async fn prepare_remote_git_cmd( cmd: &mut tokio::process::Command, repo_path: &str, + credentials: Option<&GitCredentials>, db: &AppDatabase, app_handle: &tauri::AppHandle, ) { + cmd.env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()); + if let Ok(data_dir) = app_handle.path().app_data_dir() { - crate::git_credential::try_inject_for_repo(cmd, repo_path, &db.conn, &data_dir).await; + if let Some(creds) = credentials { + // Explicit credentials provided (e.g. from credential dialog) + if let Ok(askpass) = crate::git_credential::ensure_askpass_script(&data_dir) { + crate::git_credential::inject_credentials( + cmd, + &creds.username, + &creds.password, + &askpass, + ); + } + } else { + // Fall back to stored accounts + crate::git_credential::try_inject_for_repo(cmd, repo_path, &db.conn, &data_dir).await; + } } } -/// Inject stored GitHub credentials for a clone URL (no repo path yet). -async fn inject_url_credentials( +/// Same as `prepare_remote_git_cmd` but for clone (URL only, no repo yet). +async fn prepare_remote_git_cmd_for_url( cmd: &mut tokio::process::Command, clone_url: &str, + credentials: Option<&GitCredentials>, db: &AppDatabase, app_handle: &tauri::AppHandle, ) { + cmd.env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()); + if let Ok(data_dir) = app_handle.path().app_data_dir() { - crate::git_credential::try_inject_for_url(cmd, clone_url, &db.conn, &data_dir).await; + if let Some(creds) = credentials { + if let Ok(askpass) = crate::git_credential::ensure_askpass_script(&data_dir) { + crate::git_credential::inject_credentials( + cmd, + &creds.username, + &creds.password, + &askpass, + ); + } + } else { + crate::git_credential::try_inject_for_url(cmd, clone_url, &db.conn, &data_dir).await; + } } } +/// Classify a git remote command error, detecting authentication failures. +fn classify_remote_git_error(operation: &str, stderr: &[u8]) -> AppCommandError { + let msg = String::from_utf8_lossy(stderr).trim().to_string(); + let lower = msg.to_lowercase(); + + if lower.contains("authentication failed") + || lower.contains("invalid credentials") + || lower.contains("could not read username") + || lower.contains("could not read password") + || lower.contains("logon failed") + || lower.contains("401") + || lower.contains("403") + { + return AppCommandError::authentication_failed(format!( + "git {operation}: authentication failed. Configure a GitHub account in Settings → Version Control." + )) + .with_detail(msg); + } + + if lower.contains("could not resolve host") + || lower.contains("unable to access") + || lower.contains("connection refused") + || lower.contains("network is unreachable") + { + return AppCommandError::network(format!("git {operation}: network error")) + .with_detail(msg); + } + + AppCommandError::external_command(format!("git {operation} failed"), msg) +} + #[derive(Debug, Serialize)] pub struct GitStatusEntry { pub status: String, @@ -438,6 +504,7 @@ pub async fn create_folder_directory(path: String) -> Result<(), AppCommandError pub async fn clone_repository( url: String, target_dir: String, + credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result<(), AppCommandError> { @@ -449,7 +516,7 @@ pub async fn clone_repository( let mut cmd = crate::process::tokio_command("git"); cmd.args(["clone", &url, &target_dir]); - inject_url_credentials(&mut cmd, &url, &db, &app_handle).await; + prepare_remote_git_cmd_for_url(&mut cmd, &url, credentials.as_ref(), &db, &app_handle).await; let output = cmd .output() @@ -568,6 +635,7 @@ pub async fn git_init(path: String) -> Result<(), AppCommandError> { #[tauri::command] pub async fn git_pull( path: String, + credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result { @@ -576,7 +644,7 @@ pub async fn git_pull( // Step 1: fetch from remote let mut fetch_cmd = crate::process::tokio_command("git"); fetch_cmd.args(["fetch"]).current_dir(&path); - inject_repo_credentials(&mut fetch_cmd, &path, &db, &app_handle).await; + prepare_remote_git_cmd(&mut fetch_cmd, &path, credentials.as_ref(), &db, &app_handle).await; let fetch_output = fetch_cmd .output() @@ -584,7 +652,7 @@ pub async fn git_pull( .map_err(AppCommandError::io)?; if !fetch_output.status.success() { - return Err(git_command_error("fetch", &fetch_output.stderr)); + return Err(classify_remote_git_error("fetch", &fetch_output.stderr)); } // Step 2: check if upstream exists @@ -743,12 +811,13 @@ pub async fn git_has_merge_head(path: String) -> Result { #[tauri::command] pub async fn git_fetch( path: String, + credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result { let mut cmd = crate::process::tokio_command("git"); cmd.args(["fetch", "--all"]).current_dir(&path); - inject_repo_credentials(&mut cmd, &path, &db, &app_handle).await; + prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app_handle).await; let output = cmd .output() @@ -756,7 +825,7 @@ pub async fn git_fetch( .map_err(AppCommandError::io)?; if !output.status.success() { - return Err(git_command_error("fetch --all", &output.stderr)); + return Err(classify_remote_git_error("fetch --all", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()) } @@ -764,6 +833,7 @@ pub async fn git_fetch( #[tauri::command] pub async fn git_push( path: String, + credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result { @@ -794,17 +864,17 @@ pub async fn git_push( let mut cmd = crate::process::tokio_command("git"); cmd.args(["push", "--set-upstream", "origin", &branch]) .current_dir(&path); - inject_repo_credentials(&mut cmd, &path, &db, &app_handle).await; + prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app_handle).await; cmd.output().await.map_err(AppCommandError::io)? } else { let mut cmd = crate::process::tokio_command("git"); cmd.args(["push"]).current_dir(&path); - inject_repo_credentials(&mut cmd, &path, &db, &app_handle).await; + prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app_handle).await; cmd.output().await.map_err(AppCommandError::io)? }; if !output.status.success() { - return Err(git_command_error("push", &output.stderr)); + return Err(classify_remote_git_error("push", &output.stderr)); } Ok(GitPushResult { @@ -1553,15 +1623,13 @@ pub async fn git_list_remotes(path: String) -> Result, AppCommand pub async fn git_fetch_remote( path: String, name: String, + credentials: Option, db: tauri::State<'_, AppDatabase>, app_handle: tauri::AppHandle, ) -> Result { let mut cmd = crate::process::tokio_command("git"); - cmd.args(["fetch", &name]) - .current_dir(&path) - .env("GIT_TERMINAL_PROMPT", "0") - .stdin(std::process::Stdio::null()); - inject_repo_credentials(&mut cmd, &path, &db, &app_handle).await; + cmd.args(["fetch", &name]).current_dir(&path); + prepare_remote_git_cmd(&mut cmd, &path, credentials.as_ref(), &db, &app_handle).await; let output = cmd .output() @@ -1569,7 +1637,7 @@ pub async fn git_fetch_remote( .map_err(AppCommandError::io)?; if !output.status.success() { - return Err(git_command_error("fetch", &output.stderr)); + return Err(classify_remote_git_error("fetch", &output.stderr)); } Ok(String::from_utf8_lossy(&output.stderr).trim().to_string()) } diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c59d2dc..4cc6131 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -13,6 +13,6 @@ pub use conversation::{ pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation}; pub use message::{ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage}; pub use system::{ - GitDetectResult, GitHubAccountsSettings, GitHubTokenValidation, GitSettings, + GitCredentials, GitDetectResult, GitHubAccountsSettings, GitHubTokenValidation, GitSettings, SystemLanguageSettings, SystemProxySettings, }; diff --git a/src-tauri/src/models/system.rs b/src-tauri/src/models/system.rs index e993e7b..9fbc650 100644 --- a/src-tauri/src/models/system.rs +++ b/src-tauri/src/models/system.rs @@ -39,6 +39,13 @@ pub struct SystemLanguageSettings { // --- Version Control --- +/// Explicit credentials for a single git remote operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitCredentials { + pub username: String, + pub password: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GitDetectResult { pub installed: bool, diff --git a/src/app/folder/layout.tsx b/src/app/folder/layout.tsx index 9f8dc8f..f59a77d 100644 --- a/src/app/folder/layout.tsx +++ b/src/app/folder/layout.tsx @@ -22,6 +22,7 @@ import { TerminalProvider, useTerminalContext, } from "@/contexts/terminal-context" +import { GitCredentialProvider } from "@/contexts/git-credential-context" import { WorkspaceProvider, useWorkspaceContext, @@ -642,7 +643,8 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) { initialAgentType={agentType} > - + + @@ -677,7 +679,8 @@ function FolderLayoutInner({ children }: { children: React.ReactNode }) { - + + ) diff --git a/src/app/welcome/layout.tsx b/src/app/welcome/layout.tsx new file mode 100644 index 0000000..078dd03 --- /dev/null +++ b/src/app/welcome/layout.tsx @@ -0,0 +1,8 @@ +"use client" + +import type { ReactNode } from "react" +import { GitCredentialProvider } from "@/contexts/git-credential-context" + +export default function WelcomeLayout({ children }: { children: ReactNode }) { + return {children} +} diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index f5197b1..628006a 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -92,6 +92,7 @@ import { toast } from "sonner" import { useFolderContext } from "@/contexts/folder-context" import { useTaskContext } from "@/contexts/task-context" import { useAlertContext } from "@/contexts/alert-context" +import { useGitCredential } from "@/contexts/git-credential-context" interface BranchDropdownProps { branch: string | null @@ -120,6 +121,7 @@ export function BranchDropdown({ const folderPath = folder?.path ?? "" const { addTask, updateTask, removeTask } = useTaskContext() const { pushAlert } = useAlertContext() + const { withCredentialRetry } = useGitCredential() const [branchList, setBranchList] = useState({ local: [], remote: [], @@ -335,7 +337,10 @@ export function BranchDropdown({ addTask(taskId, label) updateTask(taskId, { status: "running" }) try { - const result = await gitPush(folderPath) + const result = await withCredentialRetry( + (creds) => gitPush(folderPath, creds), + { folderPath } + ) updateTask(taskId, { status: "completed" }) onBranchChange() let description: string | undefined @@ -368,7 +373,10 @@ export function BranchDropdown({ status: "running", }) try { - const pullResult = await gitPull(folderPath) + const pullResult = await withCredentialRetry( + (creds) => gitPull(folderPath, creds), + { folderPath } + ) if (pullResult.conflict?.has_conflicts) { removeTask(taskId) onBranchChange() @@ -376,7 +384,10 @@ export function BranchDropdown({ } else { // Pull succeeded, retry push updateTask(taskId, { status: "running" }) - const pushResult = await gitPush(folderPath) + const pushResult = await withCredentialRetry( + (creds) => gitPush(folderPath, creds), + { folderPath } + ) updateTask(taskId, { status: "completed" }) onBranchChange() let description: string | undefined @@ -686,7 +697,11 @@ export function BranchDropdown({ onSelect={() => runGitTask( t("tasks.pullCode"), - () => gitPull(folderPath), + () => + withCredentialRetry( + (creds) => gitPull(folderPath, creds), + { folderPath } + ), (result) => { if (result.conflict?.has_conflicts) { setConflictInfo(result.conflict) @@ -708,7 +723,12 @@ export function BranchDropdown({ - runGitTask(t("tasks.fetchInfo"), () => gitFetch(folderPath)) + runGitTask(t("tasks.fetchInfo"), () => + withCredentialRetry( + (creds) => gitFetch(folderPath, creds), + { folderPath } + ) + ) } > diff --git a/src/components/welcome/clone-dialog.tsx b/src/components/welcome/clone-dialog.tsx index 83be353..ccf3738 100644 --- a/src/components/welcome/clone-dialog.tsx +++ b/src/components/welcome/clone-dialog.tsx @@ -5,6 +5,7 @@ import { open } from "@tauri-apps/plugin-dialog" import { useTranslations } from "next-intl" import { toast } from "sonner" import { cloneRepository, openFolderWindow } from "@/lib/tauri" +import { useGitCredential } from "@/contexts/git-credential-context" import { Dialog, DialogContent, @@ -25,6 +26,7 @@ interface CloneDialogProps { export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { const t = useTranslations("WelcomePage") + const { withCredentialRetry } = useGitCredential() const [url, setUrl] = useState("") const [targetDir, setTargetDir] = useState("") const [cloning, setCloning] = useState(false) @@ -55,7 +57,10 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { setError(null) try { - await cloneRepository(url, fullPath) + await withCredentialRetry( + (creds) => cloneRepository(url, fullPath, creds), + { remoteUrl: url } + ) await openFolderWindow(fullPath) onOpenChange(false) resetForm() diff --git a/src/contexts/git-credential-context.tsx b/src/contexts/git-credential-context.tsx new file mode 100644 index 0000000..acb88c1 --- /dev/null +++ b/src/contexts/git-credential-context.tsx @@ -0,0 +1,543 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useRef, + useState, + type ReactNode, +} from "react" +import { Eye, EyeOff, Github, KeyRound, Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { extractAppCommandError } from "@/lib/app-error" +import type { GitCredentials } from "@/lib/types" +import { + gitListRemotes, + validateGitHubToken, + getGitHubAccounts, + updateGitHubAccounts, +} from "@/lib/tauri" + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +/** + * Context for identifying the remote when credentials are needed. + * - `folderPath`: detect remote from an existing repo's origin URL. + * - `remoteUrl`: use this URL directly (e.g. for clone operations). + */ +export type GitRemoteHint = + | { folderPath: string } + | { remoteUrl: string } + +interface GitCredentialContextValue { + /** + * Wrap an async git operation with automatic credential retry. + * + * - For GitHub remotes: shows a token dialog, validates via API, + * saves as a GitHub account, then retries the operation. + * - For other remotes: shows a generic username/password dialog. + */ + withCredentialRetry: ( + operation: (credentials?: GitCredentials) => Promise, + hint: GitRemoteHint + ) => Promise +} + +const GitCredentialContext = + createContext(null) + +export function useGitCredential(): GitCredentialContextValue { + const ctx = useContext(GitCredentialContext) + if (!ctx) { + throw new Error( + "useGitCredential must be used within GitCredentialProvider" + ) + } + return ctx +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isAuthError(error: unknown): boolean { + const appError = extractAppCommandError(error) + if (appError?.code === "authentication_failed") return true + + const msg = appError?.detail ?? appError?.message ?? String(error) + const lower = msg.toLowerCase() + return ( + lower.includes("authentication failed") || + lower.includes("could not read username") || + lower.includes("could not read password") || + lower.includes("logon failed") + ) +} + +function extractHost(url: string): string | null { + const trimmed = url.trim() + // https://github.com/... + const httpsMatch = trimmed.match(/^https?:\/\/(?:[^@]+@)?([^/:]+)/) + if (httpsMatch) return httpsMatch[1].toLowerCase() + // git@github.com:... + const sshMatch = trimmed.match(/@([^/:]+)[:/]/) + if (sshMatch) return sshMatch[1].toLowerCase() + return null +} + +function isGitHubHost(host: string | null): boolean { + return host === "github.com" +} + +async function resolveRemoteHost(hint: GitRemoteHint): Promise { + if ("remoteUrl" in hint) { + return extractHost(hint.remoteUrl) + } + try { + const remotes = await gitListRemotes(hint.folderPath) + const origin = remotes.find((r) => r.name === "origin") ?? remotes[0] + if (!origin) return null + return extractHost(origin.url) + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +type DialogMode = "github" | "generic" + +interface PendingRequest { + resolve: (credentials: GitCredentials | null) => void +} + +/** Save generic credentials as a git account for future operations. */ +async function saveGenericAccount( + host: string | null, + creds: GitCredentials +): Promise { + const serverUrl = host ? `https://${host}` : "https://unknown" + try { + const existing = await getGitHubAccounts() + const isDuplicate = existing.accounts.some( + (a) => + a.username === creds.username && + extractHost(a.server_url) === host + ) + if (!isDuplicate) { + await updateGitHubAccounts({ + accounts: [ + ...existing.accounts, + { + id: crypto.randomUUID(), + server_url: serverUrl, + username: creds.username, + token: creds.password, + scopes: [], + avatar_url: null, + is_default: existing.accounts.length === 0, + created_at: new Date().toISOString(), + }, + ], + }) + } + } catch { + // Non-critical — just skip saving + } +} + +export function GitCredentialProvider({ + children, +}: { + children: ReactNode +}) { + const t = useTranslations("GitCredentialDialog") + + const [open, setOpen] = useState(false) + const [mode, setMode] = useState("generic") + const [remoteHost, setRemoteHost] = useState(null) + + // Generic mode fields + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [showPassword, setShowPassword] = useState(false) + + // GitHub mode field + const [token, setToken] = useState("") + const [showToken, setShowToken] = useState(false) + + // Save credentials checkbox (generic mode) + const [saveCredentials, setSaveCredentials] = useState(true) + + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const pendingRef = useRef(null) + const saveCredentialsRef = useRef(saveCredentials) + saveCredentialsRef.current = saveCredentials + const remoteHostRef = useRef(remoteHost) + remoteHostRef.current = remoteHost + const modeRef = useRef(mode) + modeRef.current = mode + + const resetForm = useCallback(() => { + setUsername("") + setPassword("") + setShowPassword(false) + setToken("") + setShowToken(false) + setSaveCredentials(true) + setError(null) + setSubmitting(false) + }, []) + + const requestCredentials = useCallback( + (dialogMode: DialogMode, host: string | null): Promise => { + return new Promise((resolve) => { + pendingRef.current = { resolve } + resetForm() + setMode(dialogMode) + setRemoteHost(host) + setOpen(true) + }) + }, + [resetForm] + ) + + const handleCancel = useCallback(() => { + setOpen(false) + pendingRef.current?.resolve(null) + pendingRef.current = null + }, []) + + // GitHub mode: validate token → save account → return credentials + const handleGitHubSubmit = useCallback(async () => { + const trimmedToken = token.trim() + if (!trimmedToken) return + + setSubmitting(true) + setError(null) + + try { + const serverUrl = remoteHost + ? `https://${remoteHost}` + : "https://github.com" + + const result = await validateGitHubToken(serverUrl, trimmedToken) + if (!result.success) { + setError(result.message ?? t("invalidCredentials")) + setSubmitting(false) + return + } + + // Save as GitHub account + try { + const existing = await getGitHubAccounts() + const isDuplicate = existing.accounts.some( + (a) => + a.username === result.username && + extractHost(a.server_url) === remoteHost + ) + if (!isDuplicate) { + const newAccount = { + id: crypto.randomUUID(), + server_url: serverUrl, + username: result.username ?? "unknown", + token: trimmedToken, + scopes: result.scopes, + avatar_url: result.avatar_url, + is_default: existing.accounts.length === 0, + created_at: new Date().toISOString(), + } + await updateGitHubAccounts({ + accounts: [...existing.accounts, newAccount], + }) + } + } catch { + // Saving account failed — not critical, continue with auth + } + + const creds: GitCredentials = { + username: result.username ?? "unknown", + password: trimmedToken, + } + pendingRef.current?.resolve(creds) + pendingRef.current = null + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + setSubmitting(false) + } + }, [token, remoteHost, t]) + + // Generic mode: return username + password directly + const handleGenericSubmit = useCallback(() => { + if (!username.trim() || !password.trim()) return + const creds: GitCredentials = { + username: username.trim(), + password: password.trim(), + } + setSubmitting(true) + pendingRef.current?.resolve(creds) + pendingRef.current = null + }, [username, password]) + + const handleSubmit = useCallback(() => { + if (mode === "github") { + handleGitHubSubmit() + } else { + handleGenericSubmit() + } + }, [mode, handleGitHubSubmit, handleGenericSubmit]) + + const withCredentialRetry = useCallback( + async ( + operation: (credentials?: GitCredentials) => Promise, + hint: GitRemoteHint + ): Promise => { + try { + return await operation() + } catch (firstError) { + if (!isAuthError(firstError)) throw firstError + + // Detect remote host to decide dialog mode + const host = await resolveRemoteHost(hint) + const dialogMode: DialogMode = isGitHubHost(host) + ? "github" + : "generic" + + // Show credential dialog + const creds = await requestCredentials(dialogMode, host) + if (!creds) { + setOpen(false) + throw firstError + } + + // Helper: save credentials after successful operation + const maybeSave = async (c: GitCredentials) => { + if (modeRef.current === "generic" && saveCredentialsRef.current) { + await saveGenericAccount(remoteHostRef.current, c) + } + // GitHub mode saves during handleGitHubSubmit, no extra work needed + } + + // Retry with credentials + try { + const result = await operation(creds) + await maybeSave(creds) + setOpen(false) + return result + } catch (retryError) { + setSubmitting(false) + if (isAuthError(retryError)) { + setError(t("invalidCredentials")) + const retryCreds = await new Promise( + (resolve) => { + pendingRef.current = { resolve } + } + ) + if (!retryCreds) { + setOpen(false) + throw retryError + } + try { + const result = await operation(retryCreds) + await maybeSave(retryCreds) + setOpen(false) + return result + } catch (thirdError) { + setOpen(false) + throw thirdError + } + } + setOpen(false) + throw retryError + } + } + }, + [requestCredentials, t] + ) + + const canSubmitGitHub = token.trim().length > 0 + const canSubmitGeneric = username.trim().length > 0 && password.trim().length > 0 + const canSubmit = mode === "github" ? canSubmitGitHub : canSubmitGeneric + + return ( + + {children} + + !v && handleCancel()}> + + + + {mode === "github" ? ( + + ) : ( + + )} + {mode === "github" ? t("githubTitle") : t("title")} + + + {mode === "github" ? t("githubDescription") : t("description")} + + + +
+ {mode === "github" ? ( + /* ---- GitHub Token Mode ---- */ +
+ +
+ { + setToken(e.target.value) + setError(null) + }} + placeholder={t("githubTokenPlaceholder")} + disabled={submitting} + className="pr-9" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && canSubmitGitHub) handleSubmit() + }} + /> + +
+

+ {t("githubTokenHint")} +

+
+ ) : ( + /* ---- Generic Mode ---- */ + <> +
+ + { + setUsername(e.target.value) + setError(null) + }} + placeholder={t("usernamePlaceholder")} + disabled={submitting} + autoFocus + /> +
+
+ +
+ { + setPassword(e.target.value) + setError(null) + }} + placeholder={t("passwordPlaceholder")} + disabled={submitting} + className="pr-9" + onKeyDown={(e) => { + if (e.key === "Enter" && canSubmitGeneric) + handleSubmit() + }} + /> + +
+

+ {t("passwordHint")} +

+
+ + + )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+
+ ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 14479e5..de6cc7e 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -57,6 +57,26 @@ "clone": "استنساخ" } }, + "GitCredentialDialog": { + "title": "المصادقة مطلوبة", + "description": "يتطلب الخادم البعيد بيانات اعتماد. أدخل اسم المستخدم وكلمة المرور (أو رمز الوصول الشخصي).", + "username": "اسم المستخدم", + "usernamePlaceholder": "اسم المستخدم أو البريد الإلكتروني", + "password": "كلمة المرور / الرمز", + "passwordPlaceholder": "كلمة المرور أو رمز الوصول الشخصي", + "passwordHint": "أدخل اسم المستخدم وكلمة المرور للخادم.", + "cancel": "إلغاء", + "authenticate": "مصادقة", + "authenticating": "جارٍ المصادقة...", + "invalidCredentials": "بيانات الاعتماد غير صالحة. يرجى المحاولة مرة أخرى.", + "saveCredentials": "حفظ بيانات الاعتماد للعمليات المستقبلية", + "githubTitle": "مصادقة GitHub", + "githubDescription": "أدخل رمز وصول شخصي للاتصال بـ GitHub. سيتم التحقق من الرمز وحفظه تلقائيًا.", + "githubToken": "رمز الوصول الشخصي", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "أنشئ رمزًا في GitHub → Settings → Developer settings → Personal access tokens.", + "githubAuthenticate": "التحقق والاتصال" + }, "SettingsShell": { "title": "الإعدادات", "preferences": "التفضيلات", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index ed600d4..949c18e 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -57,6 +57,26 @@ "clone": "Klonen" } }, + "GitCredentialDialog": { + "title": "Authentifizierung erforderlich", + "description": "Der Remote-Server erfordert Anmeldedaten. Geben Sie Ihren Benutzernamen und Ihr Passwort (oder persönliches Zugriffstoken) ein.", + "username": "Benutzername", + "usernamePlaceholder": "Benutzername oder E-Mail", + "password": "Passwort / Token", + "passwordPlaceholder": "Passwort oder persönliches Zugriffstoken", + "passwordHint": "Geben Sie Benutzername und Passwort des Servers ein.", + "cancel": "Abbrechen", + "authenticate": "Authentifizieren", + "authenticating": "Authentifizierung...", + "invalidCredentials": "Ungültige Anmeldedaten. Bitte versuchen Sie es erneut.", + "saveCredentials": "Anmeldedaten für zukünftige Vorgänge speichern", + "githubTitle": "GitHub-Authentifizierung", + "githubDescription": "Geben Sie ein persönliches Zugriffstoken ein, um sich mit GitHub zu verbinden. Das Token wird validiert und automatisch gespeichert.", + "githubToken": "Persönliches Zugriffstoken", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "Erstellen Sie ein Token unter GitHub → Settings → Developer settings → Personal access tokens.", + "githubAuthenticate": "Validieren & verbinden" + }, "SettingsShell": { "title": "Einstellungen", "preferences": "Präferenzen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b1087a0..8bcadf4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -57,6 +57,26 @@ "clone": "Clone" } }, + "GitCredentialDialog": { + "title": "Authentication Required", + "description": "The remote server requires credentials. Enter your username and password (or personal access token).", + "username": "Username", + "usernamePlaceholder": "Username or email", + "password": "Password / Token", + "passwordPlaceholder": "Password or personal access token", + "passwordHint": "For non-GitHub servers, enter your username and password.", + "cancel": "Cancel", + "authenticate": "Authenticate", + "authenticating": "Authenticating...", + "invalidCredentials": "Invalid credentials. Please try again.", + "saveCredentials": "Save credentials for future operations", + "githubTitle": "GitHub Authentication", + "githubDescription": "Enter a personal access token to authenticate with GitHub. The token will be validated and saved to your accounts.", + "githubToken": "Personal Access Token", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "Generate a token at GitHub → Settings → Developer settings → Personal access tokens.", + "githubAuthenticate": "Validate & Connect" + }, "SettingsShell": { "title": "Settings", "preferences": "Preferences", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index cdc644c..033fe72 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -57,6 +57,26 @@ "clone": "Clonar" } }, + "GitCredentialDialog": { + "title": "Autenticación requerida", + "description": "El servidor remoto requiere credenciales. Introduce tu nombre de usuario y contraseña (o token de acceso personal).", + "username": "Nombre de usuario", + "usernamePlaceholder": "Usuario o correo electrónico", + "password": "Contraseña / Token", + "passwordPlaceholder": "Contraseña o token de acceso personal", + "passwordHint": "Introduce el nombre de usuario y contraseña del servidor.", + "cancel": "Cancelar", + "authenticate": "Autenticar", + "authenticating": "Autenticando...", + "invalidCredentials": "Credenciales inválidas. Inténtalo de nuevo.", + "saveCredentials": "Guardar credenciales para futuras operaciones", + "githubTitle": "Autenticación de GitHub", + "githubDescription": "Introduce un token de acceso personal para conectarte a GitHub. El token se validará y guardará automáticamente.", + "githubToken": "Token de acceso personal", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "Genera un token en GitHub → Settings → Developer settings → Personal access tokens.", + "githubAuthenticate": "Validar y conectar" + }, "SettingsShell": { "title": "Configuración", "preferences": "Preferencias", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 6ee8a63..31e894a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -57,6 +57,26 @@ "clone": "Cloner" } }, + "GitCredentialDialog": { + "title": "Authentification requise", + "description": "Le serveur distant nécessite des identifiants. Entrez votre nom d'utilisateur et votre mot de passe (ou jeton d'accès personnel).", + "username": "Nom d'utilisateur", + "usernamePlaceholder": "Nom d'utilisateur ou e-mail", + "password": "Mot de passe / Jeton", + "passwordPlaceholder": "Mot de passe ou jeton d'accès personnel", + "passwordHint": "Entrez le nom d'utilisateur et le mot de passe du serveur.", + "cancel": "Annuler", + "authenticate": "Authentifier", + "authenticating": "Authentification...", + "invalidCredentials": "Identifiants invalides. Veuillez réessayer.", + "saveCredentials": "Enregistrer les identifiants pour les opérations futures", + "githubTitle": "Authentification GitHub", + "githubDescription": "Entrez un jeton d'accès personnel pour vous connecter à GitHub. Le jeton sera validé et enregistré automatiquement.", + "githubToken": "Jeton d'accès personnel", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "Générez un jeton dans GitHub → Settings → Developer settings → Personal access tokens.", + "githubAuthenticate": "Valider et connecter" + }, "SettingsShell": { "title": "Paramètres", "preferences": "Préférences", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 952a40e..ca40dbc 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -57,6 +57,26 @@ "clone": "クローン" } }, + "GitCredentialDialog": { + "title": "認証が必要です", + "description": "リモートサーバーが認証情報を要求しています。ユーザー名とパスワード(またはアクセストークン)を入力してください。", + "username": "ユーザー名", + "usernamePlaceholder": "ユーザー名またはメールアドレス", + "password": "パスワード / トークン", + "passwordPlaceholder": "パスワードまたはアクセストークン", + "passwordHint": "サーバーのユーザー名とパスワードを入力してください。", + "cancel": "キャンセル", + "authenticate": "認証", + "authenticating": "認証中...", + "invalidCredentials": "認証情報が無効です。再試行してください。", + "saveCredentials": "今後の操作のために認証情報を保存する", + "githubTitle": "GitHub 認証", + "githubDescription": "個人アクセストークンを入力して GitHub に接続します。トークン検証後、自動的にアカウントに保存されます。", + "githubToken": "個人アクセストークン", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "GitHub → Settings → Developer settings → Personal access tokens でトークンを生成してください。", + "githubAuthenticate": "検証して接続" + }, "SettingsShell": { "title": "設定", "preferences": "環境設定", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index e4c73d9..30b3fbe 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -57,6 +57,26 @@ "clone": "클론" } }, + "GitCredentialDialog": { + "title": "인증 필요", + "description": "원격 서버에서 자격 증명을 요구합니다. 사용자 이름과 비밀번호(또는 개인 액세스 토큰)를 입력하세요.", + "username": "사용자 이름", + "usernamePlaceholder": "사용자 이름 또는 이메일", + "password": "비밀번호 / 토큰", + "passwordPlaceholder": "비밀번호 또는 개인 액세스 토큰", + "passwordHint": "서버의 사용자 이름과 비밀번호를 입력하세요.", + "cancel": "취소", + "authenticate": "인증", + "authenticating": "인증 중...", + "invalidCredentials": "자격 증명이 유효하지 않습니다. 다시 시도하세요.", + "saveCredentials": "향후 작업을 위해 자격 증명 저장", + "githubTitle": "GitHub 인증", + "githubDescription": "개인 액세스 토큰을 입력하여 GitHub에 연결합니다. 토큰 확인 후 계정에 자동으로 저장됩니다.", + "githubToken": "개인 액세스 토큰", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "GitHub → Settings → Developer settings → Personal access tokens에서 토큰을 생성하세요.", + "githubAuthenticate": "확인 및 연결" + }, "SettingsShell": { "title": "설정", "preferences": "환경설정", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index b3c7e7e..999870d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -57,6 +57,26 @@ "clone": "Clonar" } }, + "GitCredentialDialog": { + "title": "Autenticação necessária", + "description": "O servidor remoto requer credenciais. Insira seu nome de usuário e senha (ou token de acesso pessoal).", + "username": "Nome de usuário", + "usernamePlaceholder": "Nome de usuário ou e-mail", + "password": "Senha / Token", + "passwordPlaceholder": "Senha ou token de acesso pessoal", + "passwordHint": "Insira o nome de usuário e a senha do servidor.", + "cancel": "Cancelar", + "authenticate": "Autenticar", + "authenticating": "Autenticando...", + "invalidCredentials": "Credenciais inválidas. Tente novamente.", + "saveCredentials": "Salvar credenciais para operações futuras", + "githubTitle": "Autenticação do GitHub", + "githubDescription": "Insira um token de acesso pessoal para se conectar ao GitHub. O token será validado e salvo automaticamente.", + "githubToken": "Token de acesso pessoal", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "Gere um token em GitHub → Settings → Developer settings → Personal access tokens.", + "githubAuthenticate": "Validar e conectar" + }, "SettingsShell": { "title": "Configurações", "preferences": "Preferências", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index a7da336..f81004b 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -57,6 +57,26 @@ "clone": "克隆" } }, + "GitCredentialDialog": { + "title": "需要身份验证", + "description": "远程服务器要求输入凭据。请输入用户名和密码(或个人访问令牌)。", + "username": "用户名", + "usernamePlaceholder": "用户名或邮箱", + "password": "密码 / 令牌", + "passwordPlaceholder": "密码或个人访问令牌", + "passwordHint": "请输入服务器的用户名和密码。", + "cancel": "取消", + "authenticate": "认证", + "authenticating": "认证中...", + "invalidCredentials": "凭据无效,请重试。", + "saveCredentials": "保存凭据以供后续操作使用", + "githubTitle": "GitHub 身份验证", + "githubDescription": "输入个人访问令牌以连接 GitHub。令牌验证成功后将自动保存到账号列表。", + "githubToken": "个人访问令牌", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "在 GitHub → Settings → Developer settings → Personal access tokens 中生成令牌。", + "githubAuthenticate": "验证并连接" + }, "SettingsShell": { "title": "设置", "preferences": "偏好设置", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 8a8d8c5..acf08b4 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -57,6 +57,26 @@ "clone": "複製" } }, + "GitCredentialDialog": { + "title": "需要身份驗證", + "description": "遠端伺服器要求輸入憑據。請輸入使用者名稱和密碼(或個人存取權杖)。", + "username": "使用者名稱", + "usernamePlaceholder": "使用者名稱或電子郵件", + "password": "密碼 / 權杖", + "passwordPlaceholder": "密碼或個人存取權杖", + "passwordHint": "請輸入伺服器的使用者名稱和密碼。", + "cancel": "取消", + "authenticate": "驗證", + "authenticating": "驗證中...", + "invalidCredentials": "憑據無效,請重試。", + "saveCredentials": "儲存憑據以供後續操作使用", + "githubTitle": "GitHub 身份驗證", + "githubDescription": "輸入個人存取權杖以連線 GitHub。權杖驗證成功後將自動儲存至帳號列表。", + "githubToken": "個人存取權杖", + "githubTokenPlaceholder": "ghp_xxxxxxxxxxxx", + "githubTokenHint": "在 GitHub → Settings → Developer settings → Personal access tokens 中產生權杖。", + "githubAuthenticate": "驗證並連線" + }, "SettingsShell": { "title": "設定", "preferences": "偏好設定", diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index cea5479..8ca985a 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -41,6 +41,7 @@ import type { GitLogEntry, SystemLanguageSettings, SystemProxySettings, + GitCredentials, GitDetectResult, GitSettings, GitHubAccountsSettings, @@ -487,9 +488,14 @@ export async function createFolderDirectory(path: string): Promise { export async function cloneRepository( url: string, - targetDir: string + targetDir: string, + credentials?: GitCredentials | null ): Promise { - return invoke("clone_repository", { url, targetDir }) + return invoke("clone_repository", { + url, + targetDir, + credentials: credentials ?? null, + }) } export async function getGitBranch(path: string): Promise { @@ -500,8 +506,11 @@ export async function gitInit(path: string): Promise { return invoke("git_init", { path }) } -export async function gitPull(path: string): Promise { - return invoke("git_pull", { path }) +export async function gitPull( + path: string, + credentials?: GitCredentials | null +): Promise { + return invoke("git_pull", { path, credentials: credentials ?? null }) } export async function gitStartPullMerge( @@ -515,12 +524,18 @@ export async function gitHasMergeHead(path: string): Promise { return invoke("git_has_merge_head", { path }) } -export async function gitFetch(path: string): Promise { - return invoke("git_fetch", { path }) +export async function gitFetch( + path: string, + credentials?: GitCredentials | null +): Promise { + return invoke("git_fetch", { path, credentials: credentials ?? null }) } -export async function gitPush(path: string): Promise { - return invoke("git_push", { path }) +export async function gitPush( + path: string, + credentials?: GitCredentials | null +): Promise { + return invoke("git_push", { path, credentials: credentials ?? null }) } export async function gitNewBranch( @@ -683,9 +698,14 @@ export async function gitListRemotes(path: string): Promise { export async function gitFetchRemote( path: string, - name: string + name: string, + credentials?: GitCredentials | null ): Promise { - return invoke("git_fetch_remote", { path, name }) + return invoke("git_fetch_remote", { + path, + name, + credentials: credentials ?? null, + }) } export async function gitAddRemote( diff --git a/src/lib/types.ts b/src/lib/types.ts index ccfa7d8..2cb7465 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -525,6 +525,11 @@ export interface SystemLanguageSettings { // --- Version Control --- +export interface GitCredentials { + username: string + password: string +} + export interface GitDetectResult { installed: boolean version: string | null