2026-04-24 15:13:24 +02:00
|
|
|
import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from 'react'
|
2026-04-12 17:08:07 +02:00
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
|
import { isTauri, mockInvoke } from '../mock-tauri'
|
|
|
|
|
|
|
|
|
|
type CloneStatus = 'idle' | 'cloning' | 'error'
|
2026-04-24 15:13:24 +02:00
|
|
|
type CloneAttemptResult =
|
|
|
|
|
| { ok: true }
|
|
|
|
|
| { ok: false; errorMessage: string }
|
2026-04-24 15:18:50 +02:00
|
|
|
type CloneRequest = Record<string, unknown> & {
|
2026-04-24 15:13:24 +02:00
|
|
|
url: string
|
|
|
|
|
localPath: string
|
|
|
|
|
}
|
2026-04-12 17:08:07 +02:00
|
|
|
|
|
|
|
|
interface CloneVaultModalProps {
|
|
|
|
|
open: boolean
|
|
|
|
|
onClose: () => void
|
|
|
|
|
onVaultCloned: (path: string, label: string) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CloneVaultFormState {
|
|
|
|
|
repoUrl: string
|
|
|
|
|
localPath: string
|
|
|
|
|
cloneStatus: CloneStatus
|
|
|
|
|
cloneError: string | null
|
2026-04-24 14:52:53 +02:00
|
|
|
isCloning: boolean
|
2026-04-12 17:08:07 +02:00
|
|
|
isCloneDisabled: boolean
|
|
|
|
|
handleClose: () => void
|
2026-04-24 15:13:24 +02:00
|
|
|
handleRepoUrlChange: (event: ChangeEvent<HTMLInputElement>) => void
|
|
|
|
|
handleLocalPathChange: (event: ChangeEvent<HTMLInputElement>) => void
|
2026-04-12 17:08:07 +02:00
|
|
|
handleClone: () => Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
|
|
|
|
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
function repoNameFromUrl(request: Pick<CloneRequest, 'url'>): string {
|
|
|
|
|
const trimmed = request.url.trim().replace(/\/+$/g, '')
|
2026-04-12 17:08:07 +02:00
|
|
|
if (!trimmed) return ''
|
|
|
|
|
const segment = trimmed.split(/[/:]/).pop() ?? ''
|
|
|
|
|
return segment.replace(/\.git$/i, '')
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
function suggestedPathFromUrl(request: Pick<CloneRequest, 'url'>): string {
|
|
|
|
|
const repoName = repoNameFromUrl(request)
|
2026-04-12 17:08:07 +02:00
|
|
|
return repoName ? `~/Vaults/${repoName}` : ''
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
function labelFromPath(request: Pick<CloneRequest, 'localPath'>): string {
|
|
|
|
|
const trimmed = request.localPath.trim().replace(/\/+$/g, '')
|
2026-04-12 17:08:07 +02:00
|
|
|
return trimmed.split('/').pop() || 'Vault'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldSyncSuggestedPath(localPath: string, pathDirty: boolean, previousSuggestedPath: string): boolean {
|
|
|
|
|
return !pathDirty || !localPath.trim() || localPath === previousSuggestedPath
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
async function attemptClone(request: CloneRequest): Promise<CloneAttemptResult> {
|
|
|
|
|
try {
|
|
|
|
|
await tauriCall<string>('clone_git_repo', request)
|
|
|
|
|
return { ok: true }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { ok: false, errorMessage: `Clone failed: ${String(error)}` }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:08:07 +02:00
|
|
|
function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, label: string) => void): CloneVaultFormState {
|
|
|
|
|
const [repoUrl, setRepoUrl] = useState('')
|
|
|
|
|
const [localPath, setLocalPath] = useState('')
|
|
|
|
|
const [pathDirty, setPathDirty] = useState(false)
|
|
|
|
|
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
|
|
|
|
const [cloneError, setCloneError] = useState<string | null>(null)
|
2026-04-24 14:52:53 +02:00
|
|
|
const cloneInFlightRef = useRef(false)
|
2026-04-12 17:08:07 +02:00
|
|
|
const previousSuggestedPathRef = useRef('')
|
|
|
|
|
|
|
|
|
|
const resetState = useCallback(() => {
|
|
|
|
|
setRepoUrl('')
|
|
|
|
|
setLocalPath('')
|
|
|
|
|
setPathDirty(false)
|
|
|
|
|
setCloneStatus('idle')
|
|
|
|
|
setCloneError(null)
|
|
|
|
|
previousSuggestedPathRef.current = ''
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const handleClose = useCallback(() => {
|
2026-04-24 14:52:53 +02:00
|
|
|
if (cloneInFlightRef.current) return
|
2026-04-12 17:08:07 +02:00
|
|
|
resetState()
|
|
|
|
|
onClose()
|
|
|
|
|
}, [onClose, resetState])
|
|
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
const handleRepoUrlChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const value = event.target.value
|
2026-04-12 17:08:07 +02:00
|
|
|
setRepoUrl(value)
|
|
|
|
|
setCloneError(null)
|
|
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
const nextSuggestedPath = suggestedPathFromUrl({ url: value })
|
2026-04-12 17:08:07 +02:00
|
|
|
const previousSuggestedPath = previousSuggestedPathRef.current
|
|
|
|
|
|
|
|
|
|
if (shouldSyncSuggestedPath(localPath, pathDirty, previousSuggestedPath)) {
|
|
|
|
|
setLocalPath(nextSuggestedPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
previousSuggestedPathRef.current = nextSuggestedPath
|
|
|
|
|
}, [localPath, pathDirty])
|
|
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
const handleLocalPathChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const value = event.target.value
|
2026-04-12 17:08:07 +02:00
|
|
|
setPathDirty(true)
|
|
|
|
|
setLocalPath(value)
|
|
|
|
|
setCloneError(null)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const handleClone = useCallback(async () => {
|
|
|
|
|
const trimmedUrl = repoUrl.trim()
|
|
|
|
|
const trimmedPath = localPath.trim()
|
2026-04-24 15:13:24 +02:00
|
|
|
const request = { url: trimmedUrl, localPath: trimmedPath }
|
|
|
|
|
if (!request.url || !request.localPath || cloneInFlightRef.current) return
|
2026-04-12 17:08:07 +02:00
|
|
|
|
2026-04-24 14:52:53 +02:00
|
|
|
cloneInFlightRef.current = true
|
2026-04-12 17:08:07 +02:00
|
|
|
setCloneStatus('cloning')
|
|
|
|
|
setCloneError(null)
|
2026-04-24 15:13:24 +02:00
|
|
|
const result = await attemptClone(request)
|
|
|
|
|
cloneInFlightRef.current = false
|
2026-04-24 14:52:53 +02:00
|
|
|
|
2026-04-24 15:13:24 +02:00
|
|
|
if (result.ok) {
|
|
|
|
|
onVaultCloned(request.localPath, labelFromPath({ localPath: request.localPath }))
|
2026-04-24 14:52:53 +02:00
|
|
|
handleClose()
|
2026-04-24 15:13:24 +02:00
|
|
|
return
|
2026-04-12 17:08:07 +02:00
|
|
|
}
|
2026-04-24 15:13:24 +02:00
|
|
|
|
|
|
|
|
setCloneStatus('error')
|
|
|
|
|
setCloneError(result.errorMessage)
|
2026-04-12 17:08:07 +02:00
|
|
|
}, [handleClose, localPath, onVaultCloned, repoUrl])
|
|
|
|
|
|
2026-04-24 14:52:53 +02:00
|
|
|
const isCloning = cloneStatus === 'cloning'
|
|
|
|
|
|
2026-04-12 17:08:07 +02:00
|
|
|
return {
|
|
|
|
|
repoUrl,
|
|
|
|
|
localPath,
|
|
|
|
|
cloneStatus,
|
|
|
|
|
cloneError,
|
2026-04-24 14:52:53 +02:00
|
|
|
isCloning,
|
|
|
|
|
isCloneDisabled: !repoUrl.trim() || !localPath.trim() || isCloning,
|
2026-04-12 17:08:07 +02:00
|
|
|
handleClose,
|
|
|
|
|
handleRepoUrlChange,
|
|
|
|
|
handleLocalPathChange,
|
|
|
|
|
handleClone,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModalProps) {
|
|
|
|
|
const {
|
|
|
|
|
repoUrl,
|
|
|
|
|
localPath,
|
|
|
|
|
cloneStatus,
|
|
|
|
|
cloneError,
|
2026-04-24 14:52:53 +02:00
|
|
|
isCloning,
|
2026-04-12 17:08:07 +02:00
|
|
|
isCloneDisabled,
|
|
|
|
|
handleClose,
|
|
|
|
|
handleRepoUrlChange,
|
|
|
|
|
handleLocalPathChange,
|
|
|
|
|
handleClone,
|
|
|
|
|
} = useCloneVaultForm(onClose, onVaultCloned)
|
|
|
|
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
2026-04-24 14:52:53 +02:00
|
|
|
if (!isOpen && !isCloning) handleClose()
|
|
|
|
|
}, [handleClose, isCloning])
|
|
|
|
|
const handleSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
void handleClone()
|
|
|
|
|
}, [handleClone])
|
2026-04-12 17:08:07 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
|
|
|
<DialogContent className="sm:max-w-[520px]" data-testid="clone-vault-modal">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Clone Git Repo</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
Clone any remote repository into a local vault folder. Tolaria uses your existing system git
|
|
|
|
|
configuration for authentication.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
2026-04-24 14:52:53 +02:00
|
|
|
<form className="flex flex-col gap-4 py-2" onSubmit={handleSubmit} data-testid="clone-vault-form" aria-busy={isCloning}>
|
2026-04-12 17:08:07 +02:00
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<label className="text-xs font-medium text-foreground" htmlFor="clone-repo-url">Repository URL</label>
|
|
|
|
|
<Input
|
|
|
|
|
id="clone-repo-url"
|
|
|
|
|
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
|
|
|
|
|
value={repoUrl}
|
2026-04-24 14:52:53 +02:00
|
|
|
disabled={isCloning}
|
2026-04-24 15:13:24 +02:00
|
|
|
onChange={handleRepoUrlChange}
|
2026-04-12 17:08:07 +02:00
|
|
|
data-testid="clone-repo-url"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<label className="text-xs font-medium text-foreground" htmlFor="clone-vault-path">Clone to</label>
|
|
|
|
|
<Input
|
|
|
|
|
id="clone-vault-path"
|
|
|
|
|
placeholder="~/Vaults/my-vault"
|
|
|
|
|
value={localPath}
|
2026-04-24 14:52:53 +02:00
|
|
|
disabled={isCloning}
|
2026-04-24 15:13:24 +02:00
|
|
|
onChange={handleLocalPathChange}
|
2026-04-12 17:08:07 +02:00
|
|
|
data-testid="clone-vault-path"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2026-04-24 14:52:53 +02:00
|
|
|
{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.'}
|
2026-04-12 17:08:07 +02:00
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{cloneError && (
|
|
|
|
|
<p className="text-xs text-destructive" data-testid="clone-vault-error">{cloneError}</p>
|
|
|
|
|
)}
|
2026-04-24 14:52:53 +02:00
|
|
|
|
|
|
|
|
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
disabled={isCloning}
|
|
|
|
|
data-testid="clone-vault-cancel"
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={isCloneDisabled}
|
|
|
|
|
data-testid="clone-vault-submit"
|
|
|
|
|
>
|
|
|
|
|
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</form>
|
2026-04-12 17:08:07 +02:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
}
|