feat(web/ui): rework project pane into flat list with repo/workspace/tool hierarchy
- Add ProjectListItem component with project header actions - Show repositories horizontally with branch labels - List workspaces vertically under each repo - Show running tools per workspace from useSessions - Link workspace names to /workspaces/:id - Link tool names to web URL or terminal page - Keep existing mobile view and dialogs unchanged
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Icon } from "../../icon";
|
||||
import type { Session } from "../../../api/sessions";
|
||||
import type { IconName } from "../../../utils/icons";
|
||||
import type { ProjectWithRepos, RepositorySummary } from "../../../types";
|
||||
|
||||
interface ProjectListItemProps {
|
||||
project: ProjectWithRepos;
|
||||
deleteConfirm: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
export const ProjectListItem = ({
|
||||
project,
|
||||
deleteConfirm,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
sessions,
|
||||
}: ProjectListItemProps) => {
|
||||
return (
|
||||
<article className="project-list-item">
|
||||
<header className="project-list-item-header">
|
||||
<div className="project-list-item-title">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="project-list-item-actions">
|
||||
{deleteConfirm ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={onConfirmDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="project-repo-list">
|
||||
{project.repositories.length === 0 ? (
|
||||
<p className="muted">No repositories.</p>
|
||||
) : (
|
||||
project.repositories.map((repo) => (
|
||||
<ProjectRepoItem
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
project={project}
|
||||
sessions={sessions}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
function ProjectRepoItem({
|
||||
repo,
|
||||
project,
|
||||
sessions,
|
||||
}: {
|
||||
repo: RepositorySummary;
|
||||
project: ProjectWithRepos;
|
||||
sessions: Session[];
|
||||
}) {
|
||||
const sessionsByWorkspace = useMemo(() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
for (const ws of repo.workspaces) {
|
||||
map.set(ws.name, []);
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (
|
||||
session.project_id !== project.id ||
|
||||
session.repository_id !== repo.id
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (session.status !== "running") continue;
|
||||
const list = map.get(session.workspace_name || "") || [];
|
||||
list.push(session);
|
||||
map.set(session.workspace_name || "", list);
|
||||
}
|
||||
return map;
|
||||
}, [sessions, project.id, repo.id, repo.workspaces]);
|
||||
|
||||
const branch = useMemo(() => {
|
||||
const firstWs = repo.workspaces[0];
|
||||
if (!firstWs) return "—";
|
||||
return firstWs.branch;
|
||||
}, [repo.workspaces]);
|
||||
|
||||
return (
|
||||
<div className="project-repo-item">
|
||||
<div className="project-repo-item-header">
|
||||
<Icon name="folder" size="sm" />
|
||||
<span className="project-repo-name">{repo.name}</span>
|
||||
<span className="project-repo-branch">
|
||||
<Icon name="branch" size="sm" /> {branch}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="project-repo-workspaces">
|
||||
{repo.workspaces.length === 0 ? (
|
||||
<p className="muted text-sm">No workspaces.</p>
|
||||
) : (
|
||||
repo.workspaces.map((ws) => {
|
||||
const runningSessions = sessionsByWorkspace.get(ws.name) || [];
|
||||
return (
|
||||
<div key={ws.id} className="project-repo-workspace">
|
||||
<Link className="workspace-name" to={`/workspaces/${ws.id}`}>
|
||||
{ws.name}
|
||||
</Link>
|
||||
{runningSessions.length === 0 ? (
|
||||
<span className="muted text-xs">No running tools</span>
|
||||
) : (
|
||||
<ul className="workspace-tool-list">
|
||||
{runningSessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<SessionToolLink session={session} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionToolLink({ session }: { session: Session }) {
|
||||
const hasTerminal = session.tool_type_interfaces.includes("terminal");
|
||||
const hasWeb = session.tool_type_interfaces.includes("web");
|
||||
const href =
|
||||
session.url && hasWeb
|
||||
? session.url
|
||||
: hasTerminal
|
||||
? `/instances/${session.id}/terminal`
|
||||
: `/projects/${session.project_id}`;
|
||||
|
||||
const isExternal = href.startsWith("http");
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? `session-${session.id}` : undefined}
|
||||
rel={isExternal ? "noreferrer" : undefined}
|
||||
className="workspace-tool-link"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
<span
|
||||
className={`session-status-dot ${session.status === "running" ? "running" : ""}`}
|
||||
/>
|
||||
<Icon name={(session.tool_icon as IconName) || "terminal"} size="sm" />
|
||||
<span className="tool-name">
|
||||
{session.display_name || session.tool_type_name}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
import { Icon } from "../components/icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { ProjectCard } from "../components/features/project/ProjectCard";
|
||||
import { ProjectListItem } from "../components/features/project/ProjectListItem";
|
||||
import { ProjectDialog } from "../components/features/project/ProjectDialog";
|
||||
import { RepositoryCreateDialog } from "../components/features/project/repository-create-dialog";
|
||||
import { MobileListView } from "../components/features/mobile/mobile-list-view";
|
||||
import { MobileFAB } from "../components/features/mobile/mobile-fab";
|
||||
import { useProjects } from "../hooks/use-projects";
|
||||
import { useSessions } from "../state/sessions";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
|
||||
type MobileView = "list" | "detail" | "create-project" | "create-repo";
|
||||
@@ -34,8 +36,6 @@ export const ProjectsPage = () => {
|
||||
formError,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
expandedProject,
|
||||
setExpandedProject,
|
||||
creatingWorkspace,
|
||||
setCreatingWorkspace,
|
||||
workspaceLoading,
|
||||
@@ -48,6 +48,7 @@ export const ProjectsPage = () => {
|
||||
handleDeleteWorkspace,
|
||||
} = useProjects();
|
||||
|
||||
const { sessions } = useSessions();
|
||||
const isEmpty = status === "ready" && projects.length === 0;
|
||||
|
||||
/* ── Mobile views ── */
|
||||
@@ -242,45 +243,15 @@ export const ProjectsPage = () => {
|
||||
{status === "ready" && projects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
<ProjectListItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
expanded={expandedProject === project.id}
|
||||
sessions={sessions}
|
||||
deleteConfirm={deleteConfirmId === project.id}
|
||||
workspaceLoading={workspaceLoading}
|
||||
showCreateForm={
|
||||
creatingWorkspace?.projectId === project.id
|
||||
? creatingWorkspace.repoId
|
||||
: null
|
||||
}
|
||||
onToggle={() =>
|
||||
setExpandedProject(
|
||||
expandedProject === project.id ? null : project.id,
|
||||
)
|
||||
}
|
||||
onEdit={() => openEdit(project)}
|
||||
onDelete={() => setDeleteConfirmId(project.id)}
|
||||
onConfirmDelete={() => void handleDelete(project.id)}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
onCreateWorkspace={(repoId) =>
|
||||
setCreatingWorkspace({ projectId: project.id, repoId })
|
||||
}
|
||||
onWorkspaceAction={(repoId, workspace, action) => {
|
||||
if (action === "sync") {
|
||||
void handleSyncWorkspace(project.id, repoId, workspace);
|
||||
} else if (action === "delete") {
|
||||
void handleDeleteWorkspace(workspace);
|
||||
}
|
||||
}}
|
||||
onCancelCreate={() => setCreatingWorkspace(null)}
|
||||
onAddRepository={() => {
|
||||
setSelectedProject(project);
|
||||
setMobileView("create-repo");
|
||||
}}
|
||||
onCreated={() => {
|
||||
setCreatingWorkspace(null);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -188,6 +188,154 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── New Project Pane List Layout ─── */
|
||||
.project-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.project-list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.project-list-item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.project-list-item-title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.project-list-item-title p {
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.project-list-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-repo-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.project-repo-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
flex: 1 1 260px;
|
||||
padding: var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.project-repo-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-repo-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-repo-branch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-repo-workspaces {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.project-repo-workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
font-weight: 500;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.workspace-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workspace-tool-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.workspace-tool-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.workspace-tool-link:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.session-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.session-status-dot.running {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Inline radio buttons for repository creation mode */
|
||||
.repo-mode-radios {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user