From 9506247233d4a1019e0828331e184dd2f492c6d1 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 6 May 2026 11:37:38 +1000 Subject: [PATCH] feat(staged): add a project rename flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a project-only rename: a `rename_project` command and an inline title editor on the project page. The write is metadata-only — it changes the `projects.name` column and nothing else. Branches, worktrees, Blox workspaces and repo branch names keep whatever was inferred from the old name when they were created, so nothing on disk moves and the rename is safe while sessions in the project are queued or running. Fitted to what has landed since the original commit: - The store gets a name-only `rename_project` write instead of routing through `update_project`, which rewrites the primary-repo columns and `location` from a snapshot and could carry stale values back over a concurrent transfer or branch move. - The command body lives in `project_commands::rename_project_impl`, shared by the Tauri command and a new web-server dispatch arm, which the web-dispatch parity test requires. - ProjectHome paints the new name into the shared `projectsDataStore` via a new `projectRenamed` entry point, so the detail-page title, sidebar row and sticky top-bar echo update at once; the store change feed's `project-changed` refetch then confirms it in every window. The window title is derived from the sidebar filters, not the project name, so it is unaffected. - The editor sits inside the title row next to the status picker. The pencil button is a sibling of the `

` rather than a child, so the heading's accessible name is just the project name and heading navigation does not land on a control; the heading itself stays mounted through editing because ProjectHome measures it for the sticky top-bar handoff. The editor distinguishes Enter from blur. Enter validates and keeps the user in the input (a blank draft shows an inline error, a failed save refocuses so the name can be fixed). Blur means the user has moved on: a blank draft, an unchanged name, or the same name whose save just failed all cancel and restore the old name, so the input losing focus after a failure cannot loop through refocus → blur → identical rename → toast. An Enter that confirms an IME composition candidate (`isComposing`) is ignored so a half-composed name is never submitted. The failure toast carries a stable per-project id so retries replace the previous toast rather than stacking infinite-duration ones, and it is dismissed when a retry succeeds or the editor is cancelled, so the pinned toast cannot outlive the error it describes. A delete starting while the editor is open closes it, and the save path refuses to run while deleting, so the blur from the disabled input never renames a project that is being torn down. Covered by a store test for the name-only write, unit tests for `rename_project_impl` (trimming, blank rejection, missing project), component tests for the inline editor (Enter and blur saves, the pending state, the blur cancels, the IME guard, toast dismissal on success and cancel, the editor closing when `deleting` flips, and the heading structure), and a store test for the immediate repaint. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 13 + apps/staged/src-tauri/src/project_commands.rs | 69 ++- apps/staged/src-tauri/src/store/projects.rs | 20 + apps/staged/src-tauri/src/store/tests.rs | 22 + apps/staged/src-tauri/src/web_server.rs | 7 + apps/staged/src/lib/commands.ts | 6 + .../lib/features/projects/ProjectHome.svelte | 12 + .../projects/ProjectSection.component.test.ts | 443 ++++++++++++++++-- .../features/projects/ProjectSection.svelte | 272 ++++++++++- .../lib/stores/projectsData.component.test.ts | 31 ++ .../src/lib/stores/projectsData.svelte.ts | 12 +- 11 files changed, 871 insertions(+), 36 deletions(-) diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index d7f00dcbb..f1e8af1e8 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -671,6 +671,18 @@ fn create_project( Ok(project) } +/// Rename a project in place. Only the stored name changes; see +/// [`project_commands::rename_project_impl`]. +#[tauri::command(rename_all = "camelCase")] +fn rename_project( + store: tauri::State<'_, Mutex>>>, + project_id: String, + name: String, +) -> Result { + let store = get_store(&store)?; + project_commands::rename_project_impl(&store, &project_id, &name) +} + #[tauri::command(rename_all = "camelCase")] fn list_project_repos( store: tauri::State<'_, Mutex>>>, @@ -2318,6 +2330,7 @@ pub fn run() { launch_resume::resume_interrupted_sessions, list_projects, create_project, + rename_project, list_project_repos, list_recent_repos, get_suggested_repos, diff --git a/apps/staged/src-tauri/src/project_commands.rs b/apps/staged/src-tauri/src/project_commands.rs index a9d48d648..3c06b4a22 100644 --- a/apps/staged/src-tauri/src/project_commands.rs +++ b/apps/staged/src-tauri/src/project_commands.rs @@ -1,4 +1,5 @@ -//! Shared project command implementations used by both Tauri commands and MCP tools. +//! Shared project command implementations used by Tauri commands, the web +//! transport and MCP tools. use std::collections::HashSet; use std::sync::{Arc, Mutex, OnceLock}; @@ -17,6 +18,32 @@ pub(crate) struct AddedProjectRepo { pub provisions_workspace: bool, } +/// Rename a project, shared by the `rename_project` Tauri command and the +/// web transport. +/// +/// A metadata-only write: the `projects.name` column changes and the store +/// publishes `project-changed`. Branch names, worktrees, Blox workspaces and +/// repo branch names keep whatever was inferred from the old name when they +/// were created, so nothing on disk moves. It is not gated on an in-flight +/// transfer: a transfer rewrites `location`, which this write never carries. +pub(crate) fn rename_project_impl( + store: &Store, + project_id: &str, + name: &str, +) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Project name is required".to_string()); + } + store + .rename_project(project_id, trimmed) + .map_err(|e| e.to_string())?; + store + .get_project(project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}")) +} + /// Core logic for adding a GitHub repository to a project. /// /// Called by both the `add_project_repo` Tauri command and the MCP tool. @@ -511,4 +538,44 @@ mod tests { RemoteWorkspace::Provision(_) )); } + + #[test] + fn rename_project_impl_trims_and_stores_the_name() { + let store = Store::in_memory().unwrap(); + let project = store::Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + + let renamed = rename_project_impl(&store, &project.id, " Renamed ").unwrap(); + + assert_eq!(renamed.name, "Renamed"); + assert_eq!(renamed.id, project.id); + assert_eq!( + store.get_project(&project.id).unwrap().unwrap().name, + "Renamed" + ); + } + + #[test] + fn rename_project_impl_rejects_a_blank_name_without_writing() { + let store = Store::in_memory().unwrap(); + let project = store::Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + + let err = rename_project_impl(&store, &project.id, " \t\n").unwrap_err(); + + assert_eq!(err, "Project name is required"); + assert_eq!( + store.get_project(&project.id).unwrap().unwrap().name, + project.name + ); + } + + #[test] + fn rename_project_impl_surfaces_a_missing_project() { + let store = Store::in_memory().unwrap(); + + let err = rename_project_impl(&store, "missing", "Renamed").unwrap_err(); + + assert!(err.contains("Project not found"), "{err}"); + } } diff --git a/apps/staged/src-tauri/src/store/projects.rs b/apps/staged/src-tauri/src/store/projects.rs index 9c12fcfce..fac973acd 100644 --- a/apps/staged/src-tauri/src/store/projects.rs +++ b/apps/staged/src-tauri/src/store/projects.rs @@ -115,6 +115,26 @@ impl Store { Ok(()) } + /// Rename a project. Only the name column moves: the primary-repo + /// columns are rewritten by `update_project` and `location` by a transfer, + /// so a rename racing either must not carry a stale snapshot of them back + /// over the row. Nothing derived from the name at creation time (branch + /// names, worktrees, workspaces) is touched. + pub fn rename_project(&self, id: &str, name: &str) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + let updated = conn.execute( + "UPDATE projects SET name = ?1, updated_at = ?2 WHERE id = ?3", + params![name, now_timestamp(), id], + )?; + if updated == 0 { + return Err(StoreError(format!("Project not found: {id}"))); + } + self.publish(StoreChange::Project { + project_id: Some(id.to_string()), + }); + Ok(()) + } + /// Delete a project. Its `project_tags` rows go with it (FK cascade), so /// this publishes [`StoreChange::Tags`] as well. pub fn delete_project(&self, id: &str) -> Result<(), StoreError> { diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index d819cf6f8..050adb97f 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -119,6 +119,28 @@ fn test_list_projects_breaks_created_at_ties_by_insert_order() { assert_eq!(ids, vec![second.id, first.id]); } +#[test] +fn test_rename_project_moves_only_the_name() { + let store = Store::in_memory().unwrap(); + let mut project = Project::new("test-owner/test-repo"); + project.subpath = Some("apps/one".to_string()); + store.create_project(&project).unwrap(); + + store.rename_project(&project.id, "Renamed").unwrap(); + + let fetched = store.get_project(&project.id).unwrap().unwrap(); + assert_eq!(fetched.name, "Renamed"); + assert_eq!(fetched.github_repo, project.github_repo); + assert_eq!(fetched.location, project.location); + assert_eq!(fetched.subpath.as_deref(), Some("apps/one")); + assert_eq!(fetched.created_at, project.created_at); + assert!(fetched.updated_at >= project.updated_at); + assert_eq!(store.list_projects().unwrap()[0].name, "Renamed"); + + let err = store.rename_project("missing", "Nope").unwrap_err(); + assert!(err.to_string().contains("Project not found"), "{err}"); +} + #[test] fn test_project_note_sets_completed_at_when_created_with_content() { let note = ProjectNote::new("project-1", "Title", "Body"); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index adc4a023e..91fc349a8 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1336,6 +1336,13 @@ pub(crate) async fn dispatch( .await?; Ok(Value::Null) } + "rename_project" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + let name: String = arg(&args, "name")?; + let project = crate::project_commands::rename_project_impl(&store, &project_id, &name)?; + Ok(serde_json::to_value(project).unwrap()) + } "set_primary_project_repo" => { let store = get_store(store_mutex)?; let project_id: String = arg(&args, "projectId")?; diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index f8e5ada70..b48a3faf2 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -230,6 +230,12 @@ export async function createProject( return project; } +export async function renameProject(projectId: string, name: string): Promise { + const project = await invokeCommand('rename_project', { projectId, name }); + await invalidateCacheByCommand('list_projects'); + return project; +} + export async function deleteProject(id: string): Promise { await invokeCommand('delete_project', { id }); await Promise.all([ diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 977d05468..3771365bd 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -540,6 +540,17 @@ selectProject(project.id); } + // The backend's name write publishes on the store change feed, so the + // project-changed refetch makes the new name authoritative in this window + // and any peer. Painting it into the shared store first means the detail + // title, sidebar row and sticky top-bar echo don't wait out the feed's + // coalescing window. + async function handleRenameProject(projectId: string, name: string): Promise { + const project = await commands.renameProject(projectId, name); + projectsDataStore.projectRenamed(project.id, project.name); + return project; + } + function handleDeleteCurrentProjectShortcut(event: Event) { if ( !selectedProject || @@ -975,6 +986,7 @@ {deletingBranches} {worktreeErrors} {workspaceErrors} + onRenameProject={handleRenameProject} onDeleteBranch={(branchId) => handleDeleteBranchRequest(branchId, project)} onRenameBranch={(branchId, branchName) => handleRenameBranch(branchId, project.id, branchName)} diff --git a/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts b/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts index 357570f75..fa71c21ac 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts +++ b/apps/staged/src/lib/features/projects/ProjectSection.component.test.ts @@ -1,35 +1,40 @@ -import { flushSync, mount, tick, unmount } from 'svelte'; +import { flushSync, mount, tick, unmount, type ComponentProps } from 'svelte'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Project, ProjectNote } from '../../types'; import type { ResumeSessionOutcome } from '../../commands'; -const { writer, loadDraftMock, commandMocks, eventHandlers, toastError } = vi.hoisted(() => ({ - writer: { - scheduleSave: vi.fn(), - saveNow: vi.fn(async () => {}), - clearNow: vi.fn(async () => {}), - flush: vi.fn(async () => {}), - }, - loadDraftMock: vi.fn(async (_key: string): Promise => null), - commandMocks: { - listProjectNotes: vi.fn(async (_projectId: string): Promise => []), - getProjectNoteBySession: vi.fn(async (_sessionId: string): Promise => null), - resumeSession: vi.fn( - async ( - _sessionId: string, - _prompt: string, - _imageIds?: string[], - _branchId?: string | null - ): Promise => ({ outcome: 'started' }) - ), - deleteProjectNote: vi.fn(async (_noteId: string): Promise => {}), - cancelSession: vi.fn(async (_sessionId: string): Promise => {}), - listChildSessions: vi.fn(async (_sessionId: string) => []), - }, - /** The section's transport listeners, keyed by event name. */ - eventHandlers: new Map unknown>(), - toastError: vi.fn(), -})); +const { writer, loadDraftMock, commandMocks, eventHandlers, toastError, toastDismiss } = vi.hoisted( + () => ({ + writer: { + scheduleSave: vi.fn(), + saveNow: vi.fn(async () => {}), + clearNow: vi.fn(async () => {}), + flush: vi.fn(async () => {}), + }, + loadDraftMock: vi.fn(async (_key: string): Promise => null), + commandMocks: { + listProjectNotes: vi.fn(async (_projectId: string): Promise => []), + getProjectNoteBySession: vi.fn( + async (_sessionId: string): Promise => null + ), + resumeSession: vi.fn( + async ( + _sessionId: string, + _prompt: string, + _imageIds?: string[], + _branchId?: string | null + ): Promise => ({ outcome: 'started' }) + ), + deleteProjectNote: vi.fn(async (_noteId: string): Promise => {}), + cancelSession: vi.fn(async (_sessionId: string): Promise => {}), + listChildSessions: vi.fn(async (_sessionId: string) => []), + }, + /** The section's transport listeners, keyed by event name. */ + eventHandlers: new Map unknown>(), + toastError: vi.fn(), + toastDismiss: vi.fn(), + }) +); // Heavy leaf components are stubbed out: the tests exercise the section's // project-session draft wiring and the state it hands its note rows, not the @@ -85,7 +90,7 @@ vi.mock('../../transport', async (importOriginal) => ({ return () => eventHandlers.delete(event); }, })); -vi.mock('svelte-sonner', () => ({ toast: { error: toastError } })); +vi.mock('svelte-sonner', () => ({ toast: { error: toastError, dismiss: toastDismiss } })); vi.mock('../../shared/draftCache', () => ({ createDraftWriteScheduler: () => writer, loadDraft: (key: string) => loadDraftMock(key), @@ -98,6 +103,7 @@ vi.mock('../../commands', async (importOriginal) => ({ })); import ProjectSection from './ProjectSection.svelte'; +import { reactiveProps } from '../sessions/testStubs/reactiveProps.svelte'; const project = { id: 'project-1', @@ -130,10 +136,10 @@ const completedNote: ProjectNote = { const REF = `#project-note:${completedNote.id}`; const DRAFT_KEY = `draft:project-session:${project.id}`; -function mountSection() { +function mountSection(extra: Partial> = {}) { const target = document.createElement('div'); document.body.appendChild(target); - return mount(ProjectSection, { target, props: { project, branches: [] } }); + return mount(ProjectSection, { target, props: { project, branches: [], ...extra } }); } /** Let the section's async note/draft loads and effect flushes run to completion. */ @@ -241,6 +247,7 @@ beforeEach(() => { document.body.innerHTML = ''; eventHandlers.clear(); toastError.mockReset(); + toastDismiss.mockReset(); for (const mock of Object.values(commandMocks)) mock.mockReset(); listNotes(completedNote); commandMocks.getProjectNoteBySession.mockImplementation(async () => null); @@ -618,3 +625,377 @@ describe('ProjectSection note rows', () => { unmount(section); }); }); + +// ── Inline project rename ── + +function renameButton(): HTMLButtonElement | null { + return document.querySelector('[aria-label="Rename project"]'); +} + +function nameInput(): HTMLInputElement | null { + return document.querySelector('[aria-label="Project name"]'); +} + +async function openRename() { + const btn = renameButton(); + if (!btn) throw new Error('rename button not rendered'); + btn.click(); + flushSync(); + await settle(); + const input = nameInput(); + if (!input) throw new Error('project name input not rendered'); + return input; +} + +function typeName(input: HTMLInputElement, value: string) { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + flushSync(); +} + +function pressKey(input: HTMLInputElement, key: string, init: KeyboardEventInit = {}) { + input.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }) + ); + flushSync(); +} + +function blur(input: HTMLInputElement) { + input.dispatchEvent(new FocusEvent('blur')); +} + +const RENAME_TOAST_ID = `rename-project-${project.id}`; + +describe('ProjectSection inline rename', () => { + it('disables the pencil when no rename handler is wired', async () => { + const section = mountSection(); + await settle(); + + expect(renameButton()?.disabled).toBe(true); + expect(document.querySelector('.project-title')?.textContent).toContain('Project'); + + unmount(section); + }); + + it('keeps the pencil out of the heading so its accessible name is the project name', async () => { + const section = mountSection({ onRenameProject: vi.fn() }); + await settle(); + + const heading = document.querySelector('h2.project-title'); + expect(heading?.textContent?.trim()).toBe('Project'); + expect(heading?.querySelector('button')).toBeNull(); + const button = renameButton(); + expect(button?.parentElement).toBe(heading?.parentElement); + expect(heading?.nextElementSibling).toBe(button); + + unmount(section); + }); + + it('edits the current name in place and saves a changed name on Enter', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => {}); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + expect(input.value).toBe('Project'); + // The heading stays mounted around the editor so ProjectHome's measured + // title element keeps its identity. + expect(input.closest('h2.project-title')).not.toBeNull(); + + typeName(input, ' Renamed '); + pressKey(input, 'Enter'); + await settle(); + + expect(onRenameProject).toHaveBeenCalledWith(project.id, 'Renamed'); + expect(nameInput()).toBeNull(); + expect(toastError).not.toHaveBeenCalled(); + + unmount(section); + }); + + it('cancels on Escape or an unchanged name without calling the handler', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => {}); + const section = mountSection({ onRenameProject }); + await settle(); + + let input = await openRename(); + typeName(input, 'Something else'); + pressKey(input, 'Escape'); + expect(nameInput()).toBeNull(); + + input = await openRename(); + typeName(input, ' Project '); + pressKey(input, 'Enter'); + await settle(); + expect(nameInput()).toBeNull(); + + expect(onRenameProject).not.toHaveBeenCalled(); + + unmount(section); + }); + + it('rejects a blank name inline and keeps editing', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => {}); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, ' '); + pressKey(input, 'Enter'); + await settle(); + + expect(onRenameProject).not.toHaveBeenCalled(); + expect(nameInput()).not.toBeNull(); + expect(document.querySelector('[role="alert"]')?.textContent).toBe('Project name is required'); + + unmount(section); + }); + + it('reports a failed rename with a toast and keeps the editor open', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => { + throw new Error('database is locked'); + }); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, 'Renamed'); + pressKey(input, 'Enter'); + await settle(); + + expect(onRenameProject).toHaveBeenCalledWith(project.id, 'Renamed'); + expect(toastError).toHaveBeenCalledWith('Unable to rename project', { + id: RENAME_TOAST_ID, + description: 'database is locked', + duration: Infinity, + }); + const stillEditing = nameInput(); + expect(stillEditing).not.toBeNull(); + expect(stillEditing?.disabled).toBe(false); + expect(document.querySelector('[role="alert"]')?.textContent).toBe('database is locked'); + + unmount(section); + }); + + it('cancels on blur after a failed save instead of re-submitting the same name', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => { + throw new Error('database is locked'); + }); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, 'Renamed'); + pressKey(input, 'Enter'); + await settle(); + expect(onRenameProject).toHaveBeenCalledTimes(1); + expect(toastError).toHaveBeenCalledTimes(1); + + // The editor refocused after the failure; the user clicks away. + blur(input); + await settle(); + + expect(onRenameProject).toHaveBeenCalledTimes(1); + expect(toastError).toHaveBeenCalledTimes(1); + // Giving up takes the pinned failure toast down with the editor. + expect(toastDismiss).toHaveBeenCalledWith(RENAME_TOAST_ID); + expect(nameInput()).toBeNull(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + expect(document.querySelector('.project-title-text')?.textContent).toBe('Project'); + + unmount(section); + }); + + it('retries a failed name on Enter and replaces the toast rather than stacking one', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => { + throw new Error('database is locked'); + }); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, 'Renamed'); + pressKey(input, 'Enter'); + await settle(); + pressKey(input, 'Enter'); + await settle(); + + expect(onRenameProject).toHaveBeenCalledTimes(2); + expect(toastError).toHaveBeenCalledTimes(2); + const ids = new Set(toastError.mock.calls.map(([, opts]) => (opts as { id: string }).id)); + expect(ids).toEqual(new Set([RENAME_TOAST_ID])); + expect(nameInput()).not.toBeNull(); + + unmount(section); + }); + + it('cancels a blank draft on blur and restores the old name', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => {}); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, ' '); + blur(input); + await settle(); + + expect(onRenameProject).not.toHaveBeenCalled(); + expect(nameInput()).toBeNull(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + expect(document.querySelector('.project-title-text')?.textContent).toBe('Project'); + + unmount(section); + }); + + it('disables the editor while a rename is pending and closes it exactly once', async () => { + let resolveRename!: () => void; + const onRenameProject = vi.fn( + (_id: string, _name: string) => + new Promise((resolve) => { + resolveRename = resolve; + }) + ); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, 'Renamed'); + pressKey(input, 'Enter'); + await settle(); + + expect(onRenameProject).toHaveBeenCalledTimes(1); + expect(input.disabled).toBe(true); + expect(document.querySelector('.project-name-editor .spinner')).not.toBeNull(); + + // A second Enter, a blur and an Escape are all ignored until it settles. + pressKey(input, 'Enter'); + blur(input); + pressKey(input, 'Escape'); + await settle(); + expect(onRenameProject).toHaveBeenCalledTimes(1); + expect(nameInput()).toBe(input); + expect(input.disabled).toBe(true); + + resolveRename(); + await settle(); + + expect(nameInput()).toBeNull(); + expect(document.querySelector('.project-name-editor')).toBeNull(); + expect(onRenameProject).toHaveBeenCalledTimes(1); + expect(toastError).not.toHaveBeenCalled(); + + unmount(section); + }); + + it('saves a changed draft on blur and closes the editor', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => {}); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, ' Renamed on blur '); + blur(input); + await settle(); + + expect(onRenameProject).toHaveBeenCalledWith(project.id, 'Renamed on blur'); + expect(nameInput()).toBeNull(); + expect(document.querySelector('.project-name-editor')).toBeNull(); + expect(toastError).not.toHaveBeenCalled(); + + unmount(section); + }); + + it('closes the editor without renaming when a delete starts while it is open', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => {}); + const props = reactiveProps({ project, branches: [], deleting: false, onRenameProject }); + const target = document.createElement('div'); + document.body.appendChild(target); + const section = mount(ProjectSection, { target, props }); + await settle(); + + const input = await openRename(); + typeName(input, 'Renamed'); + + props.deleting = true; + flushSync(); + // Disabling the input blurs it in a real browser; that blur must not save. + blur(input); + await settle(); + + expect(onRenameProject).not.toHaveBeenCalled(); + expect(nameInput()).toBeNull(); + expect(document.querySelector('.project-title-text')?.textContent).toBe('Project'); + expect(renameButton()?.disabled).toBe(true); + + unmount(section); + }); + + it('ignores the Enter that confirms an IME composition', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => {}); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, 'にほ'); + pressKey(input, 'Enter', { isComposing: true }); + await settle(); + + expect(onRenameProject).not.toHaveBeenCalled(); + expect(nameInput()).toBe(input); + + // The real Enter after the composition has ended still submits. + typeName(input, '日本'); + pressKey(input, 'Enter'); + await settle(); + expect(onRenameProject).toHaveBeenCalledWith(project.id, '日本'); + expect(nameInput()).toBeNull(); + + unmount(section); + }); + + it('dismisses the pinned failure toast once a retry succeeds', async () => { + const onRenameProject = vi + .fn(async (_id: string, _name: string) => {}) + .mockRejectedValueOnce(new Error('database is locked')); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, 'Renamed'); + pressKey(input, 'Enter'); + await settle(); + expect(toastError).toHaveBeenCalledTimes(1); + expect(toastDismiss).not.toHaveBeenCalled(); + + pressKey(input, 'Enter'); + await settle(); + + expect(onRenameProject).toHaveBeenCalledTimes(2); + expect(toastDismiss).toHaveBeenCalledWith(RENAME_TOAST_ID); + expect(nameInput()).toBeNull(); + + unmount(section); + }); + + it('dismisses the pinned failure toast when the editor is cancelled with Escape', async () => { + const onRenameProject = vi.fn(async (_id: string, _name: string) => { + throw new Error('database is locked'); + }); + const section = mountSection({ onRenameProject }); + await settle(); + + const input = await openRename(); + typeName(input, 'Renamed'); + pressKey(input, 'Enter'); + await settle(); + expect(toastError).toHaveBeenCalledTimes(1); + + pressKey(input, 'Escape'); + await settle(); + + expect(toastDismiss).toHaveBeenCalledWith(RENAME_TOAST_ID); + expect(nameInput()).toBeNull(); + + unmount(section); + }); +}); diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 836e50b42..96c12f2e8 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -5,10 +5,11 @@ controls, and all branch cards for this project. -->