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. -->