feat: improve repository list setting (#65)

This commit is contained in:
Carlos Florêncio
2026-01-21 00:42:49 +00:00
committed by GitHub
parent 9a8e3f8790
commit 7a1bb89fa9
8 changed files with 425 additions and 229 deletions
+5
View File
@@ -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>
+247 -138
View File
@@ -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&#10;# 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&#10;# 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&#10;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&#10;# 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&#10;# 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&#10;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} />
+2
View File
@@ -199,6 +199,8 @@ export type EditorOption = {
config?: Record<string, unknown>;
installed: boolean;
enabled: boolean;
created_at?: string;
updated_at?: string;
};
export type EditorsResponse = {