feat: environment toggle (#325)
* - add git clone logic - add logic to list Github repos - toggle between local and cloud envs * ci
This commit is contained in:
committed by
GitHub
parent
5febd6b17b
commit
693f85ba26
256
frontend/src/components/projects/github-repository-picker.tsx
Normal file
256
frontend/src/components/projects/github-repository-picker.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2, Github } from 'lucide-react';
|
||||
import { githubApi, RepositoryInfo } from '@/lib/api';
|
||||
|
||||
interface GitHubRepositoryPickerProps {
|
||||
selectedRepository: RepositoryInfo | null;
|
||||
onRepositorySelect: (repository: RepositoryInfo | null) => void;
|
||||
onNameChange: (name: string) => void;
|
||||
name: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
// Simple in-memory cache for repositories
|
||||
const repositoryCache = new Map<number, RepositoryInfo[]>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
const cacheTimestamps = new Map<number, number>();
|
||||
|
||||
function isCacheValid(page: number): boolean {
|
||||
const timestamp = cacheTimestamps.get(page);
|
||||
return timestamp ? Date.now() - timestamp < CACHE_DURATION : false;
|
||||
}
|
||||
|
||||
export function GitHubRepositoryPicker({
|
||||
selectedRepository,
|
||||
onRepositorySelect,
|
||||
onNameChange,
|
||||
name,
|
||||
error,
|
||||
}: GitHubRepositoryPickerProps) {
|
||||
const [repositories, setRepositories] = useState<RepositoryInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMorePages, setHasMorePages] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const loadRepositories = useCallback(
|
||||
async (pageNum: number = 1, isLoadingMore: boolean = false) => {
|
||||
if (isLoadingMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setLoadError('');
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
if (isCacheValid(pageNum)) {
|
||||
const cachedRepos = repositoryCache.get(pageNum);
|
||||
if (cachedRepos) {
|
||||
if (pageNum === 1) {
|
||||
setRepositories(cachedRepos);
|
||||
} else {
|
||||
setRepositories((prev) => [...prev, ...cachedRepos]);
|
||||
}
|
||||
setPage(pageNum);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const repos = await githubApi.listRepositories(pageNum);
|
||||
|
||||
// Cache the results
|
||||
repositoryCache.set(pageNum, repos);
|
||||
cacheTimestamps.set(pageNum, Date.now());
|
||||
|
||||
if (pageNum === 1) {
|
||||
setRepositories(repos);
|
||||
} else {
|
||||
setRepositories((prev) => [...prev, ...repos]);
|
||||
}
|
||||
setPage(pageNum);
|
||||
|
||||
// If we got fewer than expected results, we've reached the end
|
||||
if (repos.length < 30) {
|
||||
// GitHub typically returns 30 repos per page
|
||||
setHasMorePages(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : 'Failed to load repositories'
|
||||
);
|
||||
} finally {
|
||||
if (isLoadingMore) {
|
||||
setLoadingMore(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadRepositories(1);
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleRepositorySelect = (repository: RepositoryInfo) => {
|
||||
onRepositorySelect(repository);
|
||||
// Auto-populate project name from repository name if name is empty
|
||||
if (!name) {
|
||||
const cleanName = repository.name
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
onNameChange(cleanName);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreRepositories = useCallback(() => {
|
||||
if (!loading && !loadingMore && hasMorePages) {
|
||||
loadRepositories(page + 1, true);
|
||||
}
|
||||
}, [loading, loadingMore, hasMorePages, page, loadRepositories]);
|
||||
|
||||
// Infinite scroll handler
|
||||
const handleScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const isNearBottom = scrollHeight - scrollTop <= clientHeight + 100; // 100px threshold
|
||||
|
||||
if (isNearBottom && !loading && !loadingMore && hasMorePages) {
|
||||
loadMoreRepositories();
|
||||
}
|
||||
},
|
||||
[loading, loadingMore, hasMorePages, loadMoreRepositories]
|
||||
);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{loadError}
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 ml-2"
|
||||
onClick={() => loadRepositories(1)}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Repository</Label>
|
||||
{loading && repositories.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">Loading repositories...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="max-h-64 overflow-y-auto border rounded-md p-4 space-y-3"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{repositories.map((repository) => (
|
||||
<div
|
||||
key={repository.id}
|
||||
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent ${
|
||||
selectedRepository?.id === repository.id
|
||||
? 'bg-accent border-primary'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => handleRepositorySelect(repository)}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Github className="h-4 w-4 mt-1" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium">{repository.name}</span>
|
||||
{repository.private && (
|
||||
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded">
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div>{repository.full_name}</div>
|
||||
{repository.description && (
|
||||
<div className="mt-1">{repository.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{repositories.length === 0 && !loading && (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
No repositories found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading more indicator */}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading more repositories...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual load more button (fallback if infinite scroll doesn't work) */}
|
||||
{hasMorePages && !loadingMore && repositories.length > 0 && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMoreRepositories}
|
||||
disabled={loading || loadingMore}
|
||||
className="w-full"
|
||||
>
|
||||
Load more repositories
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of results indicator */}
|
||||
{!hasMorePages && repositories.length > 0 && (
|
||||
<div className="text-center py-2 text-xs text-muted-foreground border-t">
|
||||
All repositories loaded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRepository && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Project Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="Enter project name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,8 +14,15 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { FolderPicker } from '@/components/ui/folder-picker';
|
||||
import { TaskTemplateManager } from '@/components/TaskTemplateManager';
|
||||
import { ProjectFormFields } from './project-form-fields';
|
||||
import { CreateProject, Project, UpdateProject } from 'shared/types';
|
||||
import { projectsApi } from '@/lib/api';
|
||||
import { GitHubRepositoryPicker } from './github-repository-picker';
|
||||
import {
|
||||
CreateProject,
|
||||
CreateProjectFromGitHub,
|
||||
Project,
|
||||
UpdateProject,
|
||||
Environment,
|
||||
} from 'shared/types';
|
||||
import { projectsApi, configApi, githubApi, RepositoryInfo } from '@/lib/api';
|
||||
|
||||
interface ProjectFormProps {
|
||||
open: boolean;
|
||||
@@ -42,8 +51,34 @@ export function ProjectForm({
|
||||
const [parentPath, setParentPath] = useState('');
|
||||
const [folderName, setFolderName] = useState('');
|
||||
|
||||
// Environment and GitHub repository state
|
||||
const [environment, setEnvironment] = useState<Environment>('local');
|
||||
const [selectedRepository, setSelectedRepository] =
|
||||
useState<RepositoryInfo | null>(null);
|
||||
const [modeLoading, setModeLoading] = useState(true);
|
||||
|
||||
const isEditing = !!project;
|
||||
|
||||
// Load cloud mode configuration
|
||||
useEffect(() => {
|
||||
const loadMode = async () => {
|
||||
try {
|
||||
const constants = await configApi.getConstants();
|
||||
setEnvironment(constants.mode);
|
||||
} catch (err) {
|
||||
console.error('Failed to load config constants:', err);
|
||||
} finally {
|
||||
setModeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isEditing) {
|
||||
loadMode();
|
||||
} else {
|
||||
setModeLoading(false);
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// Update form fields when project prop changes
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
@@ -58,6 +93,7 @@ export function ProjectForm({
|
||||
setSetupScript('');
|
||||
setDevScript('');
|
||||
setCleanupScript('');
|
||||
setSelectedRepository(null);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
@@ -85,14 +121,13 @@ export function ProjectForm({
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let finalGitRepoPath = gitRepoPath;
|
||||
|
||||
// For new repo mode, construct the full path
|
||||
if (!isEditing && repoMode === 'new') {
|
||||
finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
// Editing existing project (local mode only)
|
||||
let finalGitRepoPath = gitRepoPath;
|
||||
if (repoMode === 'new') {
|
||||
finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
const updateData: UpdateProject = {
|
||||
name,
|
||||
git_repo_path: finalGitRepoPath,
|
||||
@@ -101,37 +136,59 @@ export function ProjectForm({
|
||||
cleanup_script: cleanupScript.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
await projectsApi.update(project.id, updateData);
|
||||
} catch (error) {
|
||||
setError('Failed to update project');
|
||||
return;
|
||||
}
|
||||
await projectsApi.update(project.id, updateData);
|
||||
} else {
|
||||
const createData: CreateProject = {
|
||||
name,
|
||||
git_repo_path: finalGitRepoPath,
|
||||
use_existing_repo: repoMode === 'existing',
|
||||
setup_script: setupScript.trim() || null,
|
||||
dev_script: devScript.trim() || null,
|
||||
cleanup_script: cleanupScript.trim() || null,
|
||||
};
|
||||
// Creating new project
|
||||
if (environment === 'cloud') {
|
||||
// Cloud mode: Create project from GitHub repository
|
||||
if (!selectedRepository) {
|
||||
setError('Please select a GitHub repository');
|
||||
return;
|
||||
}
|
||||
|
||||
const githubData: CreateProjectFromGitHub = {
|
||||
repository_id: BigInt(selectedRepository.id),
|
||||
name,
|
||||
clone_url: selectedRepository.clone_url,
|
||||
setup_script: setupScript.trim() || null,
|
||||
dev_script: devScript.trim() || null,
|
||||
cleanup_script: cleanupScript.trim() || null,
|
||||
};
|
||||
|
||||
await githubApi.createProjectFromRepository(githubData);
|
||||
} else {
|
||||
// Local mode: Create local project
|
||||
let finalGitRepoPath = gitRepoPath;
|
||||
if (repoMode === 'new') {
|
||||
finalGitRepoPath = `${parentPath}/${folderName}`.replace(
|
||||
/\/+/g,
|
||||
'/'
|
||||
);
|
||||
}
|
||||
|
||||
const createData: CreateProject = {
|
||||
name,
|
||||
git_repo_path: finalGitRepoPath,
|
||||
use_existing_repo: repoMode === 'existing',
|
||||
setup_script: setupScript.trim() || null,
|
||||
dev_script: devScript.trim() || null,
|
||||
cleanup_script: cleanupScript.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
await projectsApi.create(createData);
|
||||
} catch (error) {
|
||||
setError('Failed to create project');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
// Reset form
|
||||
setName('');
|
||||
setGitRepoPath('');
|
||||
setSetupScript('');
|
||||
setDevScript('');
|
||||
setCleanupScript('');
|
||||
setParentPath('');
|
||||
setFolderName('');
|
||||
setSelectedRepository(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'An error occurred');
|
||||
} finally {
|
||||
@@ -226,27 +283,89 @@ export function ProjectForm({
|
||||
</Tabs>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<ProjectFormFields
|
||||
isEditing={isEditing}
|
||||
repoMode={repoMode}
|
||||
setRepoMode={setRepoMode}
|
||||
gitRepoPath={gitRepoPath}
|
||||
handleGitRepoPathChange={handleGitRepoPathChange}
|
||||
setShowFolderPicker={setShowFolderPicker}
|
||||
parentPath={parentPath}
|
||||
setParentPath={setParentPath}
|
||||
folderName={folderName}
|
||||
setFolderName={setFolderName}
|
||||
setName={setName}
|
||||
name={name}
|
||||
setupScript={setupScript}
|
||||
setSetupScript={setSetupScript}
|
||||
devScript={devScript}
|
||||
setDevScript={setDevScript}
|
||||
cleanupScript={cleanupScript}
|
||||
setCleanupScript={setCleanupScript}
|
||||
error={error}
|
||||
/>
|
||||
{modeLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2">Loading...</span>
|
||||
</div>
|
||||
) : environment === 'cloud' ? (
|
||||
// Cloud mode: Show only GitHub repositories
|
||||
<>
|
||||
<GitHubRepositoryPicker
|
||||
selectedRepository={selectedRepository}
|
||||
onRepositorySelect={setSelectedRepository}
|
||||
onNameChange={setName}
|
||||
name={name}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Show script fields for GitHub source */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="setup-script">
|
||||
Setup Script (optional)
|
||||
</Label>
|
||||
<textarea
|
||||
id="setup-script"
|
||||
placeholder="e.g., npm install"
|
||||
value={setupScript}
|
||||
onChange={(e) => setSetupScript(e.target.value)}
|
||||
className="w-full p-2 border rounded-md resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dev-script">
|
||||
Dev Server Script (optional)
|
||||
</Label>
|
||||
<textarea
|
||||
id="dev-script"
|
||||
placeholder="e.g., npm run dev"
|
||||
value={devScript}
|
||||
onChange={(e) => setDevScript(e.target.value)}
|
||||
className="w-full p-2 border rounded-md resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cleanup-script">
|
||||
Cleanup Script (optional)
|
||||
</Label>
|
||||
<textarea
|
||||
id="cleanup-script"
|
||||
placeholder="e.g., docker-compose down"
|
||||
value={cleanupScript}
|
||||
onChange={(e) => setCleanupScript(e.target.value)}
|
||||
className="w-full p-2 border rounded-md resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Local mode: Show existing form
|
||||
<ProjectFormFields
|
||||
isEditing={isEditing}
|
||||
repoMode={repoMode}
|
||||
setRepoMode={setRepoMode}
|
||||
gitRepoPath={gitRepoPath}
|
||||
handleGitRepoPathChange={handleGitRepoPathChange}
|
||||
setShowFolderPicker={setShowFolderPicker}
|
||||
parentPath={parentPath}
|
||||
setParentPath={setParentPath}
|
||||
folderName={folderName}
|
||||
setFolderName={setFolderName}
|
||||
setName={setName}
|
||||
name={name}
|
||||
setupScript={setupScript}
|
||||
setSetupScript={setSetupScript}
|
||||
devScript={devScript}
|
||||
setDevScript={setDevScript}
|
||||
cleanupScript={cleanupScript}
|
||||
setCleanupScript={setCleanupScript}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -261,9 +380,11 @@ export function ProjectForm({
|
||||
disabled={
|
||||
loading ||
|
||||
!name.trim() ||
|
||||
(repoMode === 'existing'
|
||||
? !gitRepoPath.trim()
|
||||
: !parentPath.trim() || !folderName.trim())
|
||||
(environment === 'cloud'
|
||||
? !selectedRepository
|
||||
: repoMode === 'existing'
|
||||
? !gitRepoPath.trim()
|
||||
: !parentPath.trim() || !folderName.trim())
|
||||
}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Project'}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import {
|
||||
BranchStatus,
|
||||
Config,
|
||||
ConfigConstants,
|
||||
CreateFollowUpAttempt,
|
||||
CreateProject,
|
||||
CreateProjectFromGitHub,
|
||||
CreateTask,
|
||||
CreateTaskAndStart,
|
||||
CreateTaskAttempt,
|
||||
@@ -64,6 +66,19 @@ export interface DirectoryListResponse {
|
||||
current_path: string;
|
||||
}
|
||||
|
||||
// GitHub Repository Info (manually defined since not exported from Rust yet)
|
||||
export interface RepositoryInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
owner: string;
|
||||
description: string | null;
|
||||
clone_url: string;
|
||||
ssh_url: string;
|
||||
default_branch: string;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -514,6 +529,10 @@ export const configApi = {
|
||||
});
|
||||
return handleApiResponse<Config>(response);
|
||||
},
|
||||
getConstants: async (): Promise<ConfigConstants> => {
|
||||
const response = await makeRequest('/api/config/constants');
|
||||
return handleApiResponse<ConfigConstants>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub Device Auth APIs
|
||||
@@ -547,6 +566,25 @@ export const githubAuthApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// GitHub APIs (only available in cloud mode)
|
||||
export const githubApi = {
|
||||
listRepositories: async (page: number = 1): Promise<RepositoryInfo[]> => {
|
||||
const response = await makeRequest(`/api/github/repositories?page=${page}`);
|
||||
return handleApiResponse<RepositoryInfo[]>(response);
|
||||
},
|
||||
createProjectFromRepository: async (
|
||||
data: CreateProjectFromGitHub
|
||||
): Promise<Project> => {
|
||||
const response = await makeRequest('/api/projects/from-github', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data, (_key, value) =>
|
||||
typeof value === 'bigint' ? Number(value) : value
|
||||
),
|
||||
});
|
||||
return handleApiResponse<Project>(response);
|
||||
},
|
||||
};
|
||||
|
||||
// Task Templates APIs
|
||||
export const templatesApi = {
|
||||
list: async (): Promise<TaskTemplate[]> => {
|
||||
|
||||
Reference in New Issue
Block a user