refactor: extract dashboard, workspace, tool-types, and tool-configs components (Task 4.3)
- Extract DashboardSummary, ActiveSessionsList, ProjectsSection, QuickCreateForm, RecentSessionsSection from dashboard.tsx (480 → 110 lines) - Extract WorkspaceSidebar from repo-workspace.tsx - Extract ToolTypeList + ToolTypeForm from tool-types.tsx (409 → 135 lines) - Extract ToolConfigList + ToolConfigForm from tool-configs.tsx (391 → 178 lines) - Add use-dashboard-actions hook for shared dashboard action handlers - Update feature barrels with new exports Quality gates: tsc (pass), eslint (pass) Refs: repo-restructure Task 4.3
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import type { Session } from "../../../types/session";
|
||||
import { Icon } from "../../../components/icon";
|
||||
|
||||
interface ActiveSessionsListProps {
|
||||
sessions: Session[];
|
||||
actionBusy: string | null;
|
||||
onOpen: (session: Session) => void;
|
||||
onStop: (session: Session) => void;
|
||||
onDelete: (session: Session) => void;
|
||||
onRecreateTunnel: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const ActiveSessionsList = ({
|
||||
sessions,
|
||||
actionBusy,
|
||||
onOpen,
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
}: ActiveSessionsListProps) => {
|
||||
if (sessions.length === 0) {
|
||||
return <p className="muted">No active sessions right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-session-grid">
|
||||
{sessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>{session.display_name || session.tool_type_name || "Unnamed Session"}</h3>
|
||||
<span className={`status-badge ${session.status}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{session.project_name} · {session.repository_name}
|
||||
</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button
|
||||
className="secondary-button small"
|
||||
type="button"
|
||||
onClick={() => onOpen(session)}
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void onRecreateTunnel(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void onStop(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
type="button"
|
||||
onClick={() => void onDelete(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DashboardSummary as DashboardSummaryType } from "../../../api/dashboard";
|
||||
|
||||
interface DashboardSummaryProps {
|
||||
summary: DashboardSummaryType;
|
||||
activeSessionsCount: number;
|
||||
}
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
export const DashboardSummary = ({
|
||||
summary,
|
||||
activeSessionsCount,
|
||||
}: DashboardSummaryProps) => {
|
||||
return (
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessionsCount
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Project } from "../../../types/project";
|
||||
|
||||
interface ProjectsSectionProps {
|
||||
projects: Project[];
|
||||
onOpenProject: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const ProjectsSection = ({
|
||||
projects,
|
||||
onOpenProject,
|
||||
}: ProjectsSectionProps) => {
|
||||
if (projects.length === 0) {
|
||||
return <p className="muted">No projects yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article
|
||||
className="card project-card home-project-card"
|
||||
key={project.id}
|
||||
>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => onOpenProject(project.id)}
|
||||
>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import type { Project } from "../../../types/project";
|
||||
import type { GitRepository } from "../../../types/git-repository";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
import { Icon } from "../../../components/icon";
|
||||
|
||||
interface QuickCreateFormProps {
|
||||
projects: Project[];
|
||||
repositories: GitRepository[];
|
||||
toolTypes: ToolType[];
|
||||
saveState: "idle" | "saving" | "error";
|
||||
onSubmit: (data: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypeId: string;
|
||||
displayName: string;
|
||||
}) => void;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const QuickCreateForm = ({
|
||||
projects,
|
||||
repositories,
|
||||
toolTypes,
|
||||
saveState,
|
||||
onSubmit,
|
||||
onProjectChange,
|
||||
}: QuickCreateFormProps) => {
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setSelectedProject(projectId);
|
||||
setSelectedRepo("");
|
||||
onProjectChange(projectId);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
onSubmit({
|
||||
projectId: selectedProject,
|
||||
repoId: selectedRepo,
|
||||
toolTypeId: selectedToolType,
|
||||
displayName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="stack create-session-form" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(event) => handleProjectChange(event.target.value)}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(event) => setSelectedRepo(event.target.value)}
|
||||
disabled={!selectedProject}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Tool type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(event) => setSelectedToolType(event.target.value)}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Display name
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={saveState === "saving"}
|
||||
>
|
||||
{saveState === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" /> Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" /> Create Session
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{saveState === "error" && (
|
||||
<span className="error-text">Failed to create session</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Session } from "../../../types/session";
|
||||
|
||||
interface RecentSessionsSectionProps {
|
||||
sessions: Session[];
|
||||
onOpen: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const RecentSessionsSection = ({
|
||||
sessions,
|
||||
onOpen,
|
||||
}: RecentSessionsSectionProps) => {
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{sessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">
|
||||
{session.display_name || session.tool_type_name || "Unnamed Session"}
|
||||
</span>
|
||||
<span className="muted">
|
||||
{session.project_name} · {session.tool_type_name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => onOpen(session)}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DashboardSummary } from "./DashboardSummary";
|
||||
export { ActiveSessionsList } from "./ActiveSessionsList";
|
||||
export { ProjectsSection } from "./ProjectsSection";
|
||||
export { QuickCreateForm } from "./QuickCreateForm";
|
||||
export { RecentSessionsSection } from "./RecentSessionsSection";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock("../../../api/client", () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiClient } from "../../../api/client";
|
||||
|
||||
describe("FileBrowser", () => {
|
||||
it("renders loading state initially", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText(/loading files/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file entries after loading", async () => {
|
||||
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
entries: [
|
||||
{ name: "src", type: "directory", path: "src" },
|
||||
{ name: "README.md", type: "file", path: "README.md" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state on failure", async () => {
|
||||
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||
mockedGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { GitRepository } from "../../../types/git-repository";
|
||||
import type { GitStatus } from "../../../api/git_repositories";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { CommitPanel } from "../../../components/commit-panel";
|
||||
import { InstanceList } from "../../../components/instance-list";
|
||||
|
||||
interface WorkspaceSidebarProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
repositories: GitRepository[];
|
||||
gitStatus: GitStatus | null;
|
||||
toolTypes: ToolType[];
|
||||
onRepoChange: (repoId: string) => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
export const WorkspaceSidebar = ({
|
||||
projectId,
|
||||
repoId,
|
||||
repositories,
|
||||
gitStatus,
|
||||
toolTypes,
|
||||
onRepoChange,
|
||||
onCommit,
|
||||
}: WorkspaceSidebarProps) => {
|
||||
return (
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={repoId}
|
||||
onChange={(e) => onRepoChange(e.target.value)}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<FileBrowser
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
gitStatus={gitStatus}
|
||||
/>
|
||||
{gitStatus && (
|
||||
<CommitPanel
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
)}
|
||||
<InstanceList
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export { FileBrowser } from "./FileBrowser";
|
||||
export { WorkspaceSidebar } from "./WorkspaceSidebar";
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { CreateSessionForm } from "./CreateSessionForm";
|
||||
|
||||
vi.mock("@/api/git_repositories", () => ({
|
||||
listRepositories: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sessions", () => ({
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/settings", () => ({
|
||||
updateUserConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listRepositories } from "@/api/git_repositories";
|
||||
import { createInstance } from "@/api/sessions";
|
||||
|
||||
const mockProjects = [
|
||||
{ id: "p1", name: "Project One", description: null, owner_id: "u1", default_ssh_key_id: null },
|
||||
{ id: "p2", name: "Project Two", description: null, owner_id: "u1", default_ssh_key_id: null },
|
||||
];
|
||||
|
||||
const mockToolTypes = [
|
||||
{ id: "t1", name: "vscode", display_name: "VS Code", description: null, category: "editor", interfaces: ["web"] as string[], default_port: 8443, definition_type: "compose" as const, compose_template: "", dockerfile_template: null, readiness_probe: null, required_variables: [], is_builtin: true },
|
||||
{ id: "t2", name: "terminal", display_name: "Terminal", description: null, category: "shell", interfaces: ["terminal"] as string[], default_port: 22, definition_type: "dockerfile" as const, compose_template: null, dockerfile_template: "", readiness_probe: null, required_variables: [], is_builtin: true },
|
||||
];
|
||||
|
||||
describe("CreateSessionForm", () => {
|
||||
it("renders form with create button", () => {
|
||||
render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Create New Session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create Session")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows validation error when fields are missing", async () => {
|
||||
render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Create Session"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/project, repository, and tool type are required/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(createInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads repositories when project selected", async () => {
|
||||
const mockedList = listRepositories as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
|
||||
|
||||
const { container } = render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const projectSelect = container.querySelector("select") as HTMLSelectElement;
|
||||
fireEvent.change(projectSelect, { target: { value: "p1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRepositories).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { listRepositories } from "@/api/git_repositories";
|
||||
import {
|
||||
createInstance,
|
||||
startInstance,
|
||||
} from "@/api/sessions";
|
||||
import { createInstance, startInstance } from "@/api/sessions";
|
||||
import { updateUserConfig } from "@/api/settings";
|
||||
import { Icon } from "@/components/icon";
|
||||
import type { Project } from "@/types/project";
|
||||
@@ -81,10 +78,7 @@ export const CreateSessionForm: React.FC<CreateSessionFormProps> = ({
|
||||
return (
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
className="card stack create-session-form"
|
||||
>
|
||||
<form onSubmit={handleCreate} className="card stack create-session-form">
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SessionCard } from "./SessionCard";
|
||||
import type { Session } from "@/types/session";
|
||||
|
||||
const mockSession: Session = {
|
||||
id: "s1",
|
||||
display_name: "Dev Environment",
|
||||
tool_type_name: "VS Code",
|
||||
tool_icon: "code",
|
||||
tool_type_interfaces: ["web", "terminal"],
|
||||
repository_name: "my-repo",
|
||||
repository_id: "r1",
|
||||
project_name: "My Project",
|
||||
project_id: "p1",
|
||||
status: "running",
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
describe("SessionCard", () => {
|
||||
it("renders active variant with display name and status", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={mockSession}
|
||||
variant="active"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Dev Environment")).toBeInTheDocument();
|
||||
expect(screen.getByText("running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders recent variant with display name", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={{ ...mockSession, status: "stopped" }}
|
||||
variant="recent"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Dev Environment")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows unnamed fallback when display_name is empty", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={{ ...mockSession, display_name: "" }}
|
||||
variant="active"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("VS Code")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,11 @@ import type { Session } from "@/types/session";
|
||||
interface SessionCardProps {
|
||||
session: Session;
|
||||
variant: "active" | "recent";
|
||||
tunnelHealth?: { healthy: boolean; status_code: number | null; error?: string } | null;
|
||||
tunnelHealth?: {
|
||||
healthy: boolean;
|
||||
status_code: number | null;
|
||||
error?: string;
|
||||
} | null;
|
||||
isRecreating?: boolean;
|
||||
isStopConfirming?: boolean;
|
||||
isDeleteConfirming?: boolean;
|
||||
@@ -31,7 +35,8 @@ export const SessionCard: React.FC<SessionCardProps> = ({
|
||||
onCancelStop,
|
||||
onCancelDelete,
|
||||
}) => {
|
||||
const displayName = session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
const displayName =
|
||||
session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
|
||||
if (variant === "recent") {
|
||||
return (
|
||||
@@ -107,7 +112,9 @@ export const SessionCard: React.FC<SessionCardProps> = ({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>{session.status}</span>
|
||||
<span className={`status-badge ${session.status}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
{tunnelHealth && !tunnelHealth.healthy && (
|
||||
<span className="status-badge error">tunnel error</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolConfig } from "../../../types/tool-config";
|
||||
|
||||
interface ToolConfigFormProps {
|
||||
editingConfig: ToolConfig | null;
|
||||
onSubmit: (data: {
|
||||
key: string;
|
||||
value: string;
|
||||
config_type: string;
|
||||
file_path: string;
|
||||
}) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ToolConfigForm = ({ editingConfig, onSubmit, onCancel }: ToolConfigFormProps) => {
|
||||
const [formData, setFormData] = useState({
|
||||
key: editingConfig?.key ?? "",
|
||||
value: editingConfig?.value ?? "",
|
||||
config_type: editingConfig?.config_type ?? "env",
|
||||
file_path: editingConfig?.file_path ?? "",
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
setSaveStatus("saved");
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card stack">
|
||||
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div>
|
||||
<label htmlFor="config-key">Key</label>
|
||||
<input
|
||||
id="config-key"
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
placeholder="e.g., OPENAI_API_KEY"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="config-type">Type</label>
|
||||
<select
|
||||
id="config-type"
|
||||
value={formData.config_type}
|
||||
onChange={(e) => setFormData({ ...formData, config_type: e.target.value })}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="env">Environment Variable</option>
|
||||
<option value="file">Configuration File</option>
|
||||
</select>
|
||||
</div>
|
||||
{formData.config_type === "file" && (
|
||||
<div>
|
||||
<label htmlFor="config-file-path">File Path</label>
|
||||
<input
|
||||
id="config-file-path"
|
||||
type="text"
|
||||
value={formData.file_path}
|
||||
onChange={(e) => setFormData({ ...formData, file_path: e.target.value })}
|
||||
placeholder="e.g., /app/config.json"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="config-value">Value</label>
|
||||
<textarea
|
||||
id="config-value"
|
||||
value={formData.value}
|
||||
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
||||
placeholder={formData.config_type === "env" ? "Enter value..." : "Enter file contents..."}
|
||||
className="form-input"
|
||||
rows={formData.config_type === "file" ? 8 : 2}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="primary-button">
|
||||
{editingConfig ? "Update" : "Add"} Config
|
||||
</button>
|
||||
</div>
|
||||
{saveStatus === "saved" && (
|
||||
<p className="text-success" style={{ textAlign: "right" }}>
|
||||
Saved successfully!
|
||||
</p>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<p className="text-error" style={{ textAlign: "right" }}>
|
||||
Failed to save. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Icon } from "../../../components/icon";
|
||||
import type { ToolConfig } from "../../../types/tool-config";
|
||||
|
||||
interface ToolConfigListProps {
|
||||
configs: ToolConfig[];
|
||||
onEdit: (config: ToolConfig) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToolConfigList = ({
|
||||
configs,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ToolConfigListProps) => {
|
||||
if (configs.length === 0) {
|
||||
return <p className="muted">No configurations for this tool yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.75rem 1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "center" }}
|
||||
>
|
||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
background:
|
||||
config.config_type === "env"
|
||||
? "var(--color-info)"
|
||||
: "var(--color-warning)",
|
||||
color: "white",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
>
|
||||
{config.config_type}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
|
||||
>
|
||||
{config.config_type === "file" && config.file_path
|
||||
? `File: ${config.file_path}`
|
||||
: "Environment variable"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => onEdit(config)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void onDelete(config.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToolConfigForm } from "./ToolConfigForm";
|
||||
export { ToolConfigList } from "./ToolConfigList";
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/icon";
|
||||
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../../../types/tool-type";
|
||||
|
||||
interface ToolTypeFormProps {
|
||||
mode: "create" | "edit";
|
||||
toolType?: ToolType | null;
|
||||
onSubmit: (input: CreateToolTypeRequest | UpdateToolTypeRequest) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ToolTypeForm = ({ mode, toolType, onSubmit, onCancel }: ToolTypeFormProps) => {
|
||||
const [formName, setFormName] = useState(toolType?.name ?? "");
|
||||
const [formDisplayName, setFormDisplayName] = useState(toolType?.display_name ?? "");
|
||||
const [formDescription, setFormDescription] = useState(toolType?.description ?? "");
|
||||
const [formCategory, setFormCategory] = useState(toolType?.category ?? "");
|
||||
const [formInterfaces, setFormInterfaces] = useState<string[]>(toolType?.interfaces ?? []);
|
||||
const [formPort, setFormPort] = useState(toolType?.default_port?.toString() ?? "");
|
||||
const [formTemplate, setFormTemplate] = useState(toolType?.compose_template ?? "");
|
||||
const [formVariables, setFormVariables] = useState(toolType?.required_variables?.join(", ") ?? "");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
|
||||
setFormError("Name, display name, and compose template are required");
|
||||
return;
|
||||
}
|
||||
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||
setFormError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = formVariables.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
|
||||
const base = {
|
||||
display_name: formDisplayName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
category: formCategory.trim() || undefined,
|
||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||
default_port: Number(formPort),
|
||||
compose_template: formTemplate.trim(),
|
||||
required_variables: variables,
|
||||
};
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await onSubmit({ name: formName.trim(), ...base } as CreateToolTypeRequest);
|
||||
} else {
|
||||
await onSubmit(base as UpdateToolTypeRequest);
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setFormError(axiosError?.response?.data?.detail || "Failed to save tool type");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInterface = (iface: string) => {
|
||||
setFormInterfaces((prev) =>
|
||||
prev.includes(iface) ? prev.filter((i) => i !== iface) : [...prev, iface],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<h2>{mode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name (unique identifier)</label>
|
||||
<input type="text" value={formName} onChange={(e) => setFormName(e.target.value)} disabled={mode === "edit"} placeholder="e.g., code-server" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Display Name</label>
|
||||
<input type="text" value={formDisplayName} onChange={(e) => setFormDisplayName(e.target.value)} placeholder="e.g., VS Code Server" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Optional description" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Category</label>
|
||||
<input type="text" value={formCategory} onChange={(e) => setFormCategory(e.target.value)} placeholder="e.g., editor, notebook, ai-assistant" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={formInterfaces.includes("web")} onChange={() => toggleInterface("web")} /> Web
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={formInterfaces.includes("terminal")} onChange={() => toggleInterface("terminal")} /> Terminal
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Default Port *</label>
|
||||
<input type="number" value={formPort} onChange={(e) => setFormPort(e.target.value)} placeholder="e.g., 8443" required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Compose Template (YAML)</label>
|
||||
<textarea value={formTemplate} onChange={(e) => setFormTemplate(e.target.value)} rows={10} placeholder="version: '3.8' services: app: image: ..." />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Required Variables (comma-separated)</label>
|
||||
<input type="text" value={formVariables} onChange={(e) => setFormVariables(e.target.value)} placeholder="REPO_PATH, TOOL_NAME" />
|
||||
</div>
|
||||
{formError && <p className="text-error">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">
|
||||
<Icon name={mode === "create" ? "add" : "save"} size="sm" />
|
||||
{mode === "create" ? "Create" : "Update"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="button-secondary">
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Icon } from "../../../components/icon";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
|
||||
interface ToolTypeListProps {
|
||||
toolTypes: ToolType[];
|
||||
onEdit: (toolType: ToolType) => void;
|
||||
onDelete: (id: string) => void;
|
||||
deleteConfirmId: string | null;
|
||||
setDeleteConfirmId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ToolTypeList = ({
|
||||
toolTypes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
}: ToolTypeListProps) => {
|
||||
if (toolTypes.length === 0) {
|
||||
return <p>No tool types found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-grid">
|
||||
{toolTypes.map((toolType) => (
|
||||
<div key={toolType.id} className="card">
|
||||
<div className="card-header">
|
||||
<h3>{toolType.display_name}</h3>
|
||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||
</div>
|
||||
<p className="text-secondary">
|
||||
{toolType.description || "No description"}
|
||||
</p>
|
||||
<div className="tool-type-meta">
|
||||
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
)}
|
||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{!toolType.is_builtin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onEdit(toolType)}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === toolType.id && (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<p>
|
||||
Delete tool type "{toolType.display_name}"?
|
||||
</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
onClick={() => onDelete(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button onClick={() => setDeleteConfirmId(null)}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToolTypeForm } from "./ToolTypeForm";
|
||||
export { ToolTypeList } from "./ToolTypeList";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ToolTypesTab } from "./ToolTypesTab";
|
||||
|
||||
vi.mock("../../../api/tool_types", () => ({
|
||||
listToolTypes: vi.fn(),
|
||||
createToolType: vi.fn(),
|
||||
deleteToolType: vi.fn(),
|
||||
updateToolType: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listToolTypes } from "../../../api/tool_types";
|
||||
|
||||
describe("ToolTypesTab", () => {
|
||||
it("renders loading state initially", () => {
|
||||
render(<ToolTypesTab />);
|
||||
expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tool types heading after loading", async () => {
|
||||
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockResolvedValueOnce([
|
||||
{
|
||||
id: "1",
|
||||
name: "code-server",
|
||||
display_name: "VS Code Server",
|
||||
description: "Web-based IDE",
|
||||
category: "editor",
|
||||
interfaces: ["web"],
|
||||
default_port: 8443,
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\nservices:\n app:",
|
||||
dockerfile_template: null,
|
||||
readiness_probe: null,
|
||||
required_variables: [],
|
||||
is_builtin: true,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolTypesTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tool Types")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state on failure", async () => {
|
||||
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ToolTypesTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load tool types/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user