feat(web): Support GitLab repository urls.

Add GitLab URL input mode alongside existing GitHub and local modes:
- GitLab URL validation for gitlab.com and self-hosted instances
- GitLab icon component (custom SVG, matching existing GitHub icon pattern)
- Mode tab UI with GitLab option
- Backend API integration for GitLab HTTPS URLs

No token configuration included — public repositories supported only.

Close: #378

Co-authored-by: Claude <noreply@anthropic.com>
AI-model: kimi-for-coding/k2p6
This commit is contained in:
Hugo Gu 2026-05-13 23:57:38 +08:00
parent c30833fad3
commit 690163a865
2 changed files with 157 additions and 4 deletions

View file

@ -9,6 +9,7 @@
import { useState, useRef, useEffect, useId } from 'react';
import {
Github,
Gitlab,
FolderOpen,
Loader2,
Check,
@ -26,15 +27,20 @@ import { AnalyzeProgress } from './AnalyzeProgress';
// ── Helpers ──────────────────────────────────────────────────────────────────
type InputMode = 'github' | 'local';
type InputMode = 'github' | 'gitlab' | 'local';
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
const GITLAB_RE = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+(\/.*)?$/i;
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
function isValidGithubUrl(value: string): boolean {
return GITHUB_RE.test(value.trim());
}
function isValidGitlabUrl(value: string): boolean {
return GITLAB_RE.test(value.trim());
}
// ── Mode tabs ────────────────────────────────────────────────────────────────
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
@ -53,6 +59,19 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
<Github className="h-3 w-3" />
GitHub URL
</button>
<button
role="tab"
aria-selected={mode === 'gitlab'}
onClick={() => onChange('gitlab')}
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
mode === 'gitlab'
? 'bg-accent text-white shadow-sm'
: 'text-text-muted hover:text-text-secondary'
} `}
>
<Gitlab className="h-3 w-3" />
GitLab URL
</button>
<button
role="tab"
aria-selected={mode === 'local'}
@ -138,6 +157,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const folderInputRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<InputMode>('github');
const [githubUrl, setGithubUrl] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
const [localPath, setLocalPath] = useState('');
const [phase, setPhase] = useState<InternalPhase>('input');
const [validationError, setValidationError] = useState<string | null>(null);
@ -162,6 +182,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const handleModeChange = (m: InputMode) => {
setMode(m);
setGithubUrl('');
setGitlabUrl('');
setLocalPath('');
setValidationError(null);
};
@ -175,13 +196,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const canSubmit =
mode === 'github'
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
: mode === 'gitlab'
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
const handleAnalyze = async () => {
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
setValidationError('Please enter a valid GitHub repository URL.');
return;
}
if (mode === 'gitlab' && !isValidGitlabUrl(gitlabUrl)) {
setValidationError('Please enter a valid GitLab repository URL.');
return;
}
if (mode === 'local' && localPath.trim().length < 2) {
setValidationError('Please enter a folder path.');
return;
@ -191,12 +218,22 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setPhase('starting');
try {
const request = mode === 'github' ? { url: githubUrl.trim() } : { path: localPath.trim() };
const request =
mode === 'github'
? { url: githubUrl.trim() }
: mode === 'gitlab'
? { url: gitlabUrl.trim() }
: { path: localPath.trim() };
const { jobId } = await startAnalyze(request);
jobIdRef.current = jobId;
setPhase('analyzing');
const nameSource = mode === 'github' ? githubUrl.trim() : localPath.trim();
const nameSource =
mode === 'github'
? githubUrl.trim()
: mode === 'gitlab'
? gitlabUrl.trim()
: localPath.trim();
const controller = streamAnalyzeProgress(
jobId,
(p) => setProgress(p),
@ -297,6 +334,61 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
</div>
)}
{/* GitLab URL input */}
{showInput && mode === 'gitlab' && (
<div className="space-y-2">
<label
htmlFor={inputId}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
GitLab Repository URL
</label>
<div
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
validationError && phase === 'error'
? 'border-red-500/50'
: isValidGitlabUrl(gitlabUrl)
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
} `}
>
<Gitlab className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={inputId}
type="url"
value={gitlabUrl}
onChange={(e) => {
setGitlabUrl(e.target.value);
if (validationError) setValidationError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !isLoading) {
e.preventDefault();
handleAnalyze();
}
}}
disabled={isLoading}
placeholder="https://gitlab.com/owner/repo"
autoComplete="url"
spellCheck={false}
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
/>
{gitlabUrl.length > 10 && (
<div className="shrink-0">
{isValidGitlabUrl(gitlabUrl) ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
)}
</div>
)}
</div>
<p className="text-xs text-text-muted">
Supports GitLab.com and self-hosted GitLab instances.
</p>
</div>
)}
{/* Local folder input */}
{showInput && mode === 'local' && (
<div className="space-y-2">

View file

@ -123,6 +123,67 @@ export {
* defaults to `currentColor`, so Tailwind `text-*` utilities work the same as
* with any other icon in this module.
*/
/**
* GitLab tanuki mark — SVG path data from simple-icons (CC0-1.0).
*
* GitLab's logo (the tanuki/fox-head) is a registered trademark of GitLab Inc.
* We use it here only to indicate GitLab source-repo integration.
*
* API-compatible with `lucide-react` icons (`LucideProps`).
*/
export const Gitlab = forwardRef<SVGSVGElement, LucideProps>(function Gitlab(
{
size = 24,
color = 'currentColor',
className,
strokeWidth: _strokeWidth,
absoluteStrokeWidth: _absoluteStrokeWidth,
...rest
},
ref,
) {
const numericSize = typeof size === 'string' ? Number.parseFloat(size) : size;
const useSmallVariant = Number.isFinite(numericSize) && (numericSize as number) <= 16;
if (useSmallVariant) {
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 16 16"
fill={color}
className={className}
{...rest}
>
<path d="M8 15.282l1.855-5.717H6.145L8 15.282z" />
<path d="M8 15.282L6.145 9.565H2.333L8 15.282z" />
<path d="M2.333 9.565l-.944-2.942c-.09-.267.067-.553.333-.553h3.153L2.333 9.565z" />
<path d="M4.875 6.07L6.145 9.565H2.333l2.542-3.495z" />
<path d="M13.667 9.565l.944-2.942c.09-.267-.067-.553-.333-.553h-3.153l2.542 3.495z" />
<path d="M11.125 6.07L9.855 9.565h3.812l-2.542-3.495z" />
<path d="M8 15.282l1.855-5.717H6.145L8 15.282z" />
</svg>
);
}
return (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill={color}
className={className}
{...rest}
>
<path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z" />
</svg>
);
});
export const Github = forwardRef<SVGSVGElement, LucideProps>(function Github(
{
size = 24,