Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
df6e16d6a6 fix(settings): add Cancel affordance to exit clean edit mode
Clicking Edit on a clean saved row opened the row in edit mode with
no way back to display mode unless the user changed the URL and saved,
re-introducing the original saved-state ambiguity after an accidental
click. Add a per-row Cancel (X) button visible only in edit mode that:

- reverts the URL to the saved value for existing rows
- removes the row entirely for never-saved (newly added) rows
- exits edit mode without dirtying Save

Action group is always visible (no hover gate) while editing so the
exit is discoverable. Adds en/zh-Hans cancel_aria string and three
regression tests covering clean-cancel, dirty-cancel, and new-row-cancel.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 19:57:23 +08:00
Jiayuan Zhang
01d314e796 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 <github@multica.ai>
2026-05-14 19:50:21 +08:00
4 changed files with 364 additions and 31 deletions

View File

@@ -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"

View File

@@ -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": "保存代码仓库失败"

View 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");
});
});

View File

@@ -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>
)}