feat(web/ui): unify session opening and add repo card in project list
- Add openSession utility with tab deduplication/focus - Use openSession in navbar live sessions, use-instance-actions, and project tool links - Pass onAddRepository to ProjectListItem and add dashed 'Add Repository' card - Style add-repo card in projects.css
This commit is contained in:
@@ -2,6 +2,7 @@ import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
|||||||
|
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
import { useTheme } from "../hooks/use-theme";
|
import { useTheme } from "../hooks/use-theme";
|
||||||
|
import { openSession } from "../utils/open-session";
|
||||||
import { useAuth } from "../state/auth";
|
import { useAuth } from "../state/auth";
|
||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||||
@@ -33,19 +34,6 @@ const NAV_ITEMS: {
|
|||||||
const SessionItem = ({ session }: { session: Session }) => {
|
const SessionItem = ({ session }: { session: Session }) => {
|
||||||
const isRunning = session.status === "running";
|
const isRunning = session.status === "running";
|
||||||
|
|
||||||
// Determine the link target:
|
|
||||||
// - Web tools open their tunnel URL
|
|
||||||
// - Terminal tools open the terminal page
|
|
||||||
// - Everything else falls back to the project page
|
|
||||||
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 contextName = session.workspace_name || session.repository_name;
|
const contextName = session.workspace_name || session.repository_name;
|
||||||
const tooltipParts = [
|
const tooltipParts = [
|
||||||
session.display_name,
|
session.display_name,
|
||||||
@@ -56,10 +44,9 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
tooltipParts.push(`(${session.status})`);
|
tooltipParts.push(`(${session.status})`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<button
|
||||||
href={href}
|
type="button"
|
||||||
target={`session-${session.id}`}
|
onClick={() => openSession(session)}
|
||||||
rel="noreferrer"
|
|
||||||
className="nav-item session-item"
|
className="nav-item session-item"
|
||||||
title={tooltipParts.join(" · ")}
|
title={tooltipParts.join(" · ")}
|
||||||
>
|
>
|
||||||
@@ -76,7 +63,7 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
{session.tool_type_name} · {session.project_name}
|
{session.tool_type_name} · {session.project_name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Icon } from "../../icon";
|
|||||||
import type { Session } from "../../../api/sessions";
|
import type { Session } from "../../../api/sessions";
|
||||||
import type { IconName } from "../../../utils/icons";
|
import type { IconName } from "../../../utils/icons";
|
||||||
import type { ProjectWithRepos, RepositorySummary } from "../../../types";
|
import type { ProjectWithRepos, RepositorySummary } from "../../../types";
|
||||||
|
import { openSession } from "../../../utils/open-session";
|
||||||
|
|
||||||
interface ProjectListItemProps {
|
interface ProjectListItemProps {
|
||||||
project: ProjectWithRepos;
|
project: ProjectWithRepos;
|
||||||
@@ -13,6 +14,7 @@ interface ProjectListItemProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
onCancelDelete: () => void;
|
onCancelDelete: () => void;
|
||||||
|
onAddRepository?: () => void;
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ export const ProjectListItem = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
onCancelDelete,
|
onCancelDelete,
|
||||||
|
onAddRepository,
|
||||||
sessions,
|
sessions,
|
||||||
}: ProjectListItemProps) => {
|
}: ProjectListItemProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -75,17 +78,23 @@ export const ProjectListItem = ({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="project-repo-list">
|
<div className="project-repo-list">
|
||||||
{project.repositories.length === 0 ? (
|
{project.repositories.map((repo) => (
|
||||||
<p className="muted">No repositories.</p>
|
<ProjectRepoItem
|
||||||
) : (
|
key={repo.id}
|
||||||
project.repositories.map((repo) => (
|
repo={repo}
|
||||||
<ProjectRepoItem
|
project={project}
|
||||||
key={repo.id}
|
sessions={sessions}
|
||||||
repo={repo}
|
/>
|
||||||
project={project}
|
))}
|
||||||
sessions={sessions}
|
{onAddRepository && (
|
||||||
/>
|
<button
|
||||||
))
|
type="button"
|
||||||
|
className="project-repo-item project-add-repo-card"
|
||||||
|
onClick={onAddRepository}
|
||||||
|
>
|
||||||
|
<Icon name="add" size="md" />
|
||||||
|
<span className="project-repo-name">Add Repository</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -169,22 +178,10 @@ function ProjectRepoItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SessionToolLink({ session }: { session: Session }) {
|
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 (
|
return (
|
||||||
<a
|
<button
|
||||||
href={href}
|
type="button"
|
||||||
target={isExternal ? `session-${session.id}` : undefined}
|
onClick={() => openSession(session)}
|
||||||
rel={isExternal ? "noreferrer" : undefined}
|
|
||||||
className="workspace-tool-link"
|
className="workspace-tool-link"
|
||||||
title={`${session.display_name} (${session.status})`}
|
title={`${session.display_name} (${session.status})`}
|
||||||
>
|
>
|
||||||
@@ -195,6 +192,6 @@ function SessionToolLink({ session }: { session: Session }) {
|
|||||||
<span className="tool-name">
|
<span className="tool-name">
|
||||||
{session.display_name || session.tool_type_name}
|
{session.display_name || session.tool_type_name}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
stopInstance,
|
stopInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { Session } from "../api/sessions";
|
import type { Session } from "../api/sessions";
|
||||||
import { useSessions } from "../state/sessions";
|
import { useSessions } from "../state/sessions";
|
||||||
|
import { openSession } from "../utils/open-session";
|
||||||
|
|
||||||
interface UseInstanceActionsOptions {
|
interface UseInstanceActionsOptions {
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
@@ -37,27 +38,9 @@ export function useInstanceActions(
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
const tabRefs = useRef<Map<string, Window | null>>(new Map());
|
|
||||||
|
|
||||||
const handleOpen = useCallback((session: Session) => {
|
const handleOpen = useCallback((session: Session) => {
|
||||||
const key = session.id;
|
openSession(session);
|
||||||
const existing = tabRefs.current.get(key);
|
|
||||||
if (existing && !existing.closed) {
|
|
||||||
existing.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: string;
|
|
||||||
if (session.url) {
|
|
||||||
url = session.url;
|
|
||||||
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
|
||||||
url = `/instances/${session.id}/terminal`;
|
|
||||||
} else {
|
|
||||||
url = `/projects/${session.project_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = window.open(url, `session-${session.id}`);
|
|
||||||
tabRefs.current.set(key, w);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStart = useCallback(
|
const handleStart = useCallback(
|
||||||
|
|||||||
@@ -252,6 +252,10 @@ export const ProjectsPage = () => {
|
|||||||
onDelete={() => setDeleteConfirmId(project.id)}
|
onDelete={() => setDeleteConfirmId(project.id)}
|
||||||
onConfirmDelete={() => void handleDelete(project.id)}
|
onConfirmDelete={() => void handleDelete(project.id)}
|
||||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||||
|
onAddRepository={() => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setMobileView("create-repo");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,8 +177,28 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-add-repo {
|
.project-add-repo-card {
|
||||||
display: none;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border-style: dashed;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-add-repo-card:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-add-repo-card .project-repo-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Session } from "../api/sessions";
|
||||||
|
|
||||||
|
const tabRefs = new Map<string, Window | null>();
|
||||||
|
|
||||||
|
export function openSession(session: Session): void {
|
||||||
|
const key = session.id;
|
||||||
|
const existing = tabRefs.get(key);
|
||||||
|
if (existing && !existing.closed) {
|
||||||
|
existing.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
if (session.url) {
|
||||||
|
url = session.url;
|
||||||
|
} else if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
|
url = `/instances/${session.id}/terminal`;
|
||||||
|
} else {
|
||||||
|
url = `/projects/${session.project_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = window.open(url, `session-${session.id}`);
|
||||||
|
tabRefs.set(key, w);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user