feat: improved settings repos, boards

This commit is contained in:
Carlos Florêncio
2026-01-12 22:13:54 +00:00
parent ce55dbf1c1
commit c5eac3551a
13 changed files with 1027 additions and 914 deletions
@@ -0,0 +1,32 @@
import {
getWorkspaceAction,
listBoardsAction,
listColumnsAction,
} from '@/app/actions/workspaces';
import { WorkspaceBoardsClient } from '@/app/settings/workspace/workspace-boards-client';
export default async function WorkspaceBoardsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let workspace = null;
let boardsWithColumns: Array<
Awaited<ReturnType<typeof listBoardsAction>>['boards'][number] & {
columns: Awaited<ReturnType<typeof listColumnsAction>>['columns'];
}
> = [];
try {
workspace = await getWorkspaceAction(id);
const boards = await listBoardsAction(id);
boardsWithColumns = await Promise.all(
boards.boards.map(async (board) => {
const columns = await listColumnsAction(board.id);
return { ...board, columns: columns.columns };
})
);
} catch {
workspace = null;
boardsWithColumns = [];
}
return <WorkspaceBoardsClient workspace={workspace} boards={boardsWithColumns} />;
}
+2 -32
View File
@@ -1,45 +1,15 @@
import {
getWorkspaceAction,
listBoardsAction,
listColumnsAction,
listRepositoriesAction,
listRepositoryScriptsAction,
} from '@/app/actions/workspaces';
import { getWorkspaceAction } from '@/app/actions/workspaces';
import { WorkspaceEditClient } from '@/app/settings/workspace/workspace-edit-client';
export default async function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let workspace = null;
let boardsWithColumns: Array<Awaited<ReturnType<typeof listBoardsAction>>['boards'][number] & { columns: Awaited<ReturnType<typeof listColumnsAction>>['columns'] }> = [];
let repositoriesWithScripts: Array<Awaited<ReturnType<typeof listRepositoriesAction>>['repositories'][number] & { scripts: Awaited<ReturnType<typeof listRepositoryScriptsAction>>['scripts'] }> = [];
try {
workspace = await getWorkspaceAction(id);
const boards = await listBoardsAction(id);
boardsWithColumns = await Promise.all(
boards.boards.map(async (board) => {
const columns = await listColumnsAction(board.id);
return { ...board, columns: columns.columns };
})
);
const repositories = await listRepositoriesAction(id);
repositoriesWithScripts = await Promise.all(
repositories.repositories.map(async (repository) => {
const scripts = await listRepositoryScriptsAction(repository.id);
return { ...repository, scripts: scripts.scripts };
})
);
} catch {
workspace = null;
boardsWithColumns = [];
repositoriesWithScripts = [];
}
return (
<WorkspaceEditClient
workspace={workspace}
boards={boardsWithColumns}
repositories={repositoriesWithScripts}
/>
);
return <WorkspaceEditClient workspace={workspace} />;
}
@@ -0,0 +1,38 @@
import {
getWorkspaceAction,
listRepositoriesAction,
listRepositoryScriptsAction,
} from '@/app/actions/workspaces';
import { WorkspaceRepositoriesClient } from '@/app/settings/workspace/workspace-repositories-client';
export default async function WorkspaceRepositoriesPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let workspace = null;
let repositoriesWithScripts: Array<
Awaited<ReturnType<typeof listRepositoriesAction>>['repositories'][number] & {
scripts: Awaited<ReturnType<typeof listRepositoryScriptsAction>>['scripts'];
}
> = [];
try {
workspace = await getWorkspaceAction(id);
const repositories = await listRepositoriesAction(id);
repositoriesWithScripts = await Promise.all(
repositories.repositories.map(async (repository) => {
const scripts = await listRepositoryScriptsAction(repository.id);
return { ...repository, scripts: scripts.scripts };
})
);
} catch {
workspace = null;
repositoriesWithScripts = [];
}
return (
<WorkspaceRepositoriesClient workspace={workspace} repositories={repositoriesWithScripts} />
);
}
@@ -0,0 +1,339 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { IconLayoutColumns, IconPlus } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Card, CardContent } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import { SettingsSection } from '@/components/settings/settings-section';
import { BoardCard } from '@/components/settings/board-card';
import { generateUUID } from '@/lib/utils';
import {
createBoardAction,
createColumnAction,
deleteBoardAction,
deleteColumnAction,
updateBoardAction,
updateColumnAction,
} from '@/app/actions/workspaces';
import type { Board, Column, Workspace } from '@/lib/types/http';
type BoardWithColumns = Board & { columns: Column[] };
type WorkspaceBoardsClientProps = {
workspace: Workspace | null;
boards: BoardWithColumns[];
};
export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClientProps) {
const router = useRouter();
const [boardItems, setBoardItems] = useState<BoardWithColumns[]>(boards);
const [savedBoardItems, setSavedBoardItems] = useState<BoardWithColumns[]>(boards);
const cloneBoard = (board: BoardWithColumns): BoardWithColumns => ({
...board,
columns: board.columns.map((column) => ({ ...column })),
});
const savedBoardsById = useMemo(() => {
return new Map(savedBoardItems.map((board) => [board.id, board]));
}, [savedBoardItems]);
const isBoardNameDirty = (board: BoardWithColumns) => {
const saved = savedBoardsById.get(board.id);
if (!saved) return true;
if (board.name !== saved.name || board.description !== saved.description) return true;
return false;
};
const areColumnsDirty = (board: BoardWithColumns) => {
const saved = savedBoardsById.get(board.id);
if (!saved) {
return board.columns.length > 0;
}
if (board.columns.length !== saved.columns.length) return true;
const savedColumns = new Map(saved.columns.map((column) => [column.id, column]));
for (const column of board.columns) {
const savedColumn = savedColumns.get(column.id);
if (!savedColumn) return true;
if (
column.name !== savedColumn.name ||
column.color !== savedColumn.color ||
column.position !== savedColumn.position ||
column.state !== savedColumn.state
) {
return true;
}
}
return false;
};
const handleAddBoard = () => {
if (!workspace) return;
const draftBoardId = `temp-${generateUUID()}`;
const draftBoard: BoardWithColumns = {
id: draftBoardId,
workspace_id: workspace.id,
name: 'New Board',
description: '',
created_at: '',
updated_at: '',
columns: [
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'Todo',
position: 0,
state: 'TODO',
color: 'bg-cyan-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'In Progress',
position: 1,
state: 'IN_PROGRESS',
color: 'bg-yellow-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'To Review',
position: 2,
state: 'REVIEW',
color: 'bg-green-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'Done',
position: 3,
state: 'COMPLETED',
color: 'bg-indigo-500',
created_at: '',
updated_at: '',
},
],
};
setBoardItems((prev) => [draftBoard, ...prev]);
};
const handleUpdateBoard = (boardId: string, updates: { name?: string; description?: string }) => {
setBoardItems((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, ...updates } : board))
);
};
const handleDeleteBoard = async (boardId: string) => {
if (boardId.startsWith('temp-')) {
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
return;
}
await deleteBoardAction(boardId);
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
setSavedBoardItems((prev) => prev.filter((board) => board.id !== boardId));
};
const handleCreateColumn = (
boardId: string,
column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>
) => {
const draftColumn: Column = {
id: `temp-col-${generateUUID()}`,
board_id: boardId,
name: column.name,
position: column.position,
state: column.state,
color: column.color,
created_at: '',
updated_at: '',
};
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: [...board.columns, draftColumn] } : board
)
);
};
const handleUpdateColumn = (boardId: string, columnId: string, updates: Partial<Column>) => {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? {
...board,
columns: board.columns.map((column) =>
column.id === columnId ? { ...column, ...updates } : column
),
}
: board
)
);
};
const handleDeleteColumn = async (boardId: string, columnId: string) => {
if (boardId.startsWith('temp-')) {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? { ...board, columns: board.columns.filter((column) => column.id !== columnId) }
: board
)
);
return;
}
await deleteColumnAction(columnId);
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? { ...board, columns: board.columns.filter((column) => column.id !== columnId) }
: board
)
);
};
const handleReorderColumns = (boardId: string, columns: Column[]) => {
setBoardItems((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, columns } : board))
);
};
const handleSaveBoard = async (boardId: string) => {
const board = boardItems.find((item) => item.id === boardId);
if (!board) return;
if (boardId.startsWith('temp-')) {
const name = board.name.trim() || 'New Board';
const createdBoard = await createBoardAction({
workspace_id: workspace?.id ?? board.workspace_id,
name,
});
const createdColumns = await Promise.all(
board.columns.map((column, index) =>
createColumnAction({
board_id: createdBoard.id,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
})
)
);
const nextBoard = {
...createdBoard,
columns: createdColumns,
};
setBoardItems((prev) => prev.map((item) => (item.id === boardId ? nextBoard : item)));
setSavedBoardItems((prev) => [cloneBoard(nextBoard), ...prev]);
return;
}
const updates: { name?: string; description?: string } = {};
if (board.name.trim()) {
updates.name = board.name.trim();
}
if (Object.keys(updates).length) {
await updateBoardAction(boardId, updates);
}
const nextColumns = await Promise.all(
board.columns.map((column, index) => {
if (column.id.startsWith('temp-col-')) {
return createColumnAction({
board_id: boardId,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
});
}
return updateColumnAction(column.id, {
name: column.name,
color: column.color,
position: column.position ?? index,
state: column.state,
});
})
);
setBoardItems((prev) =>
prev.map((item) => (item.id === boardId ? { ...item, columns: nextColumns } : item))
);
setSavedBoardItems((prev) =>
prev.some((item) => item.id === boardId)
? prev.map((item) =>
item.id === boardId ? cloneBoard({ ...board, columns: nextColumns }) : item
)
: [...prev, cloneBoard({ ...board, columns: nextColumns })]
);
};
if (!workspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/workspace')}>
Back to Workspaces
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold">{workspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage boards and columns for this workspace.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/settings/workspace/${workspace.id}`}>Workspace settings</Link>
</Button>
</div>
<Separator />
<SettingsSection
icon={<IconLayoutColumns className="h-5 w-5" />}
title="Boards"
description="Boards and columns in this workspace"
action={
<Button size="sm" onClick={handleAddBoard}>
<IconPlus className="h-4 w-4 mr-2" />
Add Board
</Button>
}
>
<div className="grid gap-3">
{boardItems.map((board) => (
<BoardCard
key={board.id}
board={board}
columns={board.columns}
isBoardDirty={isBoardNameDirty(board)}
areColumnsDirty={areColumnsDirty(board)}
onUpdateBoard={(updates) => handleUpdateBoard(board.id, updates)}
onDeleteBoard={() => handleDeleteBoard(board.id)}
onCreateColumn={(column) => handleCreateColumn(board.id, column)}
onUpdateColumn={(columnId, updates) =>
handleUpdateColumn(board.id, columnId, updates)
}
onDeleteColumn={(columnId) => handleDeleteColumn(board.id, columnId)}
onReorderColumns={(columns) => handleReorderColumns(board.id, columns)}
onSaveBoard={() => handleSaveBoard(board.id)}
/>
))}
</div>
</SettingsSection>
</div>
);
}
@@ -1,11 +1,11 @@
'use client';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { IconGitBranch, IconLoader2, IconPlus, IconTrash } from '@tabler/icons-react';
import { IconGitBranch, IconLayoutColumns, IconTrash } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import {
@@ -16,78 +16,27 @@ import {
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { RepositoryCard } from '@/components/settings/repository-card';
import { BoardCard } from '@/components/settings/board-card';
import { generateUUID } from '@/lib/utils';
import {
createBoardAction,
createColumnAction,
discoverRepositoriesAction,
createRepositoryAction,
createRepositoryScriptAction,
deleteBoardAction,
deleteColumnAction,
deleteRepositoryAction,
deleteRepositoryScriptAction,
deleteWorkspaceAction,
updateBoardAction,
updateColumnAction,
validateRepositoryPathAction,
updateRepositoryAction,
updateRepositoryScriptAction,
updateWorkspaceAction,
} from '@/app/actions/workspaces';
import type {
Board,
Column,
LocalRepository,
Repository,
RepositoryScript,
Workspace,
} from '@/lib/types/http';
import { updateWorkspaceAction, deleteWorkspaceAction } from '@/app/actions/workspaces';
import type { Workspace } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { useAppStore } from '@/components/state-provider';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
type BoardWithColumns = Board & { columns: Column[] };
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
type WorkspaceEditClientProps = {
workspace: Workspace | null;
boards: BoardWithColumns[];
repositories: RepositoryWithScripts[];
};
export function WorkspaceEditClient({ workspace, boards, repositories }: WorkspaceEditClientProps) {
export function WorkspaceEditClient({ workspace }: WorkspaceEditClientProps) {
const router = useRouter();
const { toast } = useToast();
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(workspace);
const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace?.name ?? '');
const [boardItems, setBoardItems] = useState<BoardWithColumns[]>(boards);
const [savedWorkspaceName, setSavedWorkspaceName] = useState(workspace?.name ?? '');
const [savedBoardItems, setSavedBoardItems] = useState<BoardWithColumns[]>(boards);
const [repositoryItems, setRepositoryItems] = useState<RepositoryWithScripts[]>(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('');
const [manualValidation, setManualValidation] = useState<{
status: 'idle' | 'loading' | 'success' | 'error';
message?: string;
isValid?: boolean;
path?: string;
}>({ status: 'idle' });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const saveWorkspaceRequest = useRequest(updateWorkspaceAction);
const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction);
const discoverRepositoriesRequest = useRequest(discoverRepositoriesAction);
const validateRepositoryPathRequest = useRequest(validateRepositoryPathAction);
const workspaces = useAppStore((state) => state.workspaces.items);
const setWorkspaces = useAppStore((state) => state.setWorkspaces);
@@ -113,520 +62,6 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
}
};
const cloneBoard = (board: BoardWithColumns): BoardWithColumns => ({
...board,
columns: board.columns.map((column) => ({ ...column })),
});
const savedBoardsById = useMemo(() => {
return new Map(savedBoardItems.map((board) => [board.id, board]));
}, [savedBoardItems]);
const isBoardNameDirty = (board: BoardWithColumns) => {
const saved = savedBoardsById.get(board.id);
if (!saved) return true;
if (board.name !== saved.name || board.description !== saved.description) return true;
return false;
};
const areColumnsDirty = (board: BoardWithColumns) => {
const saved = savedBoardsById.get(board.id);
if (!saved) {
return board.columns.length > 0;
}
if (board.columns.length !== saved.columns.length) return true;
const savedColumns = new Map(saved.columns.map((column) => [column.id, column]));
for (const column of board.columns) {
const savedColumn = savedColumns.get(column.id);
if (!savedColumn) return true;
if (
column.name !== savedColumn.name ||
column.color !== savedColumn.color ||
column.position !== savedColumn.position ||
column.state !== savedColumn.state
) {
return true;
}
}
return false;
};
const isWorkspaceDirty = workspaceNameDraft.trim() !== savedWorkspaceName;
const cloneRepository = (repo: RepositoryWithScripts): RepositoryWithScripts => ({
...repo,
scripts: repo.scripts.map((script) => ({ ...script })),
});
const savedRepositoriesById = useMemo(() => {
return new Map(savedRepositoryItems.map((repo) => [repo.id, repo]));
}, [savedRepositoryItems]);
const filteredRepositories = useMemo(() => {
const query = repoSearch.trim().toLowerCase();
if (!query) return discoveredRepositories;
return discoveredRepositories.filter(
(repo) => repo.name.toLowerCase().includes(query) || repo.path.toLowerCase().includes(query)
);
}, [discoveredRepositories, repoSearch]);
const canSaveLocalRepo =
Boolean(selectedRepoPath) || (manualValidation.status === 'success' && manualValidation.isValid);
const isRepositoryDirty = (repo: RepositoryWithScripts) => {
const saved = savedRepositoriesById.get(repo.id);
if (!saved) return true;
return (
repo.name !== saved.name ||
repo.source_type !== saved.source_type ||
repo.local_path !== saved.local_path ||
repo.provider !== saved.provider ||
repo.provider_repo_id !== saved.provider_repo_id ||
repo.provider_owner !== saved.provider_owner ||
repo.provider_name !== saved.provider_name ||
repo.default_branch !== saved.default_branch ||
repo.setup_script !== saved.setup_script ||
repo.cleanup_script !== saved.cleanup_script
);
};
const areRepositoryScriptsDirty = (repo: RepositoryWithScripts) => {
const saved = savedRepositoriesById.get(repo.id);
if (!saved) return repo.scripts.length > 0;
if (repo.scripts.length !== saved.scripts.length) return true;
const savedScripts = new Map(saved.scripts.map((script) => [script.id, script]));
for (const script of repo.scripts) {
const savedScript = savedScripts.get(script.id);
if (!savedScript) return true;
if (
script.name !== savedScript.name ||
script.command !== savedScript.command ||
script.position !== savedScript.position
) {
return true;
}
}
return false;
};
const handleUpdateRepository = (repoId: string, updates: Partial<Repository>) => {
setRepositoryItems((prev) =>
prev.map((repo) => (repo.id === repoId ? { ...repo, ...updates } : repo))
);
};
const handleDiscoverRepositories = async () => {
if (!currentWorkspace) return;
try {
const result = await discoverRepositoriesRequest.run(currentWorkspace.id);
setDiscoveredRepositories(result.repositories);
} catch (error) {
toast({
title: 'Failed to discover repositories',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const openLocalRepositoryDialog = async () => {
setLocalRepoDialogOpen(true);
setRepoSearch('');
setSelectedRepoPath(null);
setManualRepoPath('');
setManualValidation({ status: 'idle' });
await handleDiscoverRepositories();
};
const handleValidateManualPath = async () => {
if (!currentWorkspace || !manualRepoPath.trim()) return;
setManualValidation({ status: 'loading' });
try {
const result = await validateRepositoryPathRequest.run(currentWorkspace.id, manualRepoPath.trim());
if (result.allowed && result.exists && result.is_git) {
setManualValidation({
status: 'success',
isValid: true,
message: 'Valid git repository',
path: result.path,
});
} else {
setManualValidation({
status: 'error',
isValid: false,
message: result.message || 'Invalid repository path',
path: result.path,
});
}
} catch (error) {
setManualValidation({
status: 'error',
isValid: false,
message: error instanceof Error ? error.message : 'Request failed',
});
}
};
const handleConfirmLocalRepository = () => {
if (!currentWorkspace) return;
const selectedRepo = discoveredRepositories.find((repo) => repo.path === selectedRepoPath);
const path = selectedRepo?.path || manualValidation.path || manualRepoPath.trim();
if (!path) return;
const name = selectedRepo?.name || path.split('/').filter(Boolean).slice(-1)[0] || 'New Repository';
const draftId = `temp-repo-${generateUUID()}`;
const draftRepo: RepositoryWithScripts = {
id: draftId,
workspace_id: currentWorkspace.id,
name,
source_type: 'local',
local_path: path,
provider: '',
provider_repo_id: '',
provider_owner: '',
provider_name: '',
default_branch: selectedRepo?.default_branch || '',
setup_script: '',
cleanup_script: '',
created_at: '',
updated_at: '',
scripts: [],
};
setRepositoryItems((prev) => [draftRepo, ...prev]);
setLocalRepoDialogOpen(false);
};
const handleAddRepositoryScript = (repoId: string) => {
const script: RepositoryScript = {
id: `temp-script-${generateUUID()}`,
repository_id: repoId,
name: '',
command: '',
position: repositoryItems.find((repo) => repo.id === repoId)?.scripts.length ?? 0,
created_at: '',
updated_at: '',
};
setRepositoryItems((prev) =>
prev.map((repo) =>
repo.id === repoId ? { ...repo, scripts: [...repo.scripts, script] } : repo
)
);
};
const handleUpdateRepositoryScript = (repoId: string, scriptId: string, updates: Partial<RepositoryScript>) => {
setRepositoryItems((prev) =>
prev.map((repo) =>
repo.id === repoId
? {
...repo,
scripts: repo.scripts.map((script) =>
script.id === scriptId ? { ...script, ...updates } : script
),
}
: repo
)
);
};
const handleDeleteRepositoryScript = (repoId: string, scriptId: string) => {
setRepositoryItems((prev) =>
prev.map((repo) =>
repo.id === repoId
? { ...repo, scripts: repo.scripts.filter((script) => script.id !== scriptId) }
: repo
)
);
};
const handleSaveRepository = async (repoId: string) => {
const repo = repositoryItems.find((item) => item.id === repoId);
if (!repo) return;
if (repoId.startsWith('temp-repo-')) {
const created = await createRepositoryAction({
workspace_id: currentWorkspace?.id ?? repo.workspace_id,
name: repo.name.trim() || 'New Repository',
source_type: repo.source_type || 'local',
local_path: repo.local_path,
provider: repo.provider,
provider_repo_id: repo.provider_repo_id,
provider_owner: repo.provider_owner,
provider_name: repo.provider_name,
default_branch: repo.default_branch,
setup_script: repo.setup_script,
cleanup_script: repo.cleanup_script,
});
const scripts = await Promise.all(
repo.scripts.map((script, index) =>
createRepositoryScriptAction({
repository_id: created.id,
name: script.name.trim() || 'New Script',
command: script.command.trim() || 'echo ""',
position: script.position ?? index,
})
)
);
const nextRepo: RepositoryWithScripts = { ...created, scripts };
setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
setSavedRepositoryItems((prev) => [cloneRepository(nextRepo), ...prev]);
return;
}
const updated = await updateRepositoryAction(repoId, {
name: repo.name,
source_type: repo.source_type,
local_path: repo.local_path,
provider: repo.provider,
provider_repo_id: repo.provider_repo_id,
provider_owner: repo.provider_owner,
provider_name: repo.provider_name,
default_branch: repo.default_branch,
setup_script: repo.setup_script,
cleanup_script: repo.cleanup_script,
});
const saved = savedRepositoriesById.get(repoId);
const savedScripts = saved ? saved.scripts : [];
const currentScriptIds = new Set(repo.scripts.map((script) => script.id));
await Promise.all(
savedScripts
.filter((script) => !currentScriptIds.has(script.id))
.map((script) => deleteRepositoryScriptAction(script.id))
);
const nextScripts = await Promise.all(
repo.scripts.map((script, index) => {
if (script.id.startsWith('temp-script-')) {
return createRepositoryScriptAction({
repository_id: repoId,
name: script.name.trim() || 'New Script',
command: script.command.trim() || 'echo ""',
position: script.position ?? index,
});
}
return updateRepositoryScriptAction(script.id, {
name: script.name,
command: script.command,
position: script.position ?? index,
});
})
);
const nextRepo: RepositoryWithScripts = { ...updated, scripts: nextScripts };
setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
setSavedRepositoryItems((prev) =>
prev.some((item) => item.id === repoId)
? prev.map((item) => (item.id === repoId ? cloneRepository(nextRepo) : item))
: [...prev, cloneRepository(nextRepo)]
);
};
const handleDeleteRepository = async (repoId: string) => {
if (repoId.startsWith('temp-repo-')) {
setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
return;
}
await deleteRepositoryAction(repoId);
setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
setSavedRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
};
const handleAddBoard = () => {
if (!currentWorkspace) return;
const draftBoardId = `temp-${generateUUID()}`;
const draftBoard: BoardWithColumns = {
id: draftBoardId,
workspace_id: currentWorkspace.id,
name: 'New Board',
description: '',
created_at: '',
updated_at: '',
columns: [
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'Todo',
position: 0,
state: 'TODO',
color: 'bg-cyan-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'In Progress',
position: 1,
state: 'IN_PROGRESS',
color: 'bg-yellow-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'To Review',
position: 2,
state: 'REVIEW',
color: 'bg-green-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'Done',
position: 3,
state: 'COMPLETED',
color: 'bg-indigo-500',
created_at: '',
updated_at: '',
},
],
};
setBoardItems((prev) => [draftBoard, ...prev]);
};
const handleUpdateBoard = (boardId: string, updates: { name?: string; description?: string }) => {
setBoardItems((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, ...updates } : board))
);
};
const handleDeleteBoard = async (boardId: string) => {
if (boardId.startsWith('temp-')) {
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
return;
}
await deleteBoardAction(boardId);
setBoardItems((prev) => prev.filter((board) => board.id !== boardId));
setSavedBoardItems((prev) => prev.filter((board) => board.id !== boardId));
};
const handleCreateColumn = (
boardId: string,
column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>
) => {
const draftColumn: Column = {
id: `temp-col-${generateUUID()}`,
board_id: boardId,
name: column.name,
position: column.position,
state: column.state,
color: column.color,
created_at: '',
updated_at: '',
};
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: [...board.columns, draftColumn] } : board
)
);
};
const handleUpdateColumn = (boardId: string, columnId: string, updates: Partial<Column>) => {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? {
...board,
columns: board.columns.map((column) => (column.id === columnId ? { ...column, ...updates } : column)),
}
: board
)
);
};
const handleDeleteColumn = async (boardId: string, columnId: string) => {
if (boardId.startsWith('temp-')) {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: board.columns.filter((column) => column.id !== columnId) } : board
)
);
return;
}
await deleteColumnAction(columnId);
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: board.columns.filter((column) => column.id !== columnId) } : board
)
);
};
const handleReorderColumns = (boardId: string, columns: Column[]) => {
setBoardItems((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, columns } : board))
);
};
const handleSaveBoard = async (boardId: string) => {
const board = boardItems.find((item) => item.id === boardId);
if (!board) return;
if (boardId.startsWith('temp-')) {
const name = board.name.trim() || 'New Board';
const createdBoard = await createBoardAction({
workspace_id: currentWorkspace?.id ?? board.workspace_id,
name,
});
const createdColumns = await Promise.all(
board.columns.map((column, index) =>
createColumnAction({
board_id: createdBoard.id,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
})
)
);
const nextBoard = {
...createdBoard,
columns: createdColumns,
};
setBoardItems((prev) =>
prev.map((item) => (item.id === boardId ? nextBoard : item))
);
setSavedBoardItems((prev) => [cloneBoard(nextBoard), ...prev]);
return;
}
const updates: { name?: string; description?: string } = {};
if (board.name.trim()) {
updates.name = board.name.trim();
}
if (Object.keys(updates).length) {
await updateBoardAction(boardId, updates);
}
const nextColumns = await Promise.all(
board.columns.map((column, index) => {
if (column.id.startsWith('temp-col-')) {
return createColumnAction({
board_id: boardId,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
});
}
return updateColumnAction(column.id, {
name: column.name,
color: column.color,
position: column.position ?? index,
state: column.state,
});
})
);
setBoardItems((prev) =>
prev.map((item) => (item.id === boardId ? { ...item, columns: nextColumns } : item))
);
setSavedBoardItems((prev) =>
prev.some((item) => item.id === boardId)
? prev.map((item) =>
item.id === boardId ? cloneBoard({ ...board, columns: nextColumns }) : item
)
: [...prev, cloneBoard({ ...board, columns: nextColumns })]
);
};
const handleDeleteWorkspace = async () => {
if (deleteConfirmText !== 'delete' || !currentWorkspace) return;
try {
@@ -662,7 +97,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
<div>
<h2 className="text-2xl font-bold">{currentWorkspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage repositories and boards for this workspace
Manage workspace details and jump into boards or repositories.
</p>
</div>
@@ -672,7 +107,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>Workspace Name</span>
{isWorkspaceDirty && <UnsavedChangesBadge />}
{workspaceNameDraft.trim() !== savedWorkspaceName && <UnsavedChangesBadge />}
</CardTitle>
</CardHeader>
<CardContent>
@@ -683,7 +118,7 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
/>
<div className="flex items-center gap-2">
<UnsavedSaveButton
isDirty={isWorkspaceDirty}
isDirty={workspaceNameDraft.trim() !== savedWorkspaceName}
isLoading={saveWorkspaceRequest.isLoading}
status={saveWorkspaceRequest.status}
onClick={handleSaveWorkspaceName}
@@ -695,182 +130,27 @@ export function WorkspaceEditClient({ workspace, boards, repositories }: Workspa
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Repositories"
description="Repositories in this workspace"
action={
<div className="flex items-center gap-2">
<Button size="sm" onClick={openLocalRepositoryDialog}>
Local Repository
<Card>
<CardHeader>
<CardTitle>Workspace Links</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<Button asChild variant="outline" className="justify-start gap-2">
<Link href={`/settings/workspace/${currentWorkspace.id}/repositories`}>
<IconGitBranch className="h-4 w-4" />
Repositories
</Link>
</Button>
<Button size="sm" variant="outline" onClick={() => setRemoteRepoDialogOpen(true)}>
Remote Repository
<Button asChild variant="outline" className="justify-start gap-2">
<Link href={`/settings/workspace/${currentWorkspace.id}/boards`}>
<IconLayoutColumns className="h-4 w-4" />
Boards
</Link>
</Button>
</div>
}
>
<div className="grid gap-3">
{repositoryItems.map((repo) => (
<RepositoryCard
key={repo.id}
repository={repo}
isRepositoryDirty={isRepositoryDirty(repo)}
areScriptsDirty={areRepositoryScriptsDirty(repo)}
onUpdate={handleUpdateRepository}
onAddScript={handleAddRepositoryScript}
onUpdateScript={handleUpdateRepositoryScript}
onDeleteScript={handleDeleteRepositoryScript}
onSave={handleSaveRepository}
onDelete={handleDeleteRepository}
/>
))}
</div>
</SettingsSection>
<Separator />
<Dialog open={localRepoDialogOpen} onOpenChange={setLocalRepoDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Local Repository</DialogTitle>
<DialogDescription>
Select a discovered repository or provide an absolute path to validate.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Discovered repositories</Label>
<Input
placeholder="Filter repositories..."
value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)}
/>
<div className="max-h-56 overflow-auto rounded-md border border-border">
{discoverRepositoriesRequest.isLoading ? (
<div className="flex items-center gap-2 p-3 text-sm text-muted-foreground">
<IconLoader2 className="h-4 w-4 animate-spin" />
Scanning repositories...
</div>
) : filteredRepositories.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No repositories found.</div>
) : (
filteredRepositories.map((repo) => (
<button
key={repo.path}
type="button"
className={`flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-muted ${
selectedRepoPath === repo.path ? 'bg-muted' : ''
}`}
onClick={() => {
setSelectedRepoPath(repo.path);
setManualRepoPath('');
setManualValidation({ status: 'idle' });
}}
>
<span className="font-medium">{repo.name}</span>
<span className="text-xs text-muted-foreground">{repo.path}</span>
</button>
))
)}
</div>
</div>
<div className="space-y-2">
<Label>Manual path</Label>
<div className="flex items-center gap-2">
<Input
placeholder="/absolute/path/to/repository"
value={manualRepoPath}
onChange={(e) => {
setManualRepoPath(e.target.value);
setSelectedRepoPath(null);
setManualValidation({ status: 'idle' });
}}
/>
<Button
type="button"
variant="outline"
onClick={handleValidateManualPath}
disabled={!manualRepoPath.trim() || validateRepositoryPathRequest.isLoading}
>
{validateRepositoryPathRequest.isLoading ? 'Checking...' : 'Validate'}
</Button>
</div>
{manualValidation.status === 'error' && (
<p className="text-xs text-destructive">{manualValidation.message}</p>
)}
{manualValidation.status === 'success' && (
<p className="text-xs text-emerald-500">{manualValidation.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setLocalRepoDialogOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={handleConfirmLocalRepository} disabled={!canSaveLocalRepo}>
Use Repository
</Button>
</DialogFooter>
</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>
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Boards"
description="Boards and columns in this workspace"
action={
<Button size="sm" onClick={handleAddBoard}>
<IconPlus className="h-4 w-4 mr-2" />
Add Board
</Button>
}
>
<div className="grid gap-3">
{boardItems.map((board) => (
<BoardCard
key={board.id}
board={board}
columns={board.columns}
isBoardDirty={isBoardNameDirty(board)}
areColumnsDirty={areColumnsDirty(board)}
onUpdateBoard={(updates) => handleUpdateBoard(board.id, updates)}
onDeleteBoard={() => handleDeleteBoard(board.id)}
onCreateColumn={(column) => handleCreateColumn(board.id, column)}
onUpdateColumn={(columnId, updates) => handleUpdateColumn(board.id, columnId, updates)}
onDeleteColumn={(columnId) => handleDeleteColumn(board.id, columnId)}
onReorderColumns={(columns) => handleReorderColumns(board.id, columns)}
onSaveBoard={() => handleSaveBoard(board.id)}
/>
))}
</div>
</SettingsSection>
</CardContent>
</Card>
<Separator />
@@ -0,0 +1,524 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { IconGitBranch, IconLoader2 } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Card, CardContent } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { RepositoryCard } from '@/components/settings/repository-card';
import { generateUUID } from '@/lib/utils';
import {
createRepositoryAction,
createRepositoryScriptAction,
deleteRepositoryAction,
deleteRepositoryScriptAction,
discoverRepositoriesAction,
updateRepositoryAction,
updateRepositoryScriptAction,
validateRepositoryPathAction,
} from '@/app/actions/workspaces';
import type { LocalRepository, Repository, RepositoryScript, Workspace } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
type WorkspaceRepositoriesClientProps = {
workspace: Workspace | null;
repositories: RepositoryWithScripts[];
};
export function WorkspaceRepositoriesClient({
workspace,
repositories,
}: WorkspaceRepositoriesClientProps) {
const router = useRouter();
const { toast } = useToast();
const [repositoryItems, setRepositoryItems] = useState<RepositoryWithScripts[]>(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('');
const [manualValidation, setManualValidation] = useState<{
status: 'idle' | 'loading' | 'success' | 'error';
message?: string;
isValid?: boolean;
path?: string;
}>({ status: 'idle' });
const discoverRepositoriesRequest = useRequest(discoverRepositoriesAction);
const validateRepositoryPathRequest = useRequest(validateRepositoryPathAction);
const cloneRepository = (repo: RepositoryWithScripts): RepositoryWithScripts => ({
...repo,
scripts: repo.scripts.map((script) => ({ ...script })),
});
const savedRepositoriesById = useMemo(() => {
return new Map(savedRepositoryItems.map((repo) => [repo.id, repo]));
}, [savedRepositoryItems]);
const filteredRepositories = useMemo(() => {
const query = repoSearch.trim().toLowerCase();
if (!query) return discoveredRepositories;
return discoveredRepositories.filter(
(repo) => repo.name.toLowerCase().includes(query) || repo.path.toLowerCase().includes(query)
);
}, [discoveredRepositories, repoSearch]);
const canSaveLocalRepo =
Boolean(selectedRepoPath) || (manualValidation.status === 'success' && manualValidation.isValid);
const isRepositoryDirty = (repo: RepositoryWithScripts) => {
const saved = savedRepositoriesById.get(repo.id);
if (!saved) return true;
return (
repo.name !== saved.name ||
repo.source_type !== saved.source_type ||
repo.local_path !== saved.local_path ||
repo.provider !== saved.provider ||
repo.provider_repo_id !== saved.provider_repo_id ||
repo.provider_owner !== saved.provider_owner ||
repo.provider_name !== saved.provider_name ||
repo.default_branch !== saved.default_branch ||
repo.setup_script !== saved.setup_script ||
repo.cleanup_script !== saved.cleanup_script
);
};
const areRepositoryScriptsDirty = (repo: RepositoryWithScripts) => {
const saved = savedRepositoriesById.get(repo.id);
if (!saved) return repo.scripts.length > 0;
if (repo.scripts.length !== saved.scripts.length) return true;
const savedScripts = new Map(saved.scripts.map((script) => [script.id, script]));
for (const script of repo.scripts) {
const savedScript = savedScripts.get(script.id);
if (!savedScript) return true;
if (
script.name !== savedScript.name ||
script.command !== savedScript.command ||
script.position !== savedScript.position
) {
return true;
}
}
return false;
};
const handleUpdateRepository = (repoId: string, updates: Partial<Repository>) => {
setRepositoryItems((prev) =>
prev.map((repo) => (repo.id === repoId ? { ...repo, ...updates } : repo))
);
};
const handleDiscoverRepositories = async () => {
if (!workspace) return;
try {
const result = await discoverRepositoriesRequest.run(workspace.id);
setDiscoveredRepositories(result.repositories);
} catch (error) {
toast({
title: 'Failed to discover repositories',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const openLocalRepositoryDialog = async () => {
setLocalRepoDialogOpen(true);
setRepoSearch('');
setSelectedRepoPath(null);
setManualRepoPath('');
setManualValidation({ status: 'idle' });
await handleDiscoverRepositories();
};
const handleValidateManualPath = async () => {
if (!workspace || !manualRepoPath.trim()) return;
setManualValidation({ status: 'loading' });
try {
const result = await validateRepositoryPathRequest.run(workspace.id, manualRepoPath.trim());
if (result.allowed && result.exists && result.is_git) {
setManualValidation({
status: 'success',
isValid: true,
message: 'Valid git repository',
path: result.path,
});
} else {
setManualValidation({
status: 'error',
isValid: false,
message: result.message || 'Invalid repository path',
path: result.path,
});
}
} catch (error) {
setManualValidation({
status: 'error',
isValid: false,
message: error instanceof Error ? error.message : 'Request failed',
});
}
};
const handleConfirmLocalRepository = () => {
if (!workspace) return;
const selectedRepo = discoveredRepositories.find((repo) => repo.path === selectedRepoPath);
const path = selectedRepo?.path || manualValidation.path || manualRepoPath.trim();
if (!path) return;
const name = selectedRepo?.name || path.split('/').filter(Boolean).slice(-1)[0] || 'New Repository';
const draftId = `temp-repo-${generateUUID()}`;
const draftRepo: RepositoryWithScripts = {
id: draftId,
workspace_id: workspace.id,
name,
source_type: 'local',
local_path: path,
provider: '',
provider_repo_id: '',
provider_owner: '',
provider_name: '',
default_branch: selectedRepo?.default_branch || '',
setup_script: '',
cleanup_script: '',
created_at: '',
updated_at: '',
scripts: [],
};
setRepositoryItems((prev) => [draftRepo, ...prev]);
setLocalRepoDialogOpen(false);
};
const handleAddRepositoryScript = (repoId: string) => {
const script: RepositoryScript = {
id: `temp-script-${generateUUID()}`,
repository_id: repoId,
name: '',
command: '',
position: repositoryItems.find((repo) => repo.id === repoId)?.scripts.length ?? 0,
created_at: '',
updated_at: '',
};
setRepositoryItems((prev) =>
prev.map((repo) =>
repo.id === repoId ? { ...repo, scripts: [...repo.scripts, script] } : repo
)
);
};
const handleUpdateRepositoryScript = (
repoId: string,
scriptId: string,
updates: Partial<RepositoryScript>
) => {
setRepositoryItems((prev) =>
prev.map((repo) =>
repo.id === repoId
? {
...repo,
scripts: repo.scripts.map((script) =>
script.id === scriptId ? { ...script, ...updates } : script
),
}
: repo
)
);
};
const handleDeleteRepositoryScript = (repoId: string, scriptId: string) => {
setRepositoryItems((prev) =>
prev.map((repo) =>
repo.id === repoId
? { ...repo, scripts: repo.scripts.filter((script) => script.id !== scriptId) }
: repo
)
);
};
const handleSaveRepository = async (repoId: string) => {
const repo = repositoryItems.find((item) => item.id === repoId);
if (!repo) return;
if (repoId.startsWith('temp-repo-')) {
const created = await createRepositoryAction({
workspace_id: workspace?.id ?? repo.workspace_id,
name: repo.name.trim() || 'New Repository',
source_type: repo.source_type || 'local',
local_path: repo.local_path,
provider: repo.provider,
provider_repo_id: repo.provider_repo_id,
provider_owner: repo.provider_owner,
provider_name: repo.provider_name,
default_branch: repo.default_branch,
setup_script: repo.setup_script,
cleanup_script: repo.cleanup_script,
});
const scripts = await Promise.all(
repo.scripts.map((script, index) =>
createRepositoryScriptAction({
repository_id: created.id,
name: script.name.trim() || 'New Script',
command: script.command.trim() || 'echo ""',
position: script.position ?? index,
})
)
);
const nextRepo: RepositoryWithScripts = { ...created, scripts };
setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
setSavedRepositoryItems((prev) => [cloneRepository(nextRepo), ...prev]);
return;
}
const updated = await updateRepositoryAction(repoId, {
name: repo.name,
source_type: repo.source_type,
local_path: repo.local_path,
provider: repo.provider,
provider_repo_id: repo.provider_repo_id,
provider_owner: repo.provider_owner,
provider_name: repo.provider_name,
default_branch: repo.default_branch,
setup_script: repo.setup_script,
cleanup_script: repo.cleanup_script,
});
const saved = savedRepositoriesById.get(repoId);
const savedScripts = saved ? saved.scripts : [];
const currentScriptIds = new Set(repo.scripts.map((script) => script.id));
await Promise.all(
savedScripts
.filter((script) => !currentScriptIds.has(script.id))
.map((script) => deleteRepositoryScriptAction(script.id))
);
const nextScripts = await Promise.all(
repo.scripts.map((script, index) => {
if (script.id.startsWith('temp-script-')) {
return createRepositoryScriptAction({
repository_id: repoId,
name: script.name.trim() || 'New Script',
command: script.command.trim() || 'echo ""',
position: script.position ?? index,
});
}
return updateRepositoryScriptAction(script.id, {
name: script.name,
command: script.command,
position: script.position ?? index,
});
})
);
const nextRepo: RepositoryWithScripts = { ...updated, scripts: nextScripts };
setRepositoryItems((prev) => prev.map((item) => (item.id === repoId ? nextRepo : item)));
setSavedRepositoryItems((prev) =>
prev.some((item) => item.id === repoId)
? prev.map((item) => (item.id === repoId ? cloneRepository(nextRepo) : item))
: [...prev, cloneRepository(nextRepo)]
);
};
const handleDeleteRepository = async (repoId: string) => {
if (repoId.startsWith('temp-repo-')) {
setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
return;
}
await deleteRepositoryAction(repoId);
setRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
setSavedRepositoryItems((prev) => prev.filter((repo) => repo.id !== repoId));
};
if (!workspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/workspace')}>
Back to Workspaces
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold">{workspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage repositories connected to this workspace.
</p>
</div>
<Button asChild variant="outline" size="sm">
<Link href={`/settings/workspace/${workspace.id}`}>Workspace settings</Link>
</Button>
</div>
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Repositories"
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>
</div>
}
>
<div className="grid gap-3">
{repositoryItems.map((repo) => (
<RepositoryCard
key={repo.id}
repository={repo}
isRepositoryDirty={isRepositoryDirty(repo)}
areScriptsDirty={areRepositoryScriptsDirty(repo)}
onUpdate={handleUpdateRepository}
onAddScript={handleAddRepositoryScript}
onUpdateScript={handleUpdateRepositoryScript}
onDeleteScript={handleDeleteRepositoryScript}
onSave={handleSaveRepository}
onDelete={handleDeleteRepository}
/>
))}
</div>
</SettingsSection>
<Dialog open={localRepoDialogOpen} onOpenChange={setLocalRepoDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Local Repository</DialogTitle>
<DialogDescription>
Select a discovered repository or provide an absolute path to validate.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Discovered repositories</Label>
<Input
placeholder="Filter repositories..."
value={repoSearch}
onChange={(e) => setRepoSearch(e.target.value)}
/>
<div className="max-h-56 overflow-auto rounded-md border border-border">
{discoverRepositoriesRequest.isLoading ? (
<div className="flex items-center gap-2 p-3 text-sm text-muted-foreground">
<IconLoader2 className="h-4 w-4 animate-spin" />
Scanning repositories...
</div>
) : filteredRepositories.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground">No repositories found.</div>
) : (
filteredRepositories.map((repo) => (
<button
key={repo.path}
type="button"
className={`flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-muted ${
selectedRepoPath === repo.path ? 'bg-muted' : ''
}`}
onClick={() => {
setSelectedRepoPath(repo.path);
setManualRepoPath('');
setManualValidation({ status: 'idle' });
}}
>
<span className="font-medium">{repo.name}</span>
<span className="text-xs text-muted-foreground">{repo.path}</span>
</button>
))
)}
</div>
</div>
<div className="space-y-2">
<Label>Manual path</Label>
<div className="flex items-center gap-2">
<Input
placeholder="/absolute/path/to/repository"
value={manualRepoPath}
onChange={(e) => {
setManualRepoPath(e.target.value);
setSelectedRepoPath(null);
setManualValidation({ status: 'idle' });
}}
/>
<Button
type="button"
variant="outline"
onClick={handleValidateManualPath}
disabled={!manualRepoPath.trim() || validateRepositoryPathRequest.isLoading}
>
{validateRepositoryPathRequest.isLoading ? 'Checking...' : 'Validate'}
</Button>
</div>
{manualValidation.status === 'error' && (
<p className="text-xs text-destructive">{manualValidation.message}</p>
)}
{manualValidation.status === 'success' && (
<p className="text-xs text-emerald-500">{manualValidation.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setLocalRepoDialogOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={handleConfirmLocalRepository} disabled={!canSaveLocalRepo}>
Use Repository
</Button>
</DialogFooter>
</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>
);
}
+6 -12
View File
@@ -18,7 +18,6 @@ import { listRepositories, listRepositoryBranches } from '@/lib/http/client';
import { useRequest } from '@/lib/http/use-request';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStoreApi } from '@/components/state-provider';
import { useAppStore } from '@/components/state-provider';
const AGENTS = [
{ id: 'codex', label: 'Codex' },
@@ -49,11 +48,6 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
// Use task from props but allow updates
const task = initialTask;
// Track task state from store (kanban.tasks) to determine if agent is running
const taskFromStore = useAppStore((state) =>
state.kanban.tasks.find((t) => t.id === task?.id)
);
useEffect(() => {
setIsMounted(true);
}, []);
@@ -92,7 +86,8 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
const branchResponse = await listRepositoryBranches(getBackendConfig().apiBaseUrl, repo.id);
return branchResponse.branches;
}, []);
const branchesRequest = useRequest(fetchBranches);
const { run: runBranches, data: branchesData, isLoading: branchesLoading } =
useRequest(fetchBranches);
// Fetch comments on mount and when task changes
useEffect(() => {
@@ -215,8 +210,8 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
useEffect(() => {
if (!task?.workspace_id || !task.repository_url) return;
branchesRequest.run(task.workspace_id, task.repository_url).catch(() => {});
}, [branchesRequest.run, task?.repository_url, task?.workspace_id]);
runBranches(task.workspace_id, task.repository_url).catch(() => {});
}, [runBranches, task?.repository_url, task?.workspace_id]);
if (!isMounted) {
return <div className="h-screen w-full bg-background" />;
@@ -228,8 +223,8 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
<TaskTopBar
taskTitle={task?.title}
baseBranch={task?.branch ?? undefined}
branches={task?.repository_url ? branchesRequest.data ?? [] : []}
branchesLoading={branchesRequest.isLoading}
branches={task?.repository_url ? branchesData ?? [] : []}
branchesLoading={branchesLoading}
onStartAgent={handleStartAgent}
onStopAgent={handleStopAgent}
isAgentRunning={isAgentRunning}
@@ -285,7 +280,6 @@ export default function TaskPage({ task: initialTask }: TaskPageClientProps) {
<TabsContent value="chat" className="mt-3 flex flex-col min-h-0 flex-1">
{task?.id ? (
<TaskChatPanel
taskId={task.id}
agents={AGENTS}
onSend={handleSendMessage}
isLoading={isLoadingComments}
@@ -90,18 +90,48 @@ export function SettingsAppSidebar() {
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{workspaces.map((workspace) => (
<SidebarMenuSubItem key={workspace.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/workspace/${workspace.id}`}
>
<Link href={`/settings/workspace/${workspace.id}`}>
<span>{workspace.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
{workspaces.map((workspace) => {
const workspacePath = `/settings/workspace/${workspace.id}`;
const boardsPath = `${workspacePath}/boards`;
const repositoriesPath = `${workspacePath}/repositories`;
return (
<SidebarMenuSubItem key={workspace.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === workspacePath}
>
<Link href={workspacePath}>
<span>{workspace.name}</span>
</Link>
</SidebarMenuSubButton>
<SidebarMenuSub className="ml-3">
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === repositoriesPath}
>
<Link href={repositoriesPath}>
<span>Repositories</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === boardsPath}
>
<Link href={boardsPath}>
<span>Boards</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</>
@@ -1,24 +0,0 @@
'use client';
import { Sheet, SheetContent } from '@kandev/ui/sheet';
import { SettingsSidebar } from './settings-sidebar';
type SettingsMobileNavProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function SettingsMobileNav({ open, onOpenChange }: SettingsMobileNavProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-64 p-0">
<div className="pt-6">
<SettingsSidebar
className="border-0"
onNavigate={() => onOpenChange(false)}
/>
</div>
</SheetContent>
</Sheet>
);
}
@@ -1,66 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { IconSettings, IconFolder, IconServer, IconRobot } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
const NAV_ITEMS = [
{
href: '/settings/general',
label: 'General',
icon: IconSettings,
},
{
href: '/settings/workspace',
label: 'Workspace',
icon: IconFolder,
},
{
href: '/settings/environments',
label: 'Environments',
icon: IconServer,
},
{
href: '/settings/agents',
label: 'Agents',
icon: IconRobot,
},
];
type SettingsSidebarProps = {
className?: string;
onNavigate?: () => void;
};
export function SettingsSidebar({ className, onNavigate }: SettingsSidebarProps) {
const pathname = usePathname();
return (
<nav className={cn('w-64 border-r border-border bg-card flex-col p-4', className)}>
<div className="space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</div>
</nav>
);
}
@@ -1,6 +1,6 @@
'use client';
import { memo, useEffect, useMemo, useState } from 'react';
import { memo, useMemo, useState } from 'react';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import { IconArrowBackUp, IconCopy, IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react';
import { useTheme } from 'next-themes';
@@ -22,13 +22,11 @@ const TaskChangesPanel = memo(function TaskChangesPanel({
selectedDiffPath,
onClearSelected,
}: TaskChangesPanelProps) {
const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(DEFAULT_DIFF_MODE);
const [diffViewMode, setDiffViewMode] = useState<'unified' | 'split'>(() =>
getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE)
);
const { resolvedTheme } = useTheme();
useEffect(() => {
setDiffViewMode(getLocalStorage('task-diff-view-mode', DEFAULT_DIFF_MODE));
}, []);
const diffTargets = useMemo(
() => (selectedDiffPath ? [selectedDiffPath] : CHANGED_FILES.map((file) => file.path)),
[selectedDiffPath]
+13 -11
View File
@@ -40,7 +40,6 @@ type AgentOption = {
};
type TaskChatPanelProps = {
taskId: string;
agents: AgentOption[];
onSend: (message: string) => void;
isLoading?: boolean;
@@ -56,25 +55,25 @@ type ToolCallMetadata = {
result?: string;
};
function getToolIcon(toolName?: string) {
function getToolIconElement(toolName?: string, className?: string) {
const name = toolName?.toLowerCase() ?? '';
// Match ACP ToolKind values: read, edit, delete, move, search, execute
if (name === 'edit' || name.includes('edit') || name.includes('replace') || name.includes('write') || name.includes('save')) {
return IconEdit;
return <IconEdit className={className} />;
}
if (name === 'read' || name.includes('view') || name.includes('read')) {
return IconEye;
return <IconEye className={className} />;
}
if (name === 'search' || name.includes('search') || name.includes('find') || name.includes('retrieval')) {
return IconSearch;
return <IconSearch className={className} />;
}
if (name === 'execute' || name.includes('terminal') || name.includes('exec') || name.includes('launch') || name.includes('process')) {
return IconTerminal2;
return <IconTerminal2 className={className} />;
}
if (name === 'delete' || name === 'move' || name.includes('file') || name.includes('create')) {
return IconFile;
return <IconFile className={className} />;
}
return IconCode;
return <IconCode className={className} />;
}
function getStatusIcon(status?: string) {
@@ -100,7 +99,10 @@ function ToolCallCard({ comment }: { comment: Comment }) {
const args = metadata?.args;
const result = metadata?.result;
const ToolIcon = getToolIcon(toolName);
const toolIcon = getToolIconElement(
toolName,
'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0'
);
const hasDetails = args && Object.keys(args).length > 0;
// Extract file path from various possible sources
@@ -128,7 +130,7 @@ function ToolCallCard({ comment }: { comment: Comment }) {
)}
disabled={!hasDetails}
>
<ToolIcon className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
{toolIcon}
<span className="flex-1 font-mono text-xs text-muted-foreground truncate">
{title}
</span>
@@ -200,7 +202,7 @@ function TypingIndicator() {
);
}
export function TaskChatPanel({ taskId, agents, onSend, isLoading, isAgentWorking }: TaskChatPanelProps) {
export function TaskChatPanel({ agents, onSend, isLoading, isAgentWorking }: TaskChatPanelProps) {
const [messageInput, setMessageInput] = useState('');
const [selectedAgent, setSelectedAgent] = useState(agents[0]?.id ?? '');
const [planModeEnabled, setPlanModeEnabled] = useState(false);
-4
View File
@@ -29,10 +29,6 @@ const DEFAULT_RECONNECT_OPTIONS: Required<ReconnectOptions> = {
backoffMultiplier: 1.5,
};
// Heartbeat configuration
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const HEARTBEAT_TIMEOUT = 5000; // 5 seconds
export class WebSocketClient {
private socket: WebSocket | null = null;
private status: WebSocketStatus = 'idle';