From 01d314e7968b8bf5b1fb74ca043dbd23db8ad753 Mon Sep 17 00:00:00 2001
From: Jiayuan Zhang
Date: Thu, 14 May 2026 19:50:21 +0800
Subject: [PATCH] feat(settings): view/edit toggle for repositories tab
Saved repos render as static rows (truncated, monospace) with hover/focus-revealed
Edit + Delete affordances. Clicking Edit flips to the existing Input; on
successful Save the row returns to display mode. Save button is gated on a
dirty check (URL arrays in order) so a clean state reads as "All changes
saved". Resolves user feedback that the always-visible input made saved
state ambiguous (MUL-2217).
- Track editingIndices with a Set; new rows auto-enter edit mode; deleting
a row remaps indices so the wrong row never opens.
- Touch devices and focus-within keep the action buttons reachable.
- New i18n keys in en + zh-Hans (saved_hint, empty, edit/delete_aria, url_empty).
Co-authored-by: multica-agent
---
packages/views/locales/en/settings.json | 5 +
packages/views/locales/zh-Hans/settings.json | 5 +
.../components/repositories-tab.test.tsx | 176 ++++++++++++++++++
.../settings/components/repositories-tab.tsx | 130 ++++++++++---
4 files changed, 285 insertions(+), 31 deletions(-)
create mode 100644 packages/views/settings/components/repositories-tab.test.tsx
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)}
+
+ )}
+
+
)}