feat: improve repository list setting (#65)
This commit is contained in:
@@ -2,6 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/editors/models"
|
||||
)
|
||||
@@ -16,6 +17,8 @@ type EditorDTO struct {
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
Installed bool `json:"installed"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type EditorsResponse struct {
|
||||
@@ -48,6 +51,8 @@ func FromEditor(editor *models.Editor) EditorDTO {
|
||||
Config: editor.Config,
|
||||
Installed: editor.Installed,
|
||||
Enabled: editor.Enabled,
|
||||
CreatedAt: editor.CreatedAt,
|
||||
UpdatedAt: editor.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2239,9 +2239,9 @@ func (r *sqliteRepository) HasActiveTaskSessionsByRepository(ctx context.Context
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT 1
|
||||
FROM task_sessions s
|
||||
INNER JOIN tasks t ON t.id = s.task_id
|
||||
INNER JOIN task_repositories tr ON tr.task_id = s.task_id
|
||||
WHERE s.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
|
||||
AND t.repository_id = ?
|
||||
AND tr.repository_id = ?
|
||||
LIMIT 1
|
||||
`, repositoryID).Scan(&exists)
|
||||
if err == sql.ErrNoRows {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useRequest } from '@/lib/http/use-request';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
|
||||
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
|
||||
type RepositoryItem = RepositoryWithScripts & { __autoOpen?: boolean };
|
||||
|
||||
type WorkspaceRepositoriesClientProps = {
|
||||
workspace: Workspace | null;
|
||||
@@ -47,11 +48,10 @@ export function WorkspaceRepositoriesClient({
|
||||
}: WorkspaceRepositoriesClientProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [repositoryItems, setRepositoryItems] = useState<RepositoryWithScripts[]>(repositories);
|
||||
const [repositoryItems, setRepositoryItems] = useState<RepositoryItem[]>(repositories);
|
||||
const [savedRepositoryItems, setSavedRepositoryItems] = useState<RepositoryWithScripts[]>(repositories);
|
||||
const [discoveredRepositories, setDiscoveredRepositories] = useState<LocalRepository[]>([]);
|
||||
const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false);
|
||||
const [remoteRepoDialogOpen, setRemoteRepoDialogOpen] = useState(false);
|
||||
const [repoSearch, setRepoSearch] = useState('');
|
||||
const [selectedRepoPath, setSelectedRepoPath] = useState<string | null>(null);
|
||||
const [manualRepoPath, setManualRepoPath] = useState('');
|
||||
@@ -84,7 +84,7 @@ export function WorkspaceRepositoriesClient({
|
||||
const canSaveLocalRepo =
|
||||
Boolean(selectedRepoPath) || (manualValidation.status === 'success' && manualValidation.isValid);
|
||||
|
||||
const isRepositoryDirty = (repo: RepositoryWithScripts) => {
|
||||
const isRepositoryDirty = (repo: RepositoryItem) => {
|
||||
const saved = savedRepositoriesById.get(repo.id);
|
||||
if (!saved) return true;
|
||||
return (
|
||||
@@ -102,7 +102,7 @@ export function WorkspaceRepositoriesClient({
|
||||
);
|
||||
};
|
||||
|
||||
const areRepositoryScriptsDirty = (repo: RepositoryWithScripts) => {
|
||||
const areRepositoryScriptsDirty = (repo: RepositoryItem) => {
|
||||
const saved = savedRepositoriesById.get(repo.id);
|
||||
if (!saved) return repo.scripts.length > 0;
|
||||
if (repo.scripts.length !== saved.scripts.length) return true;
|
||||
@@ -186,7 +186,7 @@ export function WorkspaceRepositoriesClient({
|
||||
if (!path) return;
|
||||
const name = selectedRepo?.name || path.split('/').filter(Boolean).slice(-1)[0] || 'New Repository';
|
||||
const draftId = `temp-repo-${generateUUID()}`;
|
||||
const draftRepo: RepositoryWithScripts = {
|
||||
const draftRepo: RepositoryItem = {
|
||||
id: draftId,
|
||||
workspace_id: workspace.id,
|
||||
name,
|
||||
@@ -203,6 +203,7 @@ export function WorkspaceRepositoriesClient({
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
scripts: [],
|
||||
__autoOpen: true,
|
||||
};
|
||||
setRepositoryItems((prev) => [draftRepo, ...prev]);
|
||||
setLocalRepoDialogOpen(false);
|
||||
@@ -386,11 +387,8 @@ export function WorkspaceRepositoriesClient({
|
||||
description="Repositories in this workspace"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openLocalRepositoryDialog}>
|
||||
Local Repository
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setRemoteRepoDialogOpen(true)}>
|
||||
Remote Repository
|
||||
<Button size="sm" className="cursor-pointer" onClick={openLocalRepositoryDialog}>
|
||||
Add Local Repository
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -402,6 +400,7 @@ export function WorkspaceRepositoriesClient({
|
||||
repository={repo}
|
||||
isRepositoryDirty={isRepositoryDirty(repo)}
|
||||
areScriptsDirty={areRepositoryScriptsDirty(repo)}
|
||||
autoOpen={Boolean(repo.__autoOpen)}
|
||||
onUpdate={handleUpdateRepository}
|
||||
onAddScript={handleAddRepositoryScript}
|
||||
onUpdateScript={handleUpdateRepositoryScript}
|
||||
@@ -499,30 +498,6 @@ export function WorkspaceRepositoriesClient({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={remoteRepoDialogOpen} onOpenChange={setRemoteRepoDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Remote Repository</DialogTitle>
|
||||
<DialogDescription>Remote providers are coming soon.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button type="button" variant="outline" className="w-full justify-start" disabled>
|
||||
GitHub (coming soon)
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="w-full justify-start" disabled>
|
||||
GitLab (coming soon)
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="w-full justify-start" disabled>
|
||||
Bitbucket (coming soon)
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRemoteRepoDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
type EditableCardRenderArgs = {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
type EditableCardProps = {
|
||||
isEditing: boolean;
|
||||
historyId: string;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
renderPreview: (args: Pick<EditableCardRenderArgs, 'open'>) => ReactNode;
|
||||
renderEdit: (args: Pick<EditableCardRenderArgs, 'close'>) => ReactNode;
|
||||
};
|
||||
|
||||
export function EditableCard({
|
||||
isEditing,
|
||||
historyId,
|
||||
onOpen,
|
||||
onClose,
|
||||
renderPreview,
|
||||
renderEdit,
|
||||
}: EditableCardProps) {
|
||||
const historyPushedRef = useRef(false);
|
||||
const closingFromPopRef = useRef(false);
|
||||
|
||||
const open = useCallback(() => {
|
||||
onOpen();
|
||||
}, [onOpen]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing || typeof window === 'undefined') return;
|
||||
|
||||
if (!historyPushedRef.current) {
|
||||
window.history.pushState({ ...window.history.state, editableCardId: historyId }, '');
|
||||
historyPushedRef.current = true;
|
||||
} else {
|
||||
window.history.replaceState({ ...window.history.state, editableCardId: historyId }, '');
|
||||
}
|
||||
|
||||
const handlePopState = () => {
|
||||
if (!historyPushedRef.current) return;
|
||||
closingFromPopRef.current = true;
|
||||
historyPushedRef.current = false;
|
||||
onClose();
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, [historyId, isEditing, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing || typeof window === 'undefined') return;
|
||||
if (closingFromPopRef.current) {
|
||||
closingFromPopRef.current = false;
|
||||
historyPushedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!historyPushedRef.current) return;
|
||||
historyPushedRef.current = false;
|
||||
window.history.back();
|
||||
}, [isEditing]);
|
||||
|
||||
return isEditing ? renderEdit({ close }) : renderPreview({ open });
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { SettingsPageTemplate } from '@/components/settings/settings-page-template';
|
||||
import { Combobox, type ComboboxOption } from '@/components/combobox';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { EditableCard } from '@/components/settings/editable-card';
|
||||
import { useEditors } from '@/hooks/use-editors';
|
||||
import { createEditor, deleteEditor, updateEditor, updateUserSettings } from '@/lib/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
@@ -287,7 +288,19 @@ export function EditorsSettings() {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const customEditors = useMemo(() => editors.filter(isCustomEditor), [editors]);
|
||||
const customEditors = useMemo(() => {
|
||||
const items = editors.filter(isCustomEditor);
|
||||
return items.slice().sort((a, b) => {
|
||||
const createdA = a.created_at ? Date.parse(a.created_at) : 0;
|
||||
const createdB = b.created_at ? Date.parse(b.created_at) : 0;
|
||||
if (createdA !== createdB) return createdB - createdA;
|
||||
const nameA = (a.name || '').toLowerCase();
|
||||
const nameB = (b.name || '').toLowerCase();
|
||||
if (nameA < nameB) return -1;
|
||||
if (nameA > nameB) return 1;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}, [editors]);
|
||||
const builtInEditors = useMemo(() => editors.filter((editor) => !isCustomEditor(editor)), [editors]);
|
||||
const availableEditors = useMemo(() => resolveAvailableEditors(editors), [editors]);
|
||||
|
||||
@@ -295,7 +308,6 @@ export function EditorsSettings() {
|
||||
(updater: EditorOption[] | ((prev: EditorOption[]) => EditorOption[])) => {
|
||||
setEditorItems((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
setEditors(next);
|
||||
const resolvedDefault = resolveDefaultEditorId(next, defaultEditorId);
|
||||
if (resolvedDefault !== defaultEditorId) {
|
||||
setDefaultEditorId(resolvedDefault);
|
||||
@@ -304,7 +316,7 @@ export function EditorsSettings() {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[defaultEditorId, setEditors]
|
||||
[defaultEditorId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -391,7 +403,6 @@ export function EditorsSettings() {
|
||||
applyEditors((prev: EditorOption[]) =>
|
||||
prev.map((editor: EditorOption) => (editor.id === editorId ? updated : editor))
|
||||
);
|
||||
setEditingId(null);
|
||||
});
|
||||
|
||||
const deleteRequest = useRequest(async (editorId: string) => {
|
||||
@@ -410,8 +421,6 @@ export function EditorsSettings() {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<SettingsPageTemplate
|
||||
title="Editors"
|
||||
@@ -471,57 +480,71 @@ export function EditorsSettings() {
|
||||
</div>
|
||||
)}
|
||||
{customEditors.map((editor) => {
|
||||
if (editingId === editor.id) {
|
||||
return (
|
||||
<EditorForm
|
||||
key={editor.id}
|
||||
title={`Edit ${editor.name}`}
|
||||
initialState={formStateFromEditor(editor)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
onSave={(state) => {
|
||||
void updateRequest.run(editor.id, state);
|
||||
}}
|
||||
submitLabel="Save changes"
|
||||
isSaving={updateRequest.isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<EditableCard
|
||||
key={editor.id}
|
||||
className="rounded-lg border border-border/70 bg-background p-4 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate">{editor.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{getCustomEditorSummary(editor)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setEditingId(editor.id)}
|
||||
>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
void deleteRequest.run(editor.id);
|
||||
isEditing={editingId === editor.id}
|
||||
historyId={`editor-${editor.id}`}
|
||||
onOpen={() => setEditingId(editor.id)}
|
||||
onClose={() => setEditingId(null)}
|
||||
renderEdit={({ close }) => (
|
||||
<EditorForm
|
||||
title={`Edit ${editor.name}`}
|
||||
initialState={formStateFromEditor(editor)}
|
||||
onCancel={close}
|
||||
onSave={(state) => {
|
||||
void updateRequest.run(editor.id, state).then(() => {
|
||||
close();
|
||||
});
|
||||
}}
|
||||
submitLabel="Save changes"
|
||||
isSaving={updateRequest.isLoading}
|
||||
/>
|
||||
)}
|
||||
renderPreview={({ open }) => (
|
||||
<div
|
||||
className="rounded-lg border border-border/70 bg-background p-4 flex items-center justify-between gap-3 cursor-pointer"
|
||||
onClick={open}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate">
|
||||
{editor.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{getCustomEditorSummary(editor)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void deleteRequest.run(editor.id);
|
||||
}}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconGitBranch, IconPlus, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { IconEdit, IconGitBranch, IconPlus, IconTrash, IconX } from '@tabler/icons-react';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
import { EditableCard } from '@/components/settings/editable-card';
|
||||
import type { Repository, RepositoryScript } from '@/lib/types/http';
|
||||
|
||||
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
|
||||
@@ -26,6 +28,7 @@ type RepositoryCardProps = {
|
||||
repository: RepositoryWithScripts;
|
||||
isRepositoryDirty: boolean;
|
||||
areScriptsDirty: boolean;
|
||||
autoOpen?: boolean;
|
||||
onUpdate: (repoId: string, updates: Partial<Repository>) => void;
|
||||
onAddScript: (repoId: string) => void;
|
||||
onUpdateScript: (repoId: string, scriptId: string, updates: Partial<RepositoryScript>) => void;
|
||||
@@ -38,6 +41,7 @@ export function RepositoryCard({
|
||||
repository,
|
||||
isRepositoryDirty,
|
||||
areScriptsDirty,
|
||||
autoOpen = false,
|
||||
onUpdate,
|
||||
onAddScript,
|
||||
onUpdateScript,
|
||||
@@ -47,6 +51,7 @@ export function RepositoryCard({
|
||||
}: RepositoryCardProps) {
|
||||
const { toast } = useToast();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(() => autoOpen);
|
||||
const saveRequest = useRequest(() => onSave(repository.id));
|
||||
const deleteRequest = useRequest(async () => { await onDelete(repository.id); });
|
||||
const repositoryName = repository.name ?? '';
|
||||
@@ -54,10 +59,24 @@ export function RepositoryCard({
|
||||
const worktreeBranchPrefix = repository.worktree_branch_prefix ?? '';
|
||||
const setupScript = repository.setup_script ?? '';
|
||||
const cleanupScript = repository.cleanup_script ?? '';
|
||||
const isDirty = isRepositoryDirty || areScriptsDirty;
|
||||
const scriptsCount = repository.scripts.length;
|
||||
const hasSetupScript = Boolean(setupScript.trim());
|
||||
const hasCleanupScript = Boolean(cleanupScript.trim());
|
||||
const showScriptsSummary = scriptsCount > 0 || hasSetupScript || hasCleanupScript;
|
||||
const scriptsLabel = scriptsCount === 0
|
||||
? 'No custom scripts'
|
||||
: `${scriptsCount} custom script${scriptsCount === 1 ? '' : 's'}`;
|
||||
const sourceLabel = repository.source_type === 'local' ? 'Local' : 'Remote';
|
||||
const subtitle = repository.source_type === 'local'
|
||||
? repositoryLocalPath || 'Local path not set'
|
||||
: [repository.provider_owner, repository.provider_name].filter(Boolean).join('/') ||
|
||||
repository.provider || 'Remote repository';
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (close: () => void) => {
|
||||
try {
|
||||
await saveRequest.run();
|
||||
close();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to save repository',
|
||||
@@ -71,6 +90,7 @@ export function RepositoryCard({
|
||||
try {
|
||||
await deleteRequest.run();
|
||||
setDeleteOpen(false);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete repository',
|
||||
@@ -81,150 +101,239 @@ export function RepositoryCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconGitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="flex items-center gap-2">
|
||||
<span>Repository</span>
|
||||
{isRepositoryDirty && <UnsavedChangesBadge />}
|
||||
</Label>
|
||||
</div>
|
||||
<UnsavedSaveButton
|
||||
isDirty={isRepositoryDirty || areScriptsDirty}
|
||||
isLoading={saveRequest.isLoading}
|
||||
status={saveRequest.status}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<EditableCard
|
||||
isEditing={isEditing}
|
||||
historyId={`repo-${repository.id}`}
|
||||
onOpen={() => setIsEditing(true)}
|
||||
onClose={() => setIsEditing(false)}
|
||||
renderEdit={({ close }) => (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconGitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="flex items-center gap-2">
|
||||
<span>Repository</span>
|
||||
{isDirty && <UnsavedChangesBadge />}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<UnsavedSaveButton
|
||||
isDirty={isDirty}
|
||||
isLoading={saveRequest.isLoading}
|
||||
status={saveRequest.status}
|
||||
cleanLabel="Close"
|
||||
onClick={isDirty ? () => handleSave(close) : close}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Repository Name</Label>
|
||||
<Input
|
||||
value={repositoryName}
|
||||
onChange={(e) => onUpdate(repository.id, { name: e.target.value })}
|
||||
placeholder="my-repo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Local Path</Label>
|
||||
<Input
|
||||
value={repositoryLocalPath}
|
||||
onChange={(e) => onUpdate(repository.id, { local_path: e.target.value })}
|
||||
placeholder="/path/to/repository"
|
||||
disabled={repository.source_type !== 'local'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Repository Name</Label>
|
||||
<Input
|
||||
value={repositoryName}
|
||||
onChange={(e) => onUpdate(repository.id, { name: e.target.value })}
|
||||
placeholder="my-repo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Local Path</Label>
|
||||
<Input
|
||||
value={repositoryLocalPath}
|
||||
onChange={(e) => onUpdate(repository.id, { local_path: e.target.value })}
|
||||
placeholder="/path/to/repository"
|
||||
disabled={repository.source_type !== 'local'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Worktree Branch Prefix</Label>
|
||||
<Input
|
||||
value={worktreeBranchPrefix}
|
||||
onChange={(e) => onUpdate(repository.id, { worktree_branch_prefix: e.target.value })}
|
||||
placeholder="feature/"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for new worktree branches. Leave empty to use the default.
|
||||
Branches are generated as {'{prefix}{sanitized-title}-{rand}'}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Setup Script</Label>
|
||||
<Textarea
|
||||
value={setupScript}
|
||||
onChange={(e) => onUpdate(repository.id, { setup_script: e.target.value })}
|
||||
placeholder="#!/bin/bash # any manual setup you need"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Runs when the repo is cloned or a git worktree is created.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cleanup Script</Label>
|
||||
<Textarea
|
||||
value={cleanupScript}
|
||||
onChange={(e) => onUpdate(repository.id, { cleanup_script: e.target.value })}
|
||||
placeholder="#!/bin/bash # any manual clean up you need"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Runs when the task is completed to clean up the workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<span>Custom Scripts</span>
|
||||
{areScriptsDirty && <UnsavedChangesBadge />}
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onAddScript(repository.id)}>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Script
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{repository.scripts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No scripts yet.</p>
|
||||
) : (
|
||||
repository.scripts.map((script) => (
|
||||
<div key={script.id} className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={script.name ?? ''}
|
||||
onChange={(e) =>
|
||||
onUpdateScript(repository.id, script.id, { name: e.target.value })
|
||||
}
|
||||
placeholder="Script name"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDeleteScript(repository.id, script.id)}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={script.command ?? ''}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Worktree Branch Prefix</Label>
|
||||
<Input
|
||||
value={worktreeBranchPrefix}
|
||||
onChange={(e) =>
|
||||
onUpdateScript(repository.id, script.id, { command: e.target.value })
|
||||
onUpdate(repository.id, { worktree_branch_prefix: e.target.value })
|
||||
}
|
||||
placeholder="#!/bin/bash npm run dev"
|
||||
placeholder="feature/"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for new worktree branches. Leave empty to use the default.
|
||||
Branches are generated as {'{prefix}{sanitized-title}-{rand}'}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Setup Script</Label>
|
||||
<Textarea
|
||||
value={setupScript}
|
||||
onChange={(e) => onUpdate(repository.id, { setup_script: e.target.value })}
|
||||
placeholder="#!/bin/bash # any manual setup you need"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Runs when the repo is cloned or a git worktree is created.
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cleanup Script</Label>
|
||||
<Textarea
|
||||
value={cleanupScript}
|
||||
onChange={(e) => onUpdate(repository.id, { cleanup_script: e.target.value })}
|
||||
placeholder="#!/bin/bash # any manual clean up you need"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Runs when the task is completed to clean up the workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={deleteRequest.isLoading}
|
||||
>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Repository
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<span>Custom Scripts</span>
|
||||
{areScriptsDirty && <UnsavedChangesBadge />}
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddScript(repository.id)}
|
||||
>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Script
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{repository.scripts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No scripts yet.</p>
|
||||
) : (
|
||||
repository.scripts.map((script) => (
|
||||
<div key={script.id} className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={script.name ?? ''}
|
||||
onChange={(e) =>
|
||||
onUpdateScript(repository.id, script.id, { name: e.target.value })
|
||||
}
|
||||
placeholder="Script name"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDeleteScript(repository.id, script.id)}
|
||||
>
|
||||
<IconX className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={script.command ?? ''}
|
||||
onChange={(e) =>
|
||||
onUpdateScript(repository.id, script.id, { command: e.target.value })
|
||||
}
|
||||
placeholder="#!/bin/bash npm run dev"
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={deleteRequest.isLoading}
|
||||
>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Repository
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
renderPreview={({ open }) => (
|
||||
<Card>
|
||||
<CardContent className="py-4 cursor-pointer" onClick={open}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="p-2 bg-muted rounded-md">
|
||||
<IconGitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium truncate">
|
||||
{repositoryName || 'Untitled repository'}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sourceLabel}
|
||||
</Badge>
|
||||
{worktreeBranchPrefix.trim() && worktreeBranchPrefix.trim() !== 'feature/' ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{worktreeBranchPrefix.trim()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isDirty && <UnsavedChangesBadge />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 truncate">
|
||||
{subtitle}
|
||||
</div>
|
||||
{showScriptsSummary ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground mt-1">
|
||||
{scriptsCount > 0 && <span>{scriptsLabel}</span>}
|
||||
{hasSetupScript && <span>build script</span>}
|
||||
{hasCleanupScript && <span>cleanup script</span>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
}}
|
||||
>
|
||||
<IconEdit className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
disabled={deleteRequest.isLoading}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -243,6 +352,6 @@ export function RepositoryCard({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type UnsavedSaveButtonProps = {
|
||||
isLoading: boolean;
|
||||
status: 'idle' | 'loading' | 'success' | 'error';
|
||||
onClick: () => void;
|
||||
cleanLabel?: string;
|
||||
};
|
||||
|
||||
const warningButtonClass = 'border-yellow-500/60 text-yellow-500 hover:bg-yellow-500/10';
|
||||
@@ -17,7 +18,13 @@ export function UnsavedChangesBadge() {
|
||||
return <span className="text-xs text-yellow-500">Unsaved changes</span>;
|
||||
}
|
||||
|
||||
export function UnsavedSaveButton({ isDirty, isLoading, status, onClick }: UnsavedSaveButtonProps) {
|
||||
export function UnsavedSaveButton({
|
||||
isDirty,
|
||||
isLoading,
|
||||
status,
|
||||
onClick,
|
||||
cleanLabel = 'Save',
|
||||
}: UnsavedSaveButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -28,7 +35,7 @@ export function UnsavedSaveButton({ isDirty, isLoading, status, onClick }: Unsav
|
||||
className={isDirty ? warningButtonClass : 'cursor-pointer'}
|
||||
>
|
||||
{isDirty && <IconAlertCircle className="h-4 w-4 mr-2" />}
|
||||
Save
|
||||
{isDirty ? 'Save' : cleanLabel}
|
||||
{status !== 'idle' && (
|
||||
<span className="ml-2">
|
||||
<RequestIndicator status={status} />
|
||||
|
||||
@@ -199,6 +199,8 @@ export type EditorOption = {
|
||||
config?: Record<string, unknown>;
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type EditorsResponse = {
|
||||
|
||||
Reference in New Issue
Block a user