* The implementation is complete. Here's a summary of all the changes made: ## Summary Added the ability for users to specify a working directory for dev server scripts: ### Files Changed: 1. **Database migration** - `crates/db/migrations/20251216000000_add_dev_script_working_dir_to_projects.sql` - Added `dev_script_working_dir TEXT DEFAULT ''` column 2. **Rust model** - `crates/db/src/models/project.rs` - Added `dev_script_working_dir` field to `Project` and `UpdateProject` structs - Updated all SQL queries 3. **Dev server execution** - `crates/server/src/routes/task_attempts.rs` - `start_dev_server` now passes `project.dev_script_working_dir` to the script executor 4. **Backfill logic** - `crates/services/src/services/container.rs` - Changed `backfill_repo_names` to set `dev_script_working_dir` instead of prepending `cd ./repo &&` 5. **Frontend UI** - `frontend/src/pages/settings/ProjectSettings.tsx` - Added input field for "Dev Server Working Directory" 6. **Translations** - `frontend/src/i18n/locales/en/settings.json` - Added `devWorkingDir` label, placeholder, and helper text 7. **TypeScript types** - `shared/types.ts` - Regenerated with `dev_script_working_dir` field * Cleanup script changes for task attempt 83423e2d-0d45-47fb-a6e4-dd91a2140f91 * Both packages compile successfully. The fix is complete: 1. **`project.rs`**: Removed `.or()` fallback for `dev_script` and `dev_script_working_dir` so `None` now clears the field 2. **`container.rs`**: Updated backfill to explicitly pass the existing project name instead of `None` * Done. I've completed both review tasks: 1. Removed the comment from `container.rs` line 384 2. Added `devWorkingDir` translations to all four language files (es, ja, ko, zh-Hans) with: - `label`: The field label - `placeholder`: Example placeholder text - `helper`: Description of what the field does I also updated the `dev.helper` text in each language to reference the new working directory option instead of suggesting users manually `cd` into the repo directory. * Done. Removed both comments: 1. Removed the comment from the migration file 2. Removed the comment from `task_attempts.rs` line 1267 * Fixed. The TypeScript error in `NoServerContent.tsx` has been resolved by adding `dev_script_working_dir: null` to the `UpdateProject` data. All checks pass: - ✅ `npm run format` - completed - ✅ `npm run lint` - passed (frontend and backend) - ✅ `npx tsc --noEmit` - passed * The Korean and Chinese translations already had the updated copy without the `cd` reference. The Spanish and Japanese were updated. All the translations now have the proper helper text that references the working directory field instead of telling users to manually `cd` into the repo. * Done. I've removed the `cd my_website` line from both the Windows and Unix script placeholder examples in `scriptPlaceholders.ts`. Now the placeholder examples just show `npm run dev` without the `cd` command, since users can specify the working directory separately.
939 lines
32 KiB
TypeScript
939 lines
32 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { isEqual } from 'lodash';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
import { Loader2, Plus, Trash2 } from 'lucide-react';
|
|
import { useProjects } from '@/hooks/useProjects';
|
|
import { useProjectMutations } from '@/hooks/useProjectMutations';
|
|
import { useScriptPlaceholders } from '@/hooks/useScriptPlaceholders';
|
|
import { CopyFilesField } from '@/components/projects/CopyFilesField';
|
|
import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea';
|
|
import { RepoPickerDialog } from '@/components/dialogs/shared/RepoPickerDialog';
|
|
import { projectsApi } from '@/lib/api';
|
|
import { branchKeys } from '@/hooks/useBranches';
|
|
import type { Project, ProjectRepo, Repo, UpdateProject } from 'shared/types';
|
|
|
|
interface ProjectFormState {
|
|
name: string;
|
|
dev_script: string;
|
|
dev_script_working_dir: string;
|
|
}
|
|
|
|
interface RepoScriptsFormState {
|
|
setup_script: string;
|
|
parallel_setup_script: boolean;
|
|
cleanup_script: string;
|
|
copy_files: string;
|
|
}
|
|
|
|
function projectToFormState(project: Project): ProjectFormState {
|
|
return {
|
|
name: project.name,
|
|
dev_script: project.dev_script ?? '',
|
|
dev_script_working_dir: project.dev_script_working_dir ?? '',
|
|
};
|
|
}
|
|
|
|
function projectRepoToScriptsFormState(
|
|
projectRepo: ProjectRepo | null
|
|
): RepoScriptsFormState {
|
|
return {
|
|
setup_script: projectRepo?.setup_script ?? '',
|
|
parallel_setup_script: projectRepo?.parallel_setup_script ?? false,
|
|
cleanup_script: projectRepo?.cleanup_script ?? '',
|
|
copy_files: projectRepo?.copy_files ?? '',
|
|
};
|
|
}
|
|
|
|
export function ProjectSettings() {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const projectIdParam = searchParams.get('projectId') ?? '';
|
|
const { t } = useTranslation('settings');
|
|
const queryClient = useQueryClient();
|
|
|
|
// Fetch all projects
|
|
const {
|
|
data: projects,
|
|
isLoading: projectsLoading,
|
|
error: projectsError,
|
|
} = useProjects();
|
|
|
|
// Selected project state
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string>(
|
|
searchParams.get('projectId') || ''
|
|
);
|
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
|
|
|
// Form state
|
|
const [draft, setDraft] = useState<ProjectFormState | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
// Repositories state
|
|
const [repositories, setRepositories] = useState<Repo[]>([]);
|
|
const [loadingRepos, setLoadingRepos] = useState(false);
|
|
const [repoError, setRepoError] = useState<string | null>(null);
|
|
const [addingRepo, setAddingRepo] = useState(false);
|
|
const [deletingRepoId, setDeletingRepoId] = useState<string | null>(null);
|
|
|
|
// Scripts repo state (per-repo scripts)
|
|
const [selectedScriptsRepoId, setSelectedScriptsRepoId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selectedProjectRepo, setSelectedProjectRepo] =
|
|
useState<ProjectRepo | null>(null);
|
|
const [scriptsDraft, setScriptsDraft] = useState<RepoScriptsFormState | null>(
|
|
null
|
|
);
|
|
const [loadingProjectRepo, setLoadingProjectRepo] = useState(false);
|
|
const [savingScripts, setSavingScripts] = useState(false);
|
|
const [scriptsSuccess, setScriptsSuccess] = useState(false);
|
|
const [scriptsError, setScriptsError] = useState<string | null>(null);
|
|
|
|
// Get OS-appropriate script placeholders
|
|
const placeholders = useScriptPlaceholders();
|
|
|
|
// Check for unsaved changes (project name)
|
|
const hasUnsavedProjectChanges = useMemo(() => {
|
|
if (!draft || !selectedProject) return false;
|
|
return !isEqual(draft, projectToFormState(selectedProject));
|
|
}, [draft, selectedProject]);
|
|
|
|
// Check for unsaved script changes
|
|
const hasUnsavedScriptsChanges = useMemo(() => {
|
|
if (!scriptsDraft || !selectedProjectRepo) return false;
|
|
return !isEqual(
|
|
scriptsDraft,
|
|
projectRepoToScriptsFormState(selectedProjectRepo)
|
|
);
|
|
}, [scriptsDraft, selectedProjectRepo]);
|
|
|
|
// Combined check for any unsaved changes
|
|
const hasUnsavedChanges =
|
|
hasUnsavedProjectChanges || hasUnsavedScriptsChanges;
|
|
|
|
// Handle project selection from dropdown
|
|
const handleProjectSelect = useCallback(
|
|
(id: string) => {
|
|
// No-op if same project
|
|
if (id === selectedProjectId) return;
|
|
|
|
// Confirm if there are unsaved changes
|
|
if (hasUnsavedChanges) {
|
|
const confirmed = window.confirm(
|
|
t('settings.projects.save.confirmSwitch')
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
// Clear local state before switching
|
|
setDraft(null);
|
|
setSelectedProject(null);
|
|
setSuccess(false);
|
|
setError(null);
|
|
}
|
|
|
|
// Update state and URL
|
|
setSelectedProjectId(id);
|
|
if (id) {
|
|
setSearchParams({ projectId: id });
|
|
} else {
|
|
setSearchParams({});
|
|
}
|
|
},
|
|
[hasUnsavedChanges, selectedProjectId, setSearchParams, t]
|
|
);
|
|
|
|
// Sync selectedProjectId when URL changes (with unsaved changes prompt)
|
|
useEffect(() => {
|
|
if (projectIdParam === selectedProjectId) return;
|
|
|
|
// Confirm if there are unsaved changes
|
|
if (hasUnsavedChanges) {
|
|
const confirmed = window.confirm(
|
|
t('settings.projects.save.confirmSwitch')
|
|
);
|
|
if (!confirmed) {
|
|
// Revert URL to previous value
|
|
if (selectedProjectId) {
|
|
setSearchParams({ projectId: selectedProjectId });
|
|
} else {
|
|
setSearchParams({});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Clear local state before switching
|
|
setDraft(null);
|
|
setSelectedProject(null);
|
|
setSuccess(false);
|
|
setError(null);
|
|
}
|
|
|
|
setSelectedProjectId(projectIdParam);
|
|
}, [
|
|
projectIdParam,
|
|
hasUnsavedChanges,
|
|
selectedProjectId,
|
|
setSearchParams,
|
|
t,
|
|
]);
|
|
|
|
// Populate draft from server data
|
|
useEffect(() => {
|
|
if (!projects) return;
|
|
|
|
const nextProject = selectedProjectId
|
|
? projects.find((p) => p.id === selectedProjectId)
|
|
: null;
|
|
|
|
setSelectedProject((prev) =>
|
|
prev?.id === nextProject?.id ? prev : (nextProject ?? null)
|
|
);
|
|
|
|
if (!nextProject) {
|
|
if (!hasUnsavedChanges) setDraft(null);
|
|
return;
|
|
}
|
|
|
|
if (hasUnsavedChanges) return;
|
|
|
|
setDraft(projectToFormState(nextProject));
|
|
}, [projects, selectedProjectId, hasUnsavedChanges]);
|
|
|
|
// Warn on tab close/navigation with unsaved changes
|
|
useEffect(() => {
|
|
const handler = (e: BeforeUnloadEvent) => {
|
|
if (hasUnsavedChanges) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
};
|
|
window.addEventListener('beforeunload', handler);
|
|
return () => window.removeEventListener('beforeunload', handler);
|
|
}, [hasUnsavedChanges]);
|
|
|
|
// Fetch repositories when project changes
|
|
useEffect(() => {
|
|
if (!selectedProjectId) {
|
|
setRepositories([]);
|
|
return;
|
|
}
|
|
|
|
setLoadingRepos(true);
|
|
setRepoError(null);
|
|
projectsApi
|
|
.getRepositories(selectedProjectId)
|
|
.then(setRepositories)
|
|
.catch((err) => {
|
|
setRepoError(
|
|
err instanceof Error ? err.message : 'Failed to load repositories'
|
|
);
|
|
setRepositories([]);
|
|
})
|
|
.finally(() => setLoadingRepos(false));
|
|
}, [selectedProjectId]);
|
|
|
|
// Auto-select first repository for scripts when repositories load
|
|
useEffect(() => {
|
|
if (repositories.length > 0 && !selectedScriptsRepoId) {
|
|
setSelectedScriptsRepoId(repositories[0].id);
|
|
}
|
|
// Clear selection if repo was deleted
|
|
if (
|
|
selectedScriptsRepoId &&
|
|
!repositories.some((r) => r.id === selectedScriptsRepoId)
|
|
) {
|
|
setSelectedScriptsRepoId(repositories[0]?.id ?? null);
|
|
}
|
|
}, [repositories, selectedScriptsRepoId]);
|
|
|
|
// Reset scripts selection when project changes
|
|
useEffect(() => {
|
|
setSelectedScriptsRepoId(null);
|
|
setSelectedProjectRepo(null);
|
|
setScriptsDraft(null);
|
|
setScriptsError(null);
|
|
}, [selectedProjectId]);
|
|
|
|
// Fetch ProjectRepo scripts when selected scripts repo changes
|
|
useEffect(() => {
|
|
if (!selectedProjectId || !selectedScriptsRepoId) {
|
|
setSelectedProjectRepo(null);
|
|
setScriptsDraft(null);
|
|
return;
|
|
}
|
|
|
|
setLoadingProjectRepo(true);
|
|
setScriptsError(null);
|
|
projectsApi
|
|
.getRepository(selectedProjectId, selectedScriptsRepoId)
|
|
.then((projectRepo) => {
|
|
setSelectedProjectRepo(projectRepo);
|
|
setScriptsDraft(projectRepoToScriptsFormState(projectRepo));
|
|
})
|
|
.catch((err) => {
|
|
setScriptsError(
|
|
err instanceof Error
|
|
? err.message
|
|
: 'Failed to load repository scripts'
|
|
);
|
|
setSelectedProjectRepo(null);
|
|
setScriptsDraft(null);
|
|
})
|
|
.finally(() => setLoadingProjectRepo(false));
|
|
}, [selectedProjectId, selectedScriptsRepoId]);
|
|
|
|
const handleAddRepository = async () => {
|
|
if (!selectedProjectId) return;
|
|
|
|
const repo = await RepoPickerDialog.show({
|
|
title: 'Select Git Repository',
|
|
description: 'Choose a git repository to add to this project',
|
|
});
|
|
|
|
if (!repo) return;
|
|
|
|
if (repositories.some((r) => r.id === repo.id)) {
|
|
return;
|
|
}
|
|
|
|
setAddingRepo(true);
|
|
setRepoError(null);
|
|
try {
|
|
const newRepo = await projectsApi.addRepository(selectedProjectId, {
|
|
display_name: repo.display_name,
|
|
git_repo_path: repo.path,
|
|
});
|
|
setRepositories((prev) => [...prev, newRepo]);
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['projectRepositories', selectedProjectId],
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: branchKeys.byProject(selectedProjectId),
|
|
});
|
|
} catch (err) {
|
|
setRepoError(
|
|
err instanceof Error ? err.message : 'Failed to add repository'
|
|
);
|
|
} finally {
|
|
setAddingRepo(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteRepository = async (repoId: string) => {
|
|
if (!selectedProjectId) return;
|
|
|
|
setDeletingRepoId(repoId);
|
|
setRepoError(null);
|
|
try {
|
|
await projectsApi.deleteRepository(selectedProjectId, repoId);
|
|
setRepositories((prev) => prev.filter((r) => r.id !== repoId));
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['projectRepositories', selectedProjectId],
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: branchKeys.byProject(selectedProjectId),
|
|
});
|
|
} catch (err) {
|
|
setRepoError(
|
|
err instanceof Error ? err.message : 'Failed to delete repository'
|
|
);
|
|
} finally {
|
|
setDeletingRepoId(null);
|
|
}
|
|
};
|
|
|
|
const { updateProject } = useProjectMutations({
|
|
onUpdateSuccess: (updatedProject: Project) => {
|
|
// Update local state with fresh data from server
|
|
setSelectedProject(updatedProject);
|
|
setDraft(projectToFormState(updatedProject));
|
|
setSuccess(true);
|
|
setTimeout(() => setSuccess(false), 3000);
|
|
setSaving(false);
|
|
},
|
|
onUpdateError: (err) => {
|
|
setError(
|
|
err instanceof Error ? err.message : 'Failed to save project settings'
|
|
);
|
|
setSaving(false);
|
|
},
|
|
});
|
|
|
|
const handleSave = async () => {
|
|
if (!draft || !selectedProject) return;
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
setSuccess(false);
|
|
|
|
try {
|
|
const updateData: UpdateProject = {
|
|
name: draft.name.trim(),
|
|
dev_script: draft.dev_script.trim() || null,
|
|
dev_script_working_dir: draft.dev_script_working_dir.trim() || null,
|
|
};
|
|
|
|
updateProject.mutate({
|
|
projectId: selectedProject.id,
|
|
data: updateData,
|
|
});
|
|
} catch (err) {
|
|
setError(t('settings.projects.save.error'));
|
|
console.error('Error saving project settings:', err);
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveScripts = async () => {
|
|
if (!scriptsDraft || !selectedProjectId || !selectedScriptsRepoId) return;
|
|
|
|
setSavingScripts(true);
|
|
setScriptsError(null);
|
|
setScriptsSuccess(false);
|
|
|
|
try {
|
|
const updatedRepo = await projectsApi.updateRepository(
|
|
selectedProjectId,
|
|
selectedScriptsRepoId,
|
|
{
|
|
setup_script: scriptsDraft.setup_script.trim() || null,
|
|
cleanup_script: scriptsDraft.cleanup_script.trim() || null,
|
|
copy_files: scriptsDraft.copy_files.trim() || null,
|
|
parallel_setup_script: scriptsDraft.parallel_setup_script,
|
|
}
|
|
);
|
|
setSelectedProjectRepo(updatedRepo);
|
|
setScriptsDraft(projectRepoToScriptsFormState(updatedRepo));
|
|
setScriptsSuccess(true);
|
|
setTimeout(() => setScriptsSuccess(false), 3000);
|
|
} catch (err) {
|
|
setScriptsError(
|
|
err instanceof Error ? err.message : 'Failed to save scripts'
|
|
);
|
|
} finally {
|
|
setSavingScripts(false);
|
|
}
|
|
};
|
|
|
|
const handleDiscard = () => {
|
|
if (!selectedProject) return;
|
|
setDraft(projectToFormState(selectedProject));
|
|
};
|
|
|
|
const handleDiscardScripts = () => {
|
|
if (!selectedProjectRepo) return;
|
|
setScriptsDraft(projectRepoToScriptsFormState(selectedProjectRepo));
|
|
};
|
|
|
|
const updateDraft = (updates: Partial<ProjectFormState>) => {
|
|
setDraft((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, ...updates };
|
|
});
|
|
};
|
|
|
|
const updateScriptsDraft = (updates: Partial<RepoScriptsFormState>) => {
|
|
setScriptsDraft((prev) => {
|
|
if (!prev) return prev;
|
|
return { ...prev, ...updates };
|
|
});
|
|
};
|
|
|
|
if (projectsLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-8 w-8 animate-spin" />
|
|
<span className="ml-2">{t('settings.projects.loading')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (projectsError) {
|
|
return (
|
|
<div className="py-8">
|
|
<Alert variant="destructive">
|
|
<AlertDescription>
|
|
{projectsError instanceof Error
|
|
? projectsError.message
|
|
: t('settings.projects.loadError')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{success && (
|
|
<Alert variant="success">
|
|
<AlertDescription className="font-medium">
|
|
{t('settings.projects.save.success')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('settings.projects.title')}</CardTitle>
|
|
<CardDescription>
|
|
{t('settings.projects.description')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project-selector">
|
|
{t('settings.projects.selector.label')}
|
|
</Label>
|
|
<Select
|
|
value={selectedProjectId}
|
|
onValueChange={handleProjectSelect}
|
|
>
|
|
<SelectTrigger id="project-selector">
|
|
<SelectValue
|
|
placeholder={t('settings.projects.selector.placeholder')}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{projects && projects.length > 0 ? (
|
|
projects.map((project) => (
|
|
<SelectItem key={project.id} value={project.id}>
|
|
{project.name}
|
|
</SelectItem>
|
|
))
|
|
) : (
|
|
<SelectItem value="no-projects" disabled>
|
|
{t('settings.projects.selector.noProjects')}
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.projects.selector.helper')}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{selectedProject && draft && (
|
|
<>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('settings.projects.general.title')}</CardTitle>
|
|
<CardDescription>
|
|
{t('settings.projects.general.description')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="project-name">
|
|
{t('settings.projects.general.name.label')}
|
|
</Label>
|
|
<Input
|
|
id="project-name"
|
|
type="text"
|
|
value={draft.name}
|
|
onChange={(e) => updateDraft({ name: e.target.value })}
|
|
placeholder={t('settings.projects.general.name.placeholder')}
|
|
required
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.projects.general.name.helper')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="dev-script">
|
|
{t('settings.projects.scripts.dev.label')}
|
|
</Label>
|
|
<AutoExpandingTextarea
|
|
id="dev-script"
|
|
value={draft.dev_script}
|
|
onChange={(e) => updateDraft({ dev_script: e.target.value })}
|
|
placeholder={placeholders.dev}
|
|
maxRows={12}
|
|
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.projects.scripts.dev.helper')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="dev-script-working-dir">
|
|
{t('settings.projects.scripts.devWorkingDir.label')}
|
|
</Label>
|
|
<Input
|
|
id="dev-script-working-dir"
|
|
value={draft.dev_script_working_dir}
|
|
onChange={(e) =>
|
|
updateDraft({ dev_script_working_dir: e.target.value })
|
|
}
|
|
placeholder={t(
|
|
'settings.projects.scripts.devWorkingDir.placeholder'
|
|
)}
|
|
className="font-mono"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.projects.scripts.devWorkingDir.helper')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Save Button */}
|
|
<div className="flex items-center justify-between pt-4 border-t">
|
|
{hasUnsavedProjectChanges ? (
|
|
<span className="text-sm text-muted-foreground">
|
|
{t('settings.projects.save.unsavedChanges')}
|
|
</span>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleDiscard}
|
|
disabled={saving || !hasUnsavedProjectChanges}
|
|
>
|
|
{t('settings.projects.save.discard')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving || !hasUnsavedProjectChanges}
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('settings.projects.save.saving')}
|
|
</>
|
|
) : (
|
|
t('settings.projects.save.button')
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{success && (
|
|
<Alert>
|
|
<AlertDescription>
|
|
{t('settings.projects.save.success')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Repositories Section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Repositories</CardTitle>
|
|
<CardDescription>
|
|
Manage the git repositories in this project
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{repoError && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{repoError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{loadingRepos ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
<span className="ml-2 text-sm text-muted-foreground">
|
|
Loading repositories...
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{repositories.map((repo) => (
|
|
<div
|
|
key={repo.id}
|
|
className="flex items-center justify-between p-3 border rounded-md"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium">{repo.display_name}</div>
|
|
<div className="text-sm text-muted-foreground truncate">
|
|
{repo.path}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteRepository(repo.id)}
|
|
disabled={deletingRepoId === repo.id}
|
|
title="Delete repository"
|
|
>
|
|
{deletingRepoId === repo.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
{repositories.length === 0 && !loadingRepos && (
|
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
No repositories configured
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleAddRepository}
|
|
disabled={addingRepo}
|
|
className="w-full"
|
|
>
|
|
{addingRepo ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
)}
|
|
Add Repository
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('settings.projects.scripts.title')}</CardTitle>
|
|
<CardDescription>
|
|
{t('settings.projects.scripts.description')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{scriptsError && (
|
|
<Alert variant="destructive">
|
|
<AlertDescription>{scriptsError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{scriptsSuccess && (
|
|
<Alert variant="success">
|
|
<AlertDescription className="font-medium">
|
|
Scripts saved successfully
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{repositories.length === 0 ? (
|
|
<div className="text-center py-4 text-sm text-muted-foreground">
|
|
Add a repository above to configure scripts
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Repository Selector for Scripts */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="scripts-repo-selector">Repository</Label>
|
|
<Select
|
|
value={selectedScriptsRepoId ?? ''}
|
|
onValueChange={setSelectedScriptsRepoId}
|
|
>
|
|
<SelectTrigger id="scripts-repo-selector">
|
|
<SelectValue placeholder="Select a repository" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{repositories.map((repo) => (
|
|
<SelectItem key={repo.id} value={repo.id}>
|
|
{repo.display_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-sm text-muted-foreground">
|
|
Configure scripts for each repository separately
|
|
</p>
|
|
</div>
|
|
|
|
{loadingProjectRepo ? (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
<span className="ml-2 text-sm text-muted-foreground">
|
|
Loading scripts...
|
|
</span>
|
|
</div>
|
|
) : scriptsDraft ? (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="setup-script">
|
|
{t('settings.projects.scripts.setup.label')}
|
|
</Label>
|
|
<AutoExpandingTextarea
|
|
id="setup-script"
|
|
value={scriptsDraft.setup_script}
|
|
onChange={(e) =>
|
|
updateScriptsDraft({ setup_script: e.target.value })
|
|
}
|
|
placeholder={placeholders.setup}
|
|
maxRows={12}
|
|
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.projects.scripts.setup.helper')}
|
|
</p>
|
|
|
|
<div className="flex items-center space-x-2 pt-2">
|
|
<Checkbox
|
|
id="parallel-setup-script"
|
|
checked={scriptsDraft.parallel_setup_script}
|
|
onCheckedChange={(checked) =>
|
|
updateScriptsDraft({
|
|
parallel_setup_script: checked === true,
|
|
})
|
|
}
|
|
disabled={!scriptsDraft.setup_script.trim()}
|
|
/>
|
|
<Label
|
|
htmlFor="parallel-setup-script"
|
|
className="text-sm font-normal cursor-pointer"
|
|
>
|
|
{t('settings.projects.scripts.setup.parallelLabel')}
|
|
</Label>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground pl-6">
|
|
{t('settings.projects.scripts.setup.parallelHelper')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="cleanup-script">
|
|
{t('settings.projects.scripts.cleanup.label')}
|
|
</Label>
|
|
<AutoExpandingTextarea
|
|
id="cleanup-script"
|
|
value={scriptsDraft.cleanup_script}
|
|
onChange={(e) =>
|
|
updateScriptsDraft({
|
|
cleanup_script: e.target.value,
|
|
})
|
|
}
|
|
placeholder={placeholders.cleanup}
|
|
maxRows={12}
|
|
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md focus:outline-none focus:ring-2 focus:ring-ring font-mono"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.projects.scripts.cleanup.helper')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>
|
|
{t('settings.projects.scripts.copyFiles.label')}
|
|
</Label>
|
|
<CopyFilesField
|
|
value={scriptsDraft.copy_files}
|
|
onChange={(value) =>
|
|
updateScriptsDraft({ copy_files: value })
|
|
}
|
|
projectId={selectedProject.id}
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('settings.projects.scripts.copyFiles.helper')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Scripts Save Buttons */}
|
|
<div className="flex items-center justify-between pt-4 border-t">
|
|
{hasUnsavedScriptsChanges ? (
|
|
<span className="text-sm text-muted-foreground">
|
|
{t('settings.projects.save.unsavedChanges')}
|
|
</span>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleDiscardScripts}
|
|
disabled={
|
|
!hasUnsavedScriptsChanges || savingScripts
|
|
}
|
|
>
|
|
{t('settings.projects.save.discard')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleSaveScripts}
|
|
disabled={
|
|
!hasUnsavedScriptsChanges || savingScripts
|
|
}
|
|
>
|
|
{savingScripts && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
Save Scripts
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Sticky Save Button for Project Name */}
|
|
{hasUnsavedProjectChanges && (
|
|
<div className="sticky bottom-0 z-10 bg-background/80 backdrop-blur-sm border-t py-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">
|
|
{t('settings.projects.save.unsavedChanges')}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleDiscard}
|
|
disabled={saving}
|
|
>
|
|
{t('settings.projects.save.discard')}
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving && (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
)}
|
|
{t('settings.projects.save.button')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|