diff --git a/src-tauri/src/git/clone.rs b/src-tauri/src/git/clone.rs index 9ebc444c..e432cf2d 100644 --- a/src-tauri/src/git/clone.rs +++ b/src-tauri/src/git/clone.rs @@ -94,7 +94,7 @@ fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> { fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command { let mut command = Command::new("git"); command - .args(["clone", "--progress", request.url, destination]) + .args(["clone", "--quiet", request.url, destination]) .env("GIT_TERMINAL_PROMPT", "0") .env("SSH_ASKPASS_REQUIRE", "never") .stdin(Stdio::null()); @@ -217,6 +217,10 @@ mod tests { dest, }; let command = build_clone_command(&request, "/tmp/repo"); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); let envs = command .get_envs() .map(|(key, value)| { @@ -235,5 +239,14 @@ mod tests { envs.get("SSH_ASKPASS_REQUIRE"), Some(&Some("never".to_string())) ); + assert_eq!( + args, + vec![ + "clone".to_string(), + "--quiet".to_string(), + "https://example.com/repo.git".to_string(), + "/tmp/repo".to_string(), + ] + ); } } diff --git a/src/components/CloneVaultModal.test.tsx b/src/components/CloneVaultModal.test.tsx index a6ce8823..eb10839f 100644 --- a/src/components/CloneVaultModal.test.tsx +++ b/src/components/CloneVaultModal.test.tsx @@ -36,6 +36,14 @@ describe('CloneVaultModal', () => { expect(screen.getByTestId('clone-vault-path')).toBeInTheDocument() }) + it('focuses the repository field when the modal opens', async () => { + render() + + await waitFor(() => { + expect(screen.getByTestId('clone-repo-url')).toHaveFocus() + }) + }) + it('suggests a vault path from the repository URL', () => { render() @@ -105,4 +113,35 @@ describe('CloneVaultModal', () => { expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault') }) }) + + it('submits with Enter and blocks overlapping clone attempts while pending', async () => { + let resolveClone: ((value: string) => void) | null = null + mockInvokeFn.mockReturnValueOnce(new Promise((resolve) => { + resolveClone = resolve + })) + + render() + + fireEvent.change(screen.getByTestId('clone-repo-url'), { + target: { value: 'git@github.com:user/my-vault.git' }, + }) + + fireEvent.submit(screen.getByTestId('clone-vault-form')) + fireEvent.submit(screen.getByTestId('clone-vault-form')) + + await waitFor(() => { + expect(mockInvokeFn).toHaveBeenCalledTimes(1) + }) + + expect(screen.getByTestId('clone-repo-url')).toBeDisabled() + expect(screen.getByTestId('clone-vault-path')).toBeDisabled() + expect(screen.getByTestId('clone-vault-cancel')).toBeDisabled() + expect(screen.getByTestId('clone-vault-submit')).toHaveTextContent('Cloning...') + + resolveClone?.('Cloned successfully') + + await waitFor(() => { + expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault') + }) + }) }) diff --git a/src/components/CloneVaultModal.tsx b/src/components/CloneVaultModal.tsx index 9cb28ecd..f2721101 100644 --- a/src/components/CloneVaultModal.tsx +++ b/src/components/CloneVaultModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { type FormEvent, useCallback, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { Dialog, @@ -25,6 +25,7 @@ interface CloneVaultFormState { localPath: string cloneStatus: CloneStatus cloneError: string | null + isCloning: boolean isCloneDisabled: boolean handleClose: () => void handleRepoUrlChange: (value: string) => void @@ -63,6 +64,7 @@ function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, la const [pathDirty, setPathDirty] = useState(false) const [cloneStatus, setCloneStatus] = useState('idle') const [cloneError, setCloneError] = useState(null) + const cloneInFlightRef = useRef(false) const previousSuggestedPathRef = useRef('') const resetState = useCallback(() => { @@ -75,6 +77,7 @@ function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, la }, []) const handleClose = useCallback(() => { + if (cloneInFlightRef.current) return resetState() onClose() }, [onClose, resetState]) @@ -102,27 +105,38 @@ function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, la const handleClone = useCallback(async () => { const trimmedUrl = repoUrl.trim() const trimmedPath = localPath.trim() - if (!trimmedUrl || !trimmedPath) return + if (!trimmedUrl || !trimmedPath || cloneInFlightRef.current) return + cloneInFlightRef.current = true setCloneStatus('cloning') setCloneError(null) + let cloneSucceeded = false try { await tauriCall('clone_git_repo', { url: trimmedUrl, localPath: trimmedPath }) - onVaultCloned(trimmedPath, labelFromPath(trimmedPath)) - handleClose() + cloneSucceeded = true } catch (error) { setCloneStatus('error') setCloneError(`Clone failed: ${String(error)}`) + } finally { + cloneInFlightRef.current = false + } + + if (cloneSucceeded) { + onVaultCloned(trimmedPath, labelFromPath(trimmedPath)) + handleClose() } }, [handleClose, localPath, onVaultCloned, repoUrl]) + const isCloning = cloneStatus === 'cloning' + return { repoUrl, localPath, cloneStatus, cloneError, - isCloneDisabled: !repoUrl.trim() || !localPath.trim() || cloneStatus === 'cloning', + isCloning, + isCloneDisabled: !repoUrl.trim() || !localPath.trim() || isCloning, handleClose, handleRepoUrlChange, handleLocalPathChange, @@ -136,6 +150,7 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa localPath, cloneStatus, cloneError, + isCloning, isCloneDisabled, handleClose, handleRepoUrlChange, @@ -143,8 +158,12 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa handleClone, } = useCloneVaultForm(onClose, onVaultCloned) const handleOpenChange = useCallback((isOpen: boolean) => { - if (!isOpen) handleClose() - }, [handleClose]) + if (!isOpen && !isCloning) handleClose() + }, [handleClose, isCloning]) + const handleSubmit = useCallback((event: FormEvent) => { + event.preventDefault() + void handleClone() + }, [handleClone]) return ( @@ -157,13 +176,14 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa -
+
handleRepoUrlChange(event.target.value)} data-testid="clone-repo-url" /> @@ -175,29 +195,41 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa id="clone-vault-path" placeholder="~/Vaults/my-vault" value={localPath} + disabled={isCloning} onChange={(event) => handleLocalPathChange(event.target.value)} data-testid="clone-vault-path" />

- SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work. + {isCloning + ? 'Cloning repository… Tolaria will open the vault when git finishes.' + : 'SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work.'}

{cloneError && (

{cloneError}

)} -
- - - + + + + +
)