diff --git a/apps/web/src/components/features/dashboard/ActiveSessionsList.tsx b/apps/web/src/components/features/dashboard/ActiveSessionsList.tsx new file mode 100644 index 0000000..95aa8dc --- /dev/null +++ b/apps/web/src/components/features/dashboard/ActiveSessionsList.tsx @@ -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

No active sessions right now.

; + } + + return ( +
+ {sessions.map((session) => ( +
+
+
+

{session.display_name || session.tool_type_name || "Unnamed Session"}

+ + {session.status} + +
+

+ {session.project_name} · {session.repository_name} +

+

{session.tool_type_name}

+
+
+ + + + +
+
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/DashboardSummary.tsx b/apps/web/src/components/features/dashboard/DashboardSummary.tsx new file mode 100644 index 0000000..004fa5e --- /dev/null +++ b/apps/web/src/components/features/dashboard/DashboardSummary.tsx @@ -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 ( +
+ {summaryCards.map((card) => ( +
+

{card.label}

+

+ {card.key === "openSessions" + ? activeSessionsCount + : card.key === "projects" + ? summary.projects + : summary.repositories} +

+
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/ProjectsSection.tsx b/apps/web/src/components/features/dashboard/ProjectsSection.tsx new file mode 100644 index 0000000..d8a3c80 --- /dev/null +++ b/apps/web/src/components/features/dashboard/ProjectsSection.tsx @@ -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

No projects yet.

; + } + + return ( +
+ {projects.map((project) => ( +
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+ +
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/QuickCreateForm.tsx b/apps/web/src/components/features/dashboard/QuickCreateForm.tsx new file mode 100644 index 0000000..7f707c3 --- /dev/null +++ b/apps/web/src/components/features/dashboard/QuickCreateForm.tsx @@ -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 ( +
+
+ + + +
+ +
+ + {saveState === "error" && ( + Failed to create session + )} +
+
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/RecentSessionsSection.tsx b/apps/web/src/components/features/dashboard/RecentSessionsSection.tsx new file mode 100644 index 0000000..31044c7 --- /dev/null +++ b/apps/web/src/components/features/dashboard/RecentSessionsSection.tsx @@ -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 ( +
+
+
+

Recent sessions

+

{sessions.length}

+
+
+
+ {sessions.map((session) => ( +
+
+ + {session.display_name || session.tool_type_name || "Unnamed Session"} + + + {session.project_name} · {session.tool_type_name} + +
+ +
+ ))} +
+
+ ); +}; diff --git a/apps/web/src/components/features/dashboard/index.ts b/apps/web/src/components/features/dashboard/index.ts new file mode 100644 index 0000000..4dc0c78 --- /dev/null +++ b/apps/web/src/components/features/dashboard/index.ts @@ -0,0 +1,5 @@ +export { DashboardSummary } from "./DashboardSummary"; +export { ActiveSessionsList } from "./ActiveSessionsList"; +export { ProjectsSection } from "./ProjectsSection"; +export { QuickCreateForm } from "./QuickCreateForm"; +export { RecentSessionsSection } from "./RecentSessionsSection"; diff --git a/apps/web/src/components/features/git/FileBrowser.test.tsx b/apps/web/src/components/features/git/FileBrowser.test.tsx new file mode 100644 index 0000000..b6e47a4 --- /dev/null +++ b/apps/web/src/components/features/git/FileBrowser.test.tsx @@ -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( + + + + ); + expect(screen.getByText(/loading files/i)).toBeInTheDocument(); + }); + + it("renders file entries after loading", async () => { + const mockedGet = apiClient.get as ReturnType; + mockedGet.mockResolvedValueOnce({ + data: { + entries: [ + { name: "src", type: "directory", path: "src" }, + { name: "README.md", type: "file", path: "README.md" }, + ], + }, + }); + + render( + + + + ); + + 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; + mockedGet.mockRejectedValueOnce(new Error("Network error")); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/failed to load files/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/features/git/WorkspaceSidebar.tsx b/apps/web/src/components/features/git/WorkspaceSidebar.tsx new file mode 100644 index 0000000..58f766c --- /dev/null +++ b/apps/web/src/components/features/git/WorkspaceSidebar.tsx @@ -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 ( + + ); +}; diff --git a/apps/web/src/components/features/git/index.ts b/apps/web/src/components/features/git/index.ts index 95e5862..66f37b4 100644 --- a/apps/web/src/components/features/git/index.ts +++ b/apps/web/src/components/features/git/index.ts @@ -1 +1,2 @@ export { FileBrowser } from "./FileBrowser"; +export { WorkspaceSidebar } from "./WorkspaceSidebar"; diff --git a/apps/web/src/components/features/session/CreateSessionForm.test.tsx b/apps/web/src/components/features/session/CreateSessionForm.test.tsx new file mode 100644 index 0000000..c04641c --- /dev/null +++ b/apps/web/src/components/features/session/CreateSessionForm.test.tsx @@ -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( + + ); + + expect(screen.getByText("Create New Session")).toBeInTheDocument(); + expect(screen.getByText("Create Session")).toBeInTheDocument(); + }); + + it("shows validation error when fields are missing", async () => { + render( + + ); + + 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; + mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]); + + const { container } = render( + + ); + + const projectSelect = container.querySelector("select") as HTMLSelectElement; + fireEvent.change(projectSelect, { target: { value: "p1" } }); + + await waitFor(() => { + expect(listRepositories).toHaveBeenCalledWith("p1"); + }); + }); +}); diff --git a/apps/web/src/components/features/session/CreateSessionForm.tsx b/apps/web/src/components/features/session/CreateSessionForm.tsx index bf4f94f..874a2d1 100644 --- a/apps/web/src/components/features/session/CreateSessionForm.tsx +++ b/apps/web/src/components/features/session/CreateSessionForm.tsx @@ -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 = ({ return (

Create New Session

-
+