feat: reuse existing tabs when opening sessions instead of always creating new ones
Use named window targets (session-{id}, instance-{id}) instead of _blank:
- use-instance-actions.ts: window.open(..., 'session-{id}') for programmatic opens
- session-card.tsx: <a target='session-{id}'> for direct link opens (mobile + desktop)
- workspace-instance-chips.tsx: <a target='instance-{id}'> for chip links
- instance-list.tsx: <a target='instance-{id}'> for instance open links
Browser behavior: if a tab with that target name exists, it navigates/focuses
that tab instead of opening a new one.
Quality gates: tsc --noEmit pass, npm run build pass, 80/80 tests pass
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
||||||
@@ -6,119 +12,146 @@ import * as gitRepositoriesApi from "../../../api/git-repositories";
|
|||||||
import * as sshKeysApi from "../../../api/ssh-keys";
|
import * as sshKeysApi from "../../../api/ssh-keys";
|
||||||
|
|
||||||
const mockRepositories = [
|
const mockRepositories = [
|
||||||
{
|
{
|
||||||
id: "repo-1",
|
id: "repo-1",
|
||||||
name: "Main Repo",
|
name: "Main Repo",
|
||||||
path: "/repos/main",
|
path: "/repos/main",
|
||||||
project_id: "proj-1",
|
project_id: "proj-1",
|
||||||
owner_id: "user-1",
|
owner_id: "user-1",
|
||||||
is_mirror: false,
|
is_mirror: false,
|
||||||
remote_url: null,
|
remote_url: null,
|
||||||
last_push: null,
|
last_push: null,
|
||||||
created_at: null,
|
created_at: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mock("react-router-dom", async () => {
|
vi.mock("react-router-dom", async () => {
|
||||||
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
const actual =
|
||||||
return {
|
await vi.importActual<typeof import("react-router-dom")>(
|
||||||
...actual,
|
"react-router-dom",
|
||||||
useParams: () => ({ projectId: "proj-1" }),
|
);
|
||||||
};
|
return {
|
||||||
|
...actual,
|
||||||
|
useParams: () => ({ projectId: "proj-1" }),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RepositoriesSettingsTab", () => {
|
describe("RepositoriesSettingsTab", () => {
|
||||||
it("opens create dialog and clones an existing repository", async () => {
|
it("opens create dialog and clones an existing repository", async () => {
|
||||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
const listMock = vi
|
||||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
.spyOn(gitRepositoriesApi, "listRepositories")
|
||||||
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
|
.mockResolvedValue(mockRepositories);
|
||||||
{ id: "key-1", name: "My Key", public_key: "ssh-ed25519 AAA...", created_at: "2024-01-01" },
|
const createMock = vi
|
||||||
]);
|
.spyOn(gitRepositoriesApi, "createRepository")
|
||||||
|
.mockResolvedValue(mockRepositories[0]);
|
||||||
|
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "key-1",
|
||||||
|
name: "My Key",
|
||||||
|
public_key: "ssh-ed25519 AAA...",
|
||||||
|
created_at: "2024-01-01",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
render(<RepositoriesSettingsTab />);
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
target: { value: "New Repo" },
|
target: { value: "New Repo" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||||
target: { value: "alice" },
|
target: { value: "alice" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
||||||
target: { value: "demo" },
|
target: { value: "demo" },
|
||||||
});
|
});
|
||||||
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
|
const sshSelect = screen.getByRole("combobox", { name: /ssh key/i });
|
||||||
await waitFor(() => expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy());
|
await waitFor(() =>
|
||||||
(sshSelect as HTMLSelectElement).value = "key-1";
|
expect(sshSelect.querySelector('option[value="key-1"]')).toBeTruthy(),
|
||||||
fireEvent.change(sshSelect);
|
);
|
||||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
(sshSelect as HTMLSelectElement).value = "key-1";
|
||||||
|
fireEvent.change(sshSelect);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
name: "New Repo",
|
name: "New Repo",
|
||||||
remote_url: "git@git.commumedia.org:alice/demo.git",
|
remote_url: "git@git.commumedia.org:alice/demo.git",
|
||||||
ssh_key_id: "key-1",
|
ssh_key_id: "key-1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses advanced url fallback when requested", async () => {
|
it("uses advanced url fallback when requested", async () => {
|
||||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
const listMock = vi
|
||||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
.spyOn(gitRepositoriesApi, "listRepositories")
|
||||||
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]);
|
.mockResolvedValue(mockRepositories);
|
||||||
|
const createMock = vi
|
||||||
|
.spyOn(gitRepositoriesApi, "createRepository")
|
||||||
|
.mockResolvedValue(mockRepositories[0]);
|
||||||
|
vi.spyOn(sshKeysApi, "listSSHKeys").mockResolvedValue([]);
|
||||||
|
|
||||||
render(<RepositoriesSettingsTab />);
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
target: { value: "New Repo" },
|
target: { value: "New Repo" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
|
fireEvent.click(
|
||||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
|
screen.getByRole("button", { name: /use full url instead/i }),
|
||||||
target: { value: "https://github.com/user/repo.git" },
|
);
|
||||||
});
|
fireEvent.change(
|
||||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i),
|
||||||
|
{
|
||||||
|
target: { value: "https://github.com/user/repo.git" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||||
name: "New Repo",
|
name: "New Repo",
|
||||||
remote_url: "https://github.com/user/repo.git",
|
remote_url: "https://github.com/user/repo.git",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows validation when cloning without a remote url", async () => {
|
it("shows validation when cloning without a remote url", async () => {
|
||||||
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(
|
||||||
render(<RepositoriesSettingsTab />);
|
mockRepositories,
|
||||||
|
);
|
||||||
|
render(<RepositoriesSettingsTab />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||||
target: { value: "New Repo" },
|
target: { value: "New Repo" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||||
target: { value: "" },
|
target: { value: "" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||||
|
|
||||||
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
|
expect(
|
||||||
});
|
screen.getByText(/owner and repository name are required/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export function SessionCard({
|
|||||||
)}
|
)}
|
||||||
{session.url && (
|
{session.url && (
|
||||||
<p className="session-card-url">
|
<p className="session-card-url">
|
||||||
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
<a href={session.url} target={`session-${session.id}`} rel="noopener noreferrer">
|
||||||
{session.url}
|
{session.url}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -158,7 +158,7 @@ export function SessionCard({
|
|||||||
{openHref ? (
|
{openHref ? (
|
||||||
<a
|
<a
|
||||||
href={openHref}
|
href={openHref}
|
||||||
target="_blank"
|
target={`session-${session.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="secondary-button mobile-primary"
|
className="secondary-button mobile-primary"
|
||||||
>
|
>
|
||||||
@@ -215,7 +215,7 @@ export function SessionCard({
|
|||||||
{openHref ? (
|
{openHref ? (
|
||||||
<a
|
<a
|
||||||
href={openHref}
|
href={openHref}
|
||||||
target="_blank"
|
target={`session-${session.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export const InstanceList = ({
|
|||||||
? instance.url
|
? instance.url
|
||||||
: `${API_BASE_URL}${instance.url}`
|
: `${API_BASE_URL}${instance.url}`
|
||||||
}
|
}
|
||||||
target="_blank"
|
target={`instance-${instance.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="secondary-button small"
|
className="secondary-button small"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function WorkspaceInstanceChips({
|
|||||||
{inst.status === "running" && inst.url && (
|
{inst.status === "running" && inst.url && (
|
||||||
<a
|
<a
|
||||||
href={inst.url}
|
href={inst.url}
|
||||||
target="_blank"
|
target={`instance-${inst.id}`}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -35,15 +35,16 @@ export function useInstanceActions(
|
|||||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
const handleOpen = useCallback((session: Session) => {
|
const handleOpen = useCallback((session: Session) => {
|
||||||
|
const tabName = `session-${session.id}`;
|
||||||
if (session.url) {
|
if (session.url) {
|
||||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
window.open(session.url, tabName, "noopener,noreferrer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||||
window.open(`/instances/${session.id}/terminal`, "_blank", "noopener,noreferrer");
|
window.open(`/instances/${session.id}/terminal`, tabName, "noopener,noreferrer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.open(`/projects/${session.project_id}`, "_blank", "noopener,noreferrer");
|
window.open(`/projects/${session.project_id}`, tabName, "noopener,noreferrer");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStart = useCallback(
|
const handleStart = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user