fix: resolve four frontend/backend issues

- Fix ProjectsPage tests by wrapping renders in MemoryRouter (9 passing)
- Improve session auto-naming to 'project / repo / tool' format
- Add missing /users/me/sessions endpoint for sidebar session loading
- Handle git history 500s: catch RuntimeError in endpoints, graceful empty repo handling
- Add git status badge and discard-changes button to FileEditor toolbar

Quality gates: tsc pass, build pass, Python syntax pass
This commit is contained in:
Developer
2026-06-03 08:30:28 +00:00
parent 543fee5d56
commit 5ed5e1c84b
9 changed files with 348 additions and 158 deletions
@@ -9,14 +9,23 @@ import { Icon } from "../../ui/Icon";
import { SyntaxHighlighter } from "./SyntaxHighlighter";
import { detectLanguage } from "../../../utils/language";
interface GitFileStatus {
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
}
interface FileEditorProps {
projectId: string;
repoId: string;
gitStatus?: GitFileStatus | null;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
gitStatus,
}) => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
@@ -31,9 +40,49 @@ export const FileEditor: React.FC<FileEditorProps> = ({
const [isBinary, setIsBinary] = useState(false);
const [saving, setSaving] = useState(false);
const handleDiscard = async () => {
if (!filePath) return;
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
setOriginalContent("");
} else {
setIsBinary(false);
setContent(data.content);
setOriginalContent(data.content);
}
setMode("view");
} catch {
setError("Failed to discard changes");
}
};
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const fileStatus = gitStatus
? gitStatus.modified.includes(filePath || "")
? "modified"
: gitStatus.added.includes(filePath || "")
? "added"
: gitStatus.deleted.includes(filePath || "")
? "deleted"
: gitStatus.untracked.includes(filePath || "")
? "untracked"
: undefined
: undefined;
const loadFile = useCallback(async () => {
if (!filePath) {
setContent("");
@@ -158,7 +207,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
<div className={styles.fileEditor}>
<div className={styles.fileEditorToolbar}>
<div className="file-breadcrumbs">
{filePath.split("/").map((part, i, arr) => (
{filePath?.split("/").map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && (
@@ -168,6 +217,11 @@ export const FileEditor: React.FC<FileEditorProps> = ({
))}
</div>
<div className={styles.fileActions}>
{fileStatus && (
<span className={`git-status-badge ${fileStatus}`} title={fileStatus}>
{fileStatus === "modified" ? "M" : fileStatus === "added" ? "A" : fileStatus === "deleted" ? "D" : "?"}
</span>
)}
{mode === "view" && !isBinary && (
<button
className="btn-primary"
@@ -180,24 +234,33 @@ export const FileEditor: React.FC<FileEditorProps> = ({
)}
{mode === "edit" && (
<>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleDiscard}
type="button"
title="Revert to last committed version"
>
<Icon name="undo" size="sm" />
Discard
</button>
<button
className="btn-secondary"
onClick={handleCancel}
+3 -1
View File
@@ -75,7 +75,8 @@ export type IconName =
| "play"
| "stop"
| "terminal"
| "arrow-left";
| "arrow-left"
| "undo";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
@@ -117,6 +118,7 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
stop: Stop,
terminal: Terminal,
"arrow-left": ArrowLeft,
undo: ClockCounterClockwise,
};
export interface IconProps {
+158 -127
View File
@@ -1,174 +1,205 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./ProjectsPage";
import * as projectsApi from "../api/projects";
const renderWithRouter = (ui: React.ReactElement) =>
render(<MemoryRouter>{ui}</MemoryRouter>);
const mockProjects = [
{
id: "proj-1",
name: "Alpha Project",
description: "First project",
owner_id: "user-1",
default_ssh_key_id: null,
},
{
id: "proj-2",
name: "Beta Project",
description: null,
owner_id: "user-1",
default_ssh_key_id: null,
},
{
id: "proj-1",
name: "Alpha Project",
description: "First project",
owner_id: "user-1",
default_ssh_key_id: null,
},
{
id: "proj-2",
name: "Beta Project",
description: null,
owner_id: "user-1",
default_ssh_key_id: null,
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
cleanup();
vi.restoreAllMocks();
});
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(
() => new Promise(() => {}),
);
renderWithRouter(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(screen.getByText("Beta Project")).toBeInTheDocument();
expect(screen.getByText("First project")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(screen.getByText("Beta Project")).toBeInTheDocument();
expect(screen.getByText("First project")).toBeInTheDocument();
});
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
});
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
});
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(<ProjectsPage />);
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("opens create dialog and submits new project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
it("opens create dialog and submits new project", async () => {
const listMock = vi
.spyOn(projectsApi, "listProjects")
.mockResolvedValue([]);
const createMock = vi
.spyOn(projectsApi, "createProject")
.mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/project name/i), {
target: { value: "Gamma Project" },
});
fireEvent.change(screen.getByPlaceholderText(/optional description/i), {
target: { value: "A new project" },
});
fireEvent.click(screen.getByRole("button", { name: /create/i }));
fireEvent.change(screen.getByPlaceholderText(/project name/i), {
target: { value: "Gamma Project" },
});
fireEvent.change(screen.getByPlaceholderText(/optional description/i), {
target: { value: "A new project" },
});
fireEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith({
name: "Gamma Project",
description: "A new project",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith({
name: "Gamma Project",
description: "A new project",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
fireEvent.click(screen.getByRole("button", { name: /create/i }));
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
fireEvent.click(screen.getByRole("button", { name: /create/i }));
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
it("renders settings link for each project", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const alphaCard = screen
.getByText("Alpha Project")
.closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
expect(settingsLink).toBeInTheDocument();
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
});
const settingsLink = within(alphaCard).getByRole("link", {
name: /settings/i,
});
expect(settingsLink).toBeInTheDocument();
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
});
it("renders open workspace link as rightmost action", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
it("renders open workspace link as rightmost action", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const alphaCard = screen
.getByText("Alpha Project")
.closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const actions = alphaCard.querySelector(".project-actions");
if (!actions) throw new Error("Actions container not found");
const actions = alphaCard.querySelector(".project-actions");
if (!actions) throw new Error("Actions container not found");
const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i });
expect(workspaceLink).toBeInTheDocument();
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
const workspaceLink = within(alphaCard).getByRole("link", {
name: /open workspace/i,
});
expect(workspaceLink).toBeInTheDocument();
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
// Verify it's the last action in the container
const allActions = actions.querySelectorAll("a, button");
const lastAction = allActions[allActions.length - 1];
expect(lastAction).toBe(workspaceLink);
});
// Verify it's the last action in the container
const allActions = actions.querySelectorAll("a, button");
const lastAction = allActions[allActions.length - 1];
expect(lastAction).toBe(workspaceLink);
});
it("shows delete confirmation and deletes project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
it("shows delete confirmation and deletes project", async () => {
const listMock = vi
.spyOn(projectsApi, "listProjects")
.mockResolvedValue(mockProjects);
const deleteMock = vi
.spyOn(projectsApi, "deleteProject")
.mockResolvedValue(undefined);
render(<ProjectsPage />);
renderWithRouter(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
const alphaCard = screen
.getByText("Alpha Project")
.closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("proj-1");
});
expect(listMock).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("proj-1");
});
expect(listMock).toHaveBeenCalledTimes(2);
});
});
+1 -1
View File
@@ -198,7 +198,7 @@ export const RepoWorkspace = () => {
)}
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
<FileEditor projectId={projectId!} repoId={selectedRepoId} gitStatus={gitStatus} />
)}
</main>
</div>