diff --git a/apps/backend/internal/editors/dto/dto.go b/apps/backend/internal/editors/dto/dto.go index 511c787e5a..2951a75220 100644 --- a/apps/backend/internal/editors/dto/dto.go +++ b/apps/backend/internal/editors/dto/dto.go @@ -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, } } diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go index bd2fa917e3..1a13823f66 100644 --- a/apps/backend/internal/task/repository/sqlite.go +++ b/apps/backend/internal/task/repository/sqlite.go @@ -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 { diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index a8ad2b99c4..5ee0854870 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -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(repositories); + const [repositoryItems, setRepositoryItems] = useState(repositories); const [savedRepositoryItems, setSavedRepositoryItems] = useState(repositories); const [discoveredRepositories, setDiscoveredRepositories] = useState([]); const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false); - const [remoteRepoDialogOpen, setRemoteRepoDialogOpen] = useState(false); const [repoSearch, setRepoSearch] = useState(''); const [selectedRepoPath, setSelectedRepoPath] = useState(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={
- -
} @@ -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({ - - - - Add Remote Repository - Remote providers are coming soon. - -
- - - -
- - - -
-
); } diff --git a/apps/web/components/settings/editable-card.tsx b/apps/web/components/settings/editable-card.tsx new file mode 100644 index 0000000000..f64c70ab13 --- /dev/null +++ b/apps/web/components/settings/editable-card.tsx @@ -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) => ReactNode; + renderEdit: (args: Pick) => 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 }); +} diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index 8f3d557dd5..eb6ab80e4f 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -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(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 ( )} {customEditors.map((editor) => { - if (editingId === editor.id) { - return ( - setEditingId(null)} - onSave={(state) => { - void updateRequest.run(editor.id, state); - }} - submitLabel="Save changes" - isSaving={updateRequest.isLoading} - /> - ); - } return ( -
-
-
{editor.name}
-
- {getCustomEditorSummary(editor)} -
-
-
- - -
-
+
+
+ {editor.name} +
+
+ {getCustomEditorSummary(editor)} +
+
+
+ + +
+ + )} + /> ); })} diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx index edd1d314ef..2014b7e2fd 100644 --- a/apps/web/components/settings/repository-card.tsx +++ b/apps/web/components/settings/repository-card.tsx @@ -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) => void; onAddScript: (repoId: string) => void; onUpdateScript: (repoId: string, scriptId: string, updates: Partial) => 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 ( - - -
-
-
- - -
- -
+ <> + setIsEditing(true)} + onClose={() => setIsEditing(false)} + renderEdit={({ close }) => ( + + +
+
+
+ + +
+
+ handleSave(close) : close} + /> +
+
-
-
- - onUpdate(repository.id, { name: e.target.value })} - placeholder="my-repo" - /> -
-
- - onUpdate(repository.id, { local_path: e.target.value })} - placeholder="/path/to/repository" - disabled={repository.source_type !== 'local'} - /> -
-
+
+
+ + onUpdate(repository.id, { name: e.target.value })} + placeholder="my-repo" + /> +
+
+ + onUpdate(repository.id, { local_path: e.target.value })} + placeholder="/path/to/repository" + disabled={repository.source_type !== 'local'} + /> +
+
-
-
- - onUpdate(repository.id, { worktree_branch_prefix: e.target.value })} - placeholder="feature/" - /> -

- Used for new worktree branches. Leave empty to use the default. - Branches are generated as {'{prefix}{sanitized-title}-{rand}'}.

-
-
- -
-
- -