mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
2 Commits
fix/cloud-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df6e16d6a6 | ||
|
|
01d314e796 |
@@ -175,10 +175,16 @@
|
||||
"section_title": "Repositories",
|
||||
"description": "Git repositories associated with this workspace. Agents use these to clone and work on code.",
|
||||
"url_placeholder": "https://git.example.com/org/repo.git",
|
||||
"url_empty": "(empty URL)",
|
||||
"description_placeholder": "Description (e.g. Go backend + Next.js frontend)",
|
||||
"add": "Add repository",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"saved_hint": "All changes saved",
|
||||
"empty": "No repositories yet.",
|
||||
"edit_aria": "Edit repository",
|
||||
"cancel_aria": "Cancel edit",
|
||||
"delete_aria": "Delete repository",
|
||||
"manage_hint": "Only admins and owners can manage repositories.",
|
||||
"toast_saved": "Repositories saved",
|
||||
"toast_save_failed": "Failed to save repositories"
|
||||
|
||||
@@ -175,10 +175,16 @@
|
||||
"section_title": "代码仓库",
|
||||
"description": "与该工作区关联的 Git 仓库。智能体会从这里 clone 代码并完成工作。",
|
||||
"url_placeholder": "https://git.example.com/org/repo.git",
|
||||
"url_empty": "(空 URL)",
|
||||
"description_placeholder": "描述(例如:Go 后端 + Next.js 前端)",
|
||||
"add": "添加仓库",
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"saved_hint": "所有改动已保存",
|
||||
"empty": "暂无仓库。",
|
||||
"edit_aria": "编辑仓库",
|
||||
"cancel_aria": "取消编辑",
|
||||
"delete_aria": "删除仓库",
|
||||
"manage_hint": "只有管理员和所有者可以管理代码仓库。",
|
||||
"toast_saved": "已保存代码仓库",
|
||||
"toast_save_failed": "保存代码仓库失败"
|
||||
|
||||
223
packages/views/settings/components/repositories-tab.test.tsx
Normal file
223
packages/views/settings/components/repositories-tab.test.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enSettings from "../../locales/en/settings.json";
|
||||
|
||||
const mockUpdateWorkspace = vi.hoisted(() => vi.fn());
|
||||
const workspaceRef = vi.hoisted(() => ({
|
||||
current: {
|
||||
id: "workspace-1",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
repos: [{ url: "https://github.com/multica-ai/multica" }] as { url: string }[],
|
||||
},
|
||||
}));
|
||||
const membersRef = vi.hoisted(() => ({
|
||||
current: [{ user_id: "user-1", role: "owner" as const }],
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: membersRef.current }),
|
||||
useQueryClient: () => ({ setQueryData: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "workspace-1",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
useCurrentWorkspace: () => workspaceRef.current,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
|
||||
workspaceKeys: { list: () => ["workspaces"] },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { updateWorkspace: mockUpdateWorkspace },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = Object.assign(
|
||||
(sel?: (s: { user: { id: string } }) => unknown) =>
|
||||
sel ? sel({ user: { id: "user-1" } }) : { user: { id: "user-1" } },
|
||||
{ getState: () => ({ user: { id: "user-1" } }) },
|
||||
);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { RepositoriesTab } from "./repositories-tab";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, settings: enSettings },
|
||||
};
|
||||
|
||||
function I18nWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("RepositoriesTab — view/edit toggle", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
workspaceRef.current = {
|
||||
id: "workspace-1",
|
||||
name: "Test Workspace",
|
||||
slug: "test-workspace",
|
||||
repos: [{ url: "https://github.com/multica-ai/multica" }],
|
||||
};
|
||||
membersRef.current = [{ user_id: "user-1", role: "owner" }];
|
||||
});
|
||||
|
||||
it("renders persisted repos in display mode (no input)", () => {
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.getByText("https://github.com/multica-ai/multica")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Save button is disabled when clean", () => {
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("clicking Edit reveals an input pre-filled with the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit repository" }));
|
||||
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
expect(input.value).toBe("https://github.com/multica-ai/multica");
|
||||
});
|
||||
|
||||
it("Save re-enables after editing, then returns to display mode + disabled on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateWorkspace.mockImplementation(async (_id: string, payload: { repos: { url: string }[] }) => ({
|
||||
...workspaceRef.current,
|
||||
repos: payload.repos,
|
||||
}));
|
||||
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit repository" }));
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.clear(input);
|
||||
await user.type(input, "https://github.com/multica-ai/edited");
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^Save$/ });
|
||||
expect(saveBtn).not.toBeDisabled();
|
||||
|
||||
// Simulate the workspace cache resync that the parent provider does
|
||||
// after a successful save — `setQueryData` updates the cache and the
|
||||
// useCurrentWorkspace hook would yield the new value on the next render.
|
||||
mockUpdateWorkspace.mockImplementationOnce(async (_id: string, payload: { repos: { url: string }[] }) => {
|
||||
workspaceRef.current = { ...workspaceRef.current, repos: payload.repos };
|
||||
return workspaceRef.current;
|
||||
});
|
||||
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateWorkspace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After successful save, edit mode is cleared — input gone, Save disabled.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("newly added rows start in edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Add repository/ }));
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("Edit clean row → Cancel returns to display mode without changing URL or dirtying Save", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit repository" }));
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel edit" }));
|
||||
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.getByText("https://github.com/multica-ai/multica")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).toBeDisabled();
|
||||
expect(mockUpdateWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Cancel on a dirty edited row reverts the URL and exits edit mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit repository" }));
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
await user.clear(input);
|
||||
await user.type(input, "https://github.com/multica-ai/changed");
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).not.toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel edit" }));
|
||||
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.getByText("https://github.com/multica-ai/multica")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("Cancel on a newly added (never saved) row removes the row entirely", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Add repository/ }));
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel edit" }));
|
||||
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
// Original persisted row is still there; the new empty row is gone.
|
||||
expect(screen.getByText("https://github.com/multica-ai/multica")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^Save$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("deleting a row shifts tracked edit indices so the wrong row doesn't open", async () => {
|
||||
workspaceRef.current = {
|
||||
...workspaceRef.current,
|
||||
repos: [{ url: "https://a.example/repo.git" }, { url: "https://b.example/repo.git" }],
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
render(<RepositoriesTab />, { wrapper: I18nWrapper });
|
||||
|
||||
// Edit the second row.
|
||||
const editButtons = screen.getAllByRole("button", { name: "Edit repository" });
|
||||
await user.click(editButtons[1]!);
|
||||
expect((screen.getByRole("textbox") as HTMLInputElement).value).toBe(
|
||||
"https://b.example/repo.git",
|
||||
);
|
||||
|
||||
// Delete the first row. The remaining row should remain in edit mode
|
||||
// (its index dropped from 1 → 0).
|
||||
const deleteButtons = screen.getAllByRole("button", { name: "Delete repository" });
|
||||
await user.click(deleteButtons[0]!);
|
||||
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
expect(input.value).toBe("https://b.example/repo.git");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save, Plus, Trash2 } from "lucide-react";
|
||||
import { Save, Plus, Trash2, Pencil, X } from "lucide-react";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
@@ -15,6 +15,20 @@ import { api } from "@multica/core/api";
|
||||
import type { Workspace, WorkspaceRepo } from "@multica/core/types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
function dropAndShiftIndex(set: Set<number>, removed: number): Set<number> {
|
||||
const next = new Set<number>();
|
||||
set.forEach((i) => {
|
||||
if (i === removed) return;
|
||||
next.add(i > removed ? i - 1 : i);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function isDirty(local: WorkspaceRepo[], saved: WorkspaceRepo[]): boolean {
|
||||
if (local.length !== saved.length) return true;
|
||||
return local.some((r, i) => r.url !== saved[i]?.url);
|
||||
}
|
||||
|
||||
export function RepositoriesTab() {
|
||||
const { t } = useT("settings");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -24,6 +38,7 @@ export function RepositoriesTab() {
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
|
||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||
const [editingIndices, setEditingIndices] = useState<Set<number>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
@@ -33,6 +48,9 @@ export function RepositoriesTab() {
|
||||
setRepos(workspace?.repos ?? []);
|
||||
}, [workspace]);
|
||||
|
||||
const savedRepos = workspace?.repos ?? [];
|
||||
const dirty = isDirty(repos, savedRepos);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!workspace) return;
|
||||
setSaving(true);
|
||||
@@ -41,6 +59,7 @@ export function RepositoriesTab() {
|
||||
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
|
||||
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
|
||||
);
|
||||
setEditingIndices(new Set());
|
||||
toast.success(t(($) => $.repositories.toast_saved));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t(($) => $.repositories.toast_save_failed));
|
||||
@@ -50,17 +69,37 @@ export function RepositoriesTab() {
|
||||
};
|
||||
|
||||
const handleAddRepo = () => {
|
||||
const nextIndex = repos.length;
|
||||
setRepos([...repos, { url: "" }]);
|
||||
setEditingIndices(new Set(editingIndices).add(nextIndex));
|
||||
};
|
||||
|
||||
const handleRemoveRepo = (index: number) => {
|
||||
setRepos(repos.filter((_, i) => i !== index));
|
||||
setEditingIndices(dropAndShiftIndex(editingIndices, index));
|
||||
};
|
||||
|
||||
const handleRepoChange = (index: number, value: string) => {
|
||||
setRepos(repos.map((r, i) => (i === index ? { ...r, url: value } : r)));
|
||||
};
|
||||
|
||||
const handleEditRepo = (index: number) => {
|
||||
setEditingIndices(new Set(editingIndices).add(index));
|
||||
};
|
||||
|
||||
const handleCancelEdit = (index: number) => {
|
||||
const savedUrl = savedRepos[index]?.url;
|
||||
if (savedUrl === undefined) {
|
||||
// Newly added row that was never persisted — drop it entirely.
|
||||
handleRemoveRepo(index);
|
||||
return;
|
||||
}
|
||||
setRepos(repos.map((r, i) => (i === index ? { ...r, url: savedUrl } : r)));
|
||||
const next = new Set(editingIndices);
|
||||
next.delete(index);
|
||||
setEditingIndices(next);
|
||||
};
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
@@ -74,28 +113,80 @@ export function RepositoriesTab() {
|
||||
{t(($) => $.repositories.description)}
|
||||
</p>
|
||||
|
||||
{repos.map((repo, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
value={repo.url}
|
||||
onChange={(e) => handleRepoChange(index, e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder={t(($) => $.repositories.url_placeholder)}
|
||||
className="flex-1 min-w-0 text-sm"
|
||||
/>
|
||||
{canManageWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveRepo(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{repos.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t(($) => $.repositories.empty)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{repos.map((repo, index) => {
|
||||
const isEditing = editingIndices.has(index);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="group flex items-center gap-2"
|
||||
>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="url"
|
||||
value={repo.url}
|
||||
onChange={(e) => handleRepoChange(index, e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder={t(($) => $.repositories.url_placeholder)}
|
||||
className="flex-1 min-w-0 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 min-w-0 truncate rounded-md border bg-muted/50 px-3 py-2 font-mono text-xs text-muted-foreground"
|
||||
title={repo.url}
|
||||
>
|
||||
{repo.url || t(($) => $.repositories.url_empty)}
|
||||
</div>
|
||||
)}
|
||||
{canManageWorkspace && (
|
||||
<div
|
||||
className={
|
||||
isEditing
|
||||
? "flex shrink-0 items-center gap-0.5"
|
||||
: "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
}
|
||||
>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t(($) => $.repositories.edit_aria)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleEditRepo(index)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t(($) => $.repositories.cancel_aria)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleCancelEdit(index)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t(($) => $.repositories.delete_aria)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveRepo(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{canManageWorkspace && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
@@ -103,14 +194,21 @@ export function RepositoriesTab() {
|
||||
<Plus className="h-3 w-3" />
|
||||
{t(($) => $.repositories.add)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? t(($) => $.repositories.saving) : t(($) => $.repositories.save)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
{!dirty && repos.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.repositories.saved_hint)}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? t(($) => $.repositories.saving) : t(($) => $.repositories.save)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user