feat: workspace-first UI refresh - PR-3 projects page + routing cleanup
- Rewrite ProjectsPage with inline repository and workspace display - Expandable project cards showing repos + workspace chips - Inline workspace creation from project page (New Workspace button per repo) - Workspace chips link to workspace detail page - Sync/delete actions on workspace chips - Update Project type: add ProjectWithRepos, RepositorySummary, WorkspaceSummary - Update listProjects API to return ProjectWithRepos[] - Update dashboard, sessions, config-profiles to use ProjectWithRepos - Remove old /projects/:projectId route (RepoWorkspace) - Add chevron icons to Icon component - Projects page CSS: project-toggle, repo-block, workspace-grid, workspace-chip - TypeScript + eslint clean Quality gates: tsc --noEmit clean, eslint clean
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
import type { Project } from "../types";
|
import type { Project, ProjectWithRepos } from "../types";
|
||||||
|
|
||||||
export type ProjectCreateInput = {
|
export type ProjectCreateInput = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,8 +15,8 @@ export type SetDefaultSSHKeyInput = {
|
|||||||
ssh_key_id: string;
|
ssh_key_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const listProjects = async (): Promise<Project[]> => {
|
export const listProjects = async (): Promise<ProjectWithRepos[]> => {
|
||||||
const response = await apiClient.get<Project[]>("/projects");
|
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
DotsSixVertical,
|
DotsSixVertical,
|
||||||
Bell,
|
Bell,
|
||||||
|
CaretDown,
|
||||||
|
CaretRight,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
@@ -79,7 +81,9 @@ export type IconName =
|
|||||||
| "terminal"
|
| "terminal"
|
||||||
| "arrow-left"
|
| "arrow-left"
|
||||||
| "drag"
|
| "drag"
|
||||||
| "bell";
|
| "bell"
|
||||||
|
| "chevron-down"
|
||||||
|
| "chevron-right";
|
||||||
|
|
||||||
const iconMap: Record<
|
const iconMap: Record<
|
||||||
IconName,
|
IconName,
|
||||||
@@ -129,6 +133,8 @@ const iconMap: Record<
|
|||||||
"arrow-left": ArrowLeft,
|
"arrow-left": ArrowLeft,
|
||||||
drag: DotsSixVertical,
|
drag: DotsSixVertical,
|
||||||
bell: Bell,
|
bell: Bell,
|
||||||
|
"chevron-down": CaretDown,
|
||||||
|
"chevron-right": CaretRight,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type ResolvedProfile,
|
type ResolvedProfile,
|
||||||
} from "../api/config_profiles";
|
} from "../api/config_profiles";
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { ProjectWithRepos } from "../types";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { GitMountEditor } from "../components/git-mount-editor";
|
import { GitMountEditor } from "../components/git-mount-editor";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export const ConfigProfilesPage = () => {
|
|||||||
const [mobileView, setMobileView] = useState<MobileView>("list");
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
|
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { listProjects } from "../api/projects";
|
|||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||||
import { updateUserConfig } from "../api/settings";
|
import { updateUserConfig } from "../api/settings";
|
||||||
import type { Project } from "../types";
|
import type { ProjectWithRepos } from "../types";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { CreateSessionForm } from "../components/create-session-form";
|
import { CreateSessionForm } from "../components/create-session-form";
|
||||||
import { SessionList } from "../components/session-list";
|
import { SessionList } from "../components/session-list";
|
||||||
@@ -28,7 +28,7 @@ export const HomePage = () => {
|
|||||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState("");
|
const [selectedProject, setSelectedProject] = useState("");
|
||||||
|
|||||||
+446
-189
@@ -1,216 +1,473 @@
|
|||||||
|
/** Projects page with inline repositories and workspaces. */
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createProject,
|
createProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
listProjects,
|
listProjects,
|
||||||
updateProject,
|
updateProject,
|
||||||
type ProjectCreateInput,
|
type ProjectCreateInput,
|
||||||
type ProjectUpdateInput,
|
type ProjectUpdateInput,
|
||||||
} from "../api/projects";
|
} from "../api/projects";
|
||||||
|
import {
|
||||||
|
createWorkspace,
|
||||||
|
deleteWorkspace,
|
||||||
|
syncWorkspace,
|
||||||
|
} from "../api/workspaces";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
import { WorkspaceCreateForm } from "../components/workspace-create-form";
|
||||||
import { useAsyncData } from "../hooks/use-async-data";
|
import { useAsyncData } from "../hooks/use-async-data";
|
||||||
import type { Project } from "../types";
|
import type { ProjectWithRepos, WorkspaceSummary } from "../types";
|
||||||
|
|
||||||
type DialogMode = "none" | "create" | "edit";
|
type DialogMode = "none" | "create" | "edit";
|
||||||
|
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
const { data: projects, status, reload } = useAsyncData<Project[]>(listProjects, []);
|
const { data: projects, status, reload } = useAsyncData<ProjectWithRepos[]>(
|
||||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
listProjects,
|
||||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
[],
|
||||||
const [formName, setFormName] = useState("");
|
);
|
||||||
const [formDescription, setFormDescription] = useState("");
|
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [editingProject, setEditingProject] = useState<ProjectWithRepos | null>(
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
const [formName, setFormName] = useState("");
|
||||||
|
const [formDescription, setFormDescription] = useState("");
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [expandedProject, setExpandedProject] = useState<string | null>(null);
|
||||||
|
const [creatingWorkspace, setCreatingWorkspace] = useState<{
|
||||||
|
projectId: string;
|
||||||
|
repoId: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [workspaceLoading, setWorkspaceLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
const safeProjects = projects ?? [];
|
const safeProjects = projects ?? [];
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setFormName("");
|
setFormName("");
|
||||||
setFormDescription("");
|
setFormDescription("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingProject(null);
|
setEditingProject(null);
|
||||||
setDialogMode("create");
|
setDialogMode("create");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (project: Project) => {
|
const openEdit = (project: ProjectWithRepos) => {
|
||||||
setFormName(project.name);
|
setFormName(project.name);
|
||||||
setFormDescription(project.description ?? "");
|
setFormDescription(project.description ?? "");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingProject(project);
|
setEditingProject(project);
|
||||||
setDialogMode("edit");
|
setDialogMode("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogMode("none");
|
setDialogMode("none");
|
||||||
setEditingProject(null);
|
setEditingProject(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
if (!formName.trim()) {
|
if (!formName.trim()) {
|
||||||
setFormError("Project name is required");
|
setFormError("Project name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (dialogMode === "create") {
|
if (dialogMode === "create") {
|
||||||
const input: ProjectCreateInput = {
|
const input: ProjectCreateInput = {
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
description: formDescription.trim() || null,
|
description: formDescription.trim() || null,
|
||||||
};
|
};
|
||||||
await createProject(input);
|
await createProject(input);
|
||||||
} else if (dialogMode === "edit" && editingProject) {
|
} else if (dialogMode === "edit" && editingProject) {
|
||||||
const input: ProjectUpdateInput = {
|
const input: ProjectUpdateInput = {
|
||||||
name: formName.trim(),
|
name: formName.trim(),
|
||||||
description: formDescription.trim() || null,
|
description: formDescription.trim() || null,
|
||||||
};
|
};
|
||||||
await updateProject(editingProject.id, input);
|
await updateProject(editingProject.id, input);
|
||||||
}
|
}
|
||||||
closeDialog();
|
closeDialog();
|
||||||
reload();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setFormError("Failed to save project");
|
setFormError("Failed to save project");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (projectId: string) => {
|
const handleDelete = async (projectId: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteProject(projectId);
|
await deleteProject(projectId);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
reload();
|
reload();
|
||||||
} catch {
|
} catch {
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmpty = status === "ready" && safeProjects.length === 0;
|
const handleCreateWorkspace = async (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
data: { name: string; branch: string },
|
||||||
|
) => {
|
||||||
|
setWorkspaceLoading(repoId);
|
||||||
|
try {
|
||||||
|
await createWorkspace(projectId, repoId, data);
|
||||||
|
setCreatingWorkspace(null);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to create workspace");
|
||||||
|
} finally {
|
||||||
|
setWorkspaceLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const handleSyncWorkspace = async (
|
||||||
<section className="stack">
|
projectId: string,
|
||||||
<div className="page-header">
|
repoId: string,
|
||||||
<h1>Projects</h1>
|
workspace: WorkspaceSummary,
|
||||||
<button className="primary-button" onClick={openCreate} type="button">
|
) => {
|
||||||
<Icon name="add" size="sm" />
|
setWorkspaceLoading(workspace.id);
|
||||||
New Project
|
try {
|
||||||
</button>
|
await syncWorkspace(projectId, repoId, workspace.id);
|
||||||
</div>
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to sync workspace");
|
||||||
|
} finally {
|
||||||
|
setWorkspaceLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{status === "loading" && <LoadingState message="Loading projects..." />}
|
const handleDeleteWorkspace = async (
|
||||||
|
projectId: string,
|
||||||
|
repoId: string,
|
||||||
|
workspace: WorkspaceSummary,
|
||||||
|
) => {
|
||||||
|
if (!confirm(`Delete workspace "${workspace.name}"?`)) return;
|
||||||
|
setWorkspaceLoading(workspace.id);
|
||||||
|
try {
|
||||||
|
await deleteWorkspace(projectId, repoId, workspace.id);
|
||||||
|
reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err instanceof Error ? err.message : "Failed to delete workspace");
|
||||||
|
} finally {
|
||||||
|
setWorkspaceLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{status === "error" && <ErrorState message="Failed to load projects" onRetry={reload} />}
|
const isEmpty = status === "ready" && safeProjects.length === 0;
|
||||||
|
|
||||||
{isEmpty && <EmptyState message="No projects yet. Create your first project above." />}
|
return (
|
||||||
|
<section className="stack">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Projects</h1>
|
||||||
|
<button className="primary-button" onClick={openCreate} type="button">
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{status === "ready" && safeProjects.length > 0 && (
|
{status === "loading" && <LoadingState message="Loading projects..." />}
|
||||||
<div className="project-list">
|
|
||||||
{safeProjects.map((project) => (
|
|
||||||
<article className="card project-card" key={project.id}>
|
|
||||||
<div className="project-info">
|
|
||||||
<h3>{project.name}</h3>
|
|
||||||
{project.description && <p className="muted">{project.description}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="project-actions">
|
|
||||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
|
||||||
Open Workspace
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => openEdit(project)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="edit" size="sm" />
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
{deleteConfirmId === project.id ? (
|
|
||||||
<div className="delete-confirm">
|
|
||||||
<span>Are you sure?</span>
|
|
||||||
<button
|
|
||||||
className="danger-button"
|
|
||||||
onClick={() => void handleDelete(project.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => setDeleteConfirmId(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="cancel" size="sm" />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="ghost-button danger-text"
|
|
||||||
onClick={() => setDeleteConfirmId(project.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="sm" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dialogMode !== "none" && (
|
{status === "error" && (
|
||||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
<ErrorState message="Failed to load projects" onRetry={reload} />
|
||||||
<div className="dialog">
|
)}
|
||||||
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
|
|
||||||
<form onSubmit={handleSubmit} className="stack">
|
{isEmpty && (
|
||||||
<label className="form-field">
|
<EmptyState message="No projects yet. Create your first project above." />
|
||||||
Name
|
)}
|
||||||
<input
|
|
||||||
type="text"
|
{status === "ready" && safeProjects.length > 0 && (
|
||||||
value={formName}
|
<div className="project-list">
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
{safeProjects.map((project) => (
|
||||||
placeholder="Project name"
|
<ProjectCard
|
||||||
/>
|
key={project.id}
|
||||||
</label>
|
project={project}
|
||||||
<label className="form-field">
|
expanded={expandedProject === project.id}
|
||||||
Description
|
onToggle={() =>
|
||||||
<textarea
|
setExpandedProject(
|
||||||
value={formDescription}
|
expandedProject === project.id ? null : project.id,
|
||||||
onChange={(e) => setFormDescription(e.target.value)}
|
)
|
||||||
placeholder="Optional description"
|
}
|
||||||
rows={3}
|
onEdit={() => openEdit(project)}
|
||||||
/>
|
onDelete={() => setDeleteConfirmId(project.id)}
|
||||||
</label>
|
deleteConfirm={deleteConfirmId === project.id}
|
||||||
{formError && <p className="error-text">{formError}</p>}
|
onConfirmDelete={() => void handleDelete(project.id)}
|
||||||
<div className="dialog-actions">
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
<button className="secondary-button" onClick={closeDialog} type="button">
|
onCreateWorkspace={(repoId) =>
|
||||||
<Icon name="cancel" size="sm" />
|
setCreatingWorkspace({ projectId: project.id, repoId })
|
||||||
Cancel
|
}
|
||||||
</button>
|
onWorkspaceAction={(repoId, workspace, action) => {
|
||||||
<button className="primary-button" type="submit">
|
if (action === "sync") {
|
||||||
{dialogMode === "create" ? (
|
void handleSyncWorkspace(project.id, repoId, workspace);
|
||||||
<>
|
} else if (action === "delete") {
|
||||||
<Icon name="add" size="sm" />
|
void handleDeleteWorkspace(
|
||||||
Create
|
project.id,
|
||||||
</>
|
repoId,
|
||||||
) : (
|
workspace,
|
||||||
<>
|
);
|
||||||
<Icon name="save" size="sm" />
|
}
|
||||||
Save
|
}}
|
||||||
</>
|
workspaceLoading={workspaceLoading}
|
||||||
)}
|
showCreateForm={
|
||||||
</button>
|
creatingWorkspace?.projectId === project.id
|
||||||
</div>
|
? creatingWorkspace.repoId
|
||||||
</form>
|
: null
|
||||||
</div>
|
}
|
||||||
</div>
|
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||||
)}
|
onSubmitCreate={async (repoId, data) =>
|
||||||
</section>
|
await handleCreateWorkspace(project.id, repoId, data)
|
||||||
);
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dialogMode !== "none" && (
|
||||||
|
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="dialog">
|
||||||
|
<h2>
|
||||||
|
{dialogMode === "create" ? "Create Project" : "Edit Project"}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="stack">
|
||||||
|
<label className="form-field">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
placeholder="Project name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="form-field">
|
||||||
|
Description
|
||||||
|
<textarea
|
||||||
|
value={formDescription}
|
||||||
|
onChange={(e) => setFormDescription(e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{formError && <p className="error-text">{formError}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={closeDialog}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="submit">
|
||||||
|
{dialogMode === "create" ? (
|
||||||
|
<>
|
||||||
|
<Icon name="add" size="sm" />
|
||||||
|
Create
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="save" size="sm" />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ─── Project Card ─── */
|
||||||
|
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
deleteConfirm,
|
||||||
|
onConfirmDelete,
|
||||||
|
onCancelDelete,
|
||||||
|
onCreateWorkspace,
|
||||||
|
onWorkspaceAction,
|
||||||
|
workspaceLoading,
|
||||||
|
showCreateForm,
|
||||||
|
onCancelCreate,
|
||||||
|
onSubmitCreate,
|
||||||
|
}: {
|
||||||
|
project: ProjectWithRepos;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
deleteConfirm: boolean;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onCreateWorkspace: (repoId: string) => void;
|
||||||
|
onWorkspaceAction: (
|
||||||
|
repoId: string,
|
||||||
|
workspace: WorkspaceSummary,
|
||||||
|
action: "sync" | "delete",
|
||||||
|
) => void;
|
||||||
|
workspaceLoading: string | null;
|
||||||
|
onCancelCreate: () => void;
|
||||||
|
showCreateForm: string | null;
|
||||||
|
onSubmitCreate: (repoId: string, data: { name: string; branch: string }) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="card project-card">
|
||||||
|
<div className="project-info-row">
|
||||||
|
<button
|
||||||
|
className="project-toggle"
|
||||||
|
onClick={onToggle}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name={expanded ? "chevron-down" : "chevron-right"}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<h3>{project.name}</h3>
|
||||||
|
{project.repositories.length > 0 && (
|
||||||
|
<span className="repo-count">
|
||||||
|
{project.repositories.length} repo
|
||||||
|
{project.repositories.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="project-actions">
|
||||||
|
<button className="ghost-button" onClick={onEdit} type="button">
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{deleteConfirm ? (
|
||||||
|
<div className="delete-confirm">
|
||||||
|
<span>Are you sure?</span>
|
||||||
|
<button
|
||||||
|
className="danger-button"
|
||||||
|
onClick={onConfirmDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={onCancelDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="cancel" size="sm" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost-button danger-text"
|
||||||
|
onClick={onDelete}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="project-detail">
|
||||||
|
{project.repositories.length === 0 ? (
|
||||||
|
<p className="muted">No repositories yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="repo-list">
|
||||||
|
{project.repositories.map((repo) => (
|
||||||
|
<div key={repo.id} className="repo-block">
|
||||||
|
<div className="repo-header">
|
||||||
|
<h4>{repo.name}</h4>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => onCreateWorkspace(repo.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="add" size="sm" /> New Workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showCreateForm === repo.id && (
|
||||||
|
<WorkspaceCreateForm
|
||||||
|
projectId={project.id}
|
||||||
|
repoId={repo.id}
|
||||||
|
onSubmit={(data) =>
|
||||||
|
onSubmitCreate(repo.id, data)
|
||||||
|
}
|
||||||
|
onCancel={onCancelCreate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{repo.workspaces.length === 0 ? (
|
||||||
|
<p className="muted">No workspaces.</p>
|
||||||
|
) : (
|
||||||
|
<div className="workspace-grid">
|
||||||
|
{repo.workspaces.map((ws) => (
|
||||||
|
<div
|
||||||
|
key={ws.id}
|
||||||
|
className={`workspace-chip ${ws.status}`}
|
||||||
|
>
|
||||||
|
<a href={`/workspaces/${ws.id}`}>{ws.name}</a>
|
||||||
|
<span className="ws-branch">
|
||||||
|
<Icon name="branch" size="sm" /> {ws.branch}
|
||||||
|
</span>
|
||||||
|
{ws.instance_count > 0 && (
|
||||||
|
<span className="ws-instances">
|
||||||
|
{ws.instance_count} tool
|
||||||
|
{ws.instance_count > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ws-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
workspaceLoading === ws.id
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
onWorkspaceAction(
|
||||||
|
repo.id,
|
||||||
|
ws,
|
||||||
|
"sync",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-text"
|
||||||
|
disabled={
|
||||||
|
workspaceLoading === ws.id
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
onWorkspaceAction(
|
||||||
|
repo.id,
|
||||||
|
ws,
|
||||||
|
"delete",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { listProjects } from "../api/projects";
|
import { listProjects } from "../api/projects";
|
||||||
import type { Project } from "../types";
|
import type { ProjectWithRepos } from "../types";
|
||||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||||
import {
|
import {
|
||||||
getUserSessions,
|
getUserSessions,
|
||||||
@@ -24,7 +24,7 @@ export const SessionsPage = () => {
|
|||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ProjectsPage } from "./pages/projects";
|
|||||||
import { GitRepositoriesPage } from "./pages/git-repositories";
|
import { GitRepositoriesPage } from "./pages/git-repositories";
|
||||||
import { GitHistoryPage } from "./pages/git-history";
|
import { GitHistoryPage } from "./pages/git-history";
|
||||||
import { ProjectSettingsPage } from "./pages/project-settings";
|
import { ProjectSettingsPage } from "./pages/project-settings";
|
||||||
import { RepoWorkspace } from "./pages/repo-workspace";
|
|
||||||
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
import { SettingsPage, GeneralSettingsTab } from "./pages/settings";
|
||||||
import { TerminalPage } from "./pages/terminal";
|
import { TerminalPage } from "./pages/terminal";
|
||||||
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
import { ToolWorkshopPage } from "./pages/tool-workshop";
|
||||||
@@ -37,7 +36,6 @@ export const AppRouter = () => {
|
|||||||
>
|
>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:projectId" element={<RepoWorkspace />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="projects/:projectId/repositories"
|
path="projects/:projectId/repositories"
|
||||||
element={<GitRepositoriesPage />}
|
element={<GitRepositoriesPage />}
|
||||||
|
|||||||
@@ -5322,3 +5322,135 @@ a:active,
|
|||||||
.workspace-header-link:hover .workspace-header h4 {
|
.workspace-header-link:hover .workspace-header h4 {
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Projects Page Refresh ─── */
|
||||||
|
|
||||||
|
.project-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-toggle:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-toggle h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-count {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip a {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-branch {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-instances {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip .ws-actions button.danger-text:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,3 +16,28 @@ export type Project = {
|
|||||||
owner_id: string;
|
owner_id: string;
|
||||||
default_ssh_key_id: string | null;
|
default_ssh_key_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkspaceSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
branch: string;
|
||||||
|
status: string;
|
||||||
|
instance_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepositorySummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
remote_url: string;
|
||||||
|
workspaces: WorkspaceSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectWithRepos = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
owner_id: string;
|
||||||
|
default_ssh_key_id: string | null;
|
||||||
|
repositories: RepositorySummary[];
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|||||||
+1
-180
@@ -1,180 +1 @@
|
|||||||
import {
|
export type { IconName } from "../components/icon";
|
||||||
House,
|
|
||||||
Folder,
|
|
||||||
GitBranch,
|
|
||||||
Gear,
|
|
||||||
User,
|
|
||||||
SignOut,
|
|
||||||
Plus,
|
|
||||||
PencilSimple,
|
|
||||||
Trash,
|
|
||||||
FloppyDisk,
|
|
||||||
X,
|
|
||||||
ArrowsClockwise,
|
|
||||||
Copy,
|
|
||||||
MagnifyingGlass,
|
|
||||||
List,
|
|
||||||
Check,
|
|
||||||
Warning,
|
|
||||||
Info,
|
|
||||||
Spinner,
|
|
||||||
GitCommit,
|
|
||||||
GitMerge,
|
|
||||||
ClockCounterClockwise,
|
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
File,
|
|
||||||
FileText,
|
|
||||||
Image,
|
|
||||||
Binary,
|
|
||||||
Code,
|
|
||||||
ArrowSquareOut,
|
|
||||||
Play,
|
|
||||||
Stop,
|
|
||||||
Terminal,
|
|
||||||
ArrowLeft,
|
|
||||||
Bell,
|
|
||||||
} from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
export type IconName =
|
|
||||||
| "dashboard"
|
|
||||||
| "projects"
|
|
||||||
| "repositories"
|
|
||||||
| "settings"
|
|
||||||
| "profile"
|
|
||||||
| "logout"
|
|
||||||
| "add"
|
|
||||||
| "edit"
|
|
||||||
| "delete"
|
|
||||||
| "save"
|
|
||||||
| "cancel"
|
|
||||||
| "refresh"
|
|
||||||
| "copy"
|
|
||||||
| "search"
|
|
||||||
| "menu"
|
|
||||||
| "close"
|
|
||||||
| "success"
|
|
||||||
| "error"
|
|
||||||
| "warning"
|
|
||||||
| "info"
|
|
||||||
| "loading"
|
|
||||||
| "branch"
|
|
||||||
| "commit"
|
|
||||||
| "merge"
|
|
||||||
| "history"
|
|
||||||
| "pull"
|
|
||||||
| "push"
|
|
||||||
| "fetch"
|
|
||||||
| "file"
|
|
||||||
| "folder"
|
|
||||||
| "code"
|
|
||||||
| "document"
|
|
||||||
| "image"
|
|
||||||
| "binary"
|
|
||||||
| "external"
|
|
||||||
| "play"
|
|
||||||
| "stop"
|
|
||||||
| "terminal"
|
|
||||||
| "arrow-left"
|
|
||||||
| "bell";
|
|
||||||
|
|
||||||
export const iconRegistry: Record<
|
|
||||||
IconName,
|
|
||||||
React.ComponentType<{
|
|
||||||
size?: number | string;
|
|
||||||
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
|
||||||
}>
|
|
||||||
> = {
|
|
||||||
// Navigation
|
|
||||||
dashboard: House,
|
|
||||||
projects: Folder,
|
|
||||||
repositories: GitBranch,
|
|
||||||
settings: Gear,
|
|
||||||
profile: User,
|
|
||||||
logout: SignOut,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
add: Plus,
|
|
||||||
edit: PencilSimple,
|
|
||||||
delete: Trash,
|
|
||||||
save: FloppyDisk,
|
|
||||||
cancel: X,
|
|
||||||
refresh: ArrowsClockwise,
|
|
||||||
copy: Copy,
|
|
||||||
search: MagnifyingGlass,
|
|
||||||
menu: List,
|
|
||||||
close: X,
|
|
||||||
|
|
||||||
// Status
|
|
||||||
success: Check,
|
|
||||||
error: X,
|
|
||||||
warning: Warning,
|
|
||||||
info: Info,
|
|
||||||
loading: Spinner,
|
|
||||||
|
|
||||||
// Git
|
|
||||||
branch: GitBranch,
|
|
||||||
commit: GitCommit,
|
|
||||||
merge: GitMerge,
|
|
||||||
history: ClockCounterClockwise,
|
|
||||||
pull: ArrowDown,
|
|
||||||
push: ArrowUp,
|
|
||||||
fetch: ArrowsClockwise,
|
|
||||||
|
|
||||||
// Files
|
|
||||||
file: File,
|
|
||||||
folder: Folder,
|
|
||||||
code: Code,
|
|
||||||
document: FileText,
|
|
||||||
image: Image,
|
|
||||||
binary: Binary,
|
|
||||||
|
|
||||||
// Instance actions
|
|
||||||
external: ArrowSquareOut,
|
|
||||||
play: Play,
|
|
||||||
stop: Stop,
|
|
||||||
terminal: Terminal,
|
|
||||||
"arrow-left": ArrowLeft,
|
|
||||||
bell: Bell,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const iconCategories = {
|
|
||||||
navigation: [
|
|
||||||
"dashboard",
|
|
||||||
"projects",
|
|
||||||
"repositories",
|
|
||||||
"settings",
|
|
||||||
"profile",
|
|
||||||
"logout",
|
|
||||||
] as IconName[],
|
|
||||||
actions: [
|
|
||||||
"add",
|
|
||||||
"edit",
|
|
||||||
"delete",
|
|
||||||
"save",
|
|
||||||
"cancel",
|
|
||||||
"refresh",
|
|
||||||
"copy",
|
|
||||||
"search",
|
|
||||||
"menu",
|
|
||||||
"close",
|
|
||||||
] as IconName[],
|
|
||||||
status: ["success", "error", "warning", "info", "loading"] as IconName[],
|
|
||||||
git: [
|
|
||||||
"branch",
|
|
||||||
"commit",
|
|
||||||
"merge",
|
|
||||||
"history",
|
|
||||||
"pull",
|
|
||||||
"push",
|
|
||||||
"fetch",
|
|
||||||
] as IconName[],
|
|
||||||
files: [
|
|
||||||
"file",
|
|
||||||
"folder",
|
|
||||||
"code",
|
|
||||||
"document",
|
|
||||||
"image",
|
|
||||||
"binary",
|
|
||||||
] as IconName[],
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user