diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index a7835ee2d..663e3a20f 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -175,10 +175,15 @@ "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", + "delete_aria": "Delete repository", "manage_hint": "Only admins and owners can manage repositories.", "toast_saved": "Repositories saved", "toast_save_failed": "Failed to save repositories" diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index fe993212b..11140afa5 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -175,10 +175,15 @@ "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": "编辑仓库", + "delete_aria": "删除仓库", "manage_hint": "只有管理员和所有者可以管理代码仓库。", "toast_saved": "已保存代码仓库", "toast_save_failed": "保存代码仓库失败" diff --git a/packages/views/settings/components/repositories-tab.test.tsx b/packages/views/settings/components/repositories-tab.test.tsx new file mode 100644 index 000000000..b309f5140 --- /dev/null +++ b/packages/views/settings/components/repositories-tab.test.tsx @@ -0,0 +1,176 @@ +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 ( + + {children} + + ); +} + +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(, { 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(, { 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(, { 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(, { 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(, { 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("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(, { 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"); + }); +}); diff --git a/packages/views/settings/components/repositories-tab.tsx b/packages/views/settings/components/repositories-tab.tsx index ac0071e13..dc6b4d26f 100644 --- a/packages/views/settings/components/repositories-tab.tsx +++ b/packages/views/settings/components/repositories-tab.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Save, Plus, Trash2 } from "lucide-react"; +import { Save, Plus, Trash2, Pencil } 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, removed: number): Set { + const next = new Set(); + 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(workspace?.repos ?? []); + const [editingIndices, setEditingIndices] = useState>(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,24 @@ 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)); + }; + if (!workspace) return null; return ( @@ -74,28 +100,63 @@ export function RepositoriesTab() { {t(($) => $.repositories.description)}

- {repos.map((repo, index) => ( -
- handleRepoChange(index, e.target.value)} - disabled={!canManageWorkspace} - placeholder={t(($) => $.repositories.url_placeholder)} - className="flex-1 min-w-0 text-sm" - /> - {canManageWorkspace && ( - - )} -
- ))} + {repos.length === 0 && ( +

+ {t(($) => $.repositories.empty)} +

+ )} + + {repos.map((repo, index) => { + const isEditing = editingIndices.has(index); + return ( +
+ {isEditing ? ( + handleRepoChange(index, e.target.value)} + disabled={!canManageWorkspace} + placeholder={t(($) => $.repositories.url_placeholder)} + className="flex-1 min-w-0 text-sm" + /> + ) : ( +
+ {repo.url || t(($) => $.repositories.url_empty)} +
+ )} + {canManageWorkspace && ( +
+ {!isEditing && ( + + )} + +
+ )} +
+ ); + })} {canManageWorkspace && (
@@ -103,14 +164,21 @@ export function RepositoriesTab() { {t(($) => $.repositories.add)} - +
+ {!dirty && repos.length > 0 && ( + + {t(($) => $.repositories.saved_hint)} + + )} + +
)}