feat: improved settings repos, boards
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user