Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/components/ai-edition/v4/EditorShellV4.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,25 @@
flex-shrink: 0;
transition: background var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease);
}
.ghostBtn:hover {
.ghostBtn:hover:not(:disabled) {
background: var(--surface-1);
border-color: var(--border);
}
.ghostBtn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.projectNameInput {
height: 30px;
min-width: 160px;
padding: 0 10px;
border-radius: 9px;
border: 1px solid var(--accent);
background: var(--surface);
color: var(--fg);
font: 500 13px var(--font-display);
outline: none;
}
.saved {
display: inline-flex;
align-items: center;
Expand Down
122 changes: 122 additions & 0 deletions src/components/ai-edition/v4/EditorTopBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

// ProjectNameField is a private helper inside EditorTopBar, so reach it through
// the public topbar instead. The translator echoes keys; assertions read better
// against keys than against prose that drifts with copy edits.
vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({ locale: "en", setLocale: () => {} }),
useScopedT: () => (key: string) => key,
}));

vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({ theme: "dark", toggle: () => {} }),
}));

import { EditorTopBar } from "./EditorTopBar";

const noop = () => {};

function renderTopBar(projectTitle: string | null) {
const onRename = vi.fn();
render(
<EditorTopBar
mode="edit"
onModeChange={noop}
projectTitle={projectTitle}
dirty={false}
canExport={false}
chatOpen={false}
actions={{
openProject: noop,
newProject: noop,
save: noop,
export: noop,
openSettings: noop,
renameProject: onRename,
toggleChat: noop,
}}
/>,
);
return { onRename };
}

describe("ProjectNameField (issue #180)", () => {
it("renders the project title on the button", () => {
renderTopBar("Demo Project");
expect(screen.getByRole("button", { name: "topbar.renameProject" })).toHaveTextContent(
"Demo Project",
);
});

it("shows a placeholder and is disabled when no project is loaded", () => {
renderTopBar(null);
const button = screen.getByRole("button", { name: "topbar.renameProject" });
expect(button).toBeDisabled();
expect(button).toHaveTextContent("topbar.noProject");
});

it("swaps to an input pre-filled with the title on click and selects it", () => {
renderTopBar("Demo Project");
fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
const input = screen.getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("Demo Project");
// The text is selected on focus, so a keystroke replaces the whole title.
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(input).not.toBeInTheDocument();
});

it("commits a typed title on Enter via onRename", () => {
const { onRename } = renderTopBar("Demo Project");
fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("Renamed");
});

it("commits on blur when the title was edited", () => {
const { onRename } = renderTopBar("Demo Project");
fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "Blurred rename" } });
fireEvent.blur(input);
expect(onRename).toHaveBeenCalledWith("Blurred rename");
});

it("rejects an empty / whitespace-only rename", () => {
const { onRename } = renderTopBar("Demo Project");
fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).not.toHaveBeenCalled();
});

it("cancels on Escape without calling onRename", () => {
const { onRename } = renderTopBar("Demo Project");
fireEvent.click(screen.getByRole("button", { name: "topbar.renameProject" }));
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "Half-typed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "topbar.renameProject" })).toHaveTextContent(
"Demo Project",
);
});

it("keeps the rename button in the no-drag region (regression for #180)", () => {
// The pre-fix button used `style={{ all: "unset" }}` which clobbered the
// topbar's `-webkit-app-region: no-drag` rule. In the Electron build the
// button then becomes a window-drag handle and the click never fires the
// onClick handler. The CSS module class must keep the no-drag property.
renderTopBar("Demo Project");
const button = screen.getByRole("button", { name: "topbar.renameProject" });
// jsdom doesn't honour `-webkit-app-region`, so assert the marker
// indirectly via the inline-style rule we removed: the pre-fix button
// had `all: unset`; if any element still has it, the regression is back.
expect(button.getAttribute("style") ?? "").not.toMatch(/all\s*:\s*unset/);
});
});
54 changes: 17 additions & 37 deletions src/components/ai-edition/v4/EditorTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,26 +205,25 @@ function ProjectNameField({
const t = useScopedT("editor");
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(title ?? "");
const inputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
if (editing) {
setDraft(title ?? "");
inputRef.current?.select();
}
}, [editing, title]);
const startEditing = () => {
setDraft(title ?? "");
setEditing(true);
};

const commit = () => {
setEditing(false);
const next = draft.trim();
if (next && next !== title) onRename(next);
if (next) onRename(next);
};

if (editing) {
return (
<input
ref={inputRef}
className={styles.projectNameInput}
autoFocus
value={draft}
onFocus={(e) => e.currentTarget.select()}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
Expand All @@ -235,39 +234,20 @@ function ProjectNameField({
setEditing(false);
}
}}
style={{
height: 30,
minWidth: 160,
padding: "0 10px",
borderRadius: 9,
border: "1px solid var(--accent)",
background: "var(--surface)",
color: "var(--fg)",
font: "500 13px var(--font-display)",
outline: "none",
}}
/>
);
}

return (
<span className={styles.ghostBtn}>
<button
type="button"
title={t("topbar.renameProject")}
aria-label={t("topbar.renameProject")}
disabled={!title}
onClick={() => setEditing(true)}
style={{
all: "unset",
cursor: title ? "pointer" : "default",
color: "inherit",
font: "inherit",
}}
>
{title ?? t("topbar.noProject")}
</button>
</span>
<button
type="button"
className={styles.ghostBtn}
aria-label={t("topbar.renameProject")}
disabled={!title}
onClick={startEditing}
>
{title ?? t("topbar.noProject")}
</button>
);
}

Expand Down
Loading