Skip to content
Open
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
13 changes: 13 additions & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Arc<Store>>>>,
project_id: String,
name: String,
) -> Result<store::Project, String> {
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<Option<Arc<Store>>>>,
Expand Down Expand Up @@ -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,
Expand Down
69 changes: 68 additions & 1 deletion apps/staged/src-tauri/src/project_commands.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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<store::Project, String> {
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.
Expand Down Expand Up @@ -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}");
}
}
20 changes: 20 additions & 0 deletions apps/staged/src-tauri/src/store/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Comment on lines +125 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep concurrent metadata writes from undoing renames

When another window or MCP client is adding a first/primary repository concurrently, add_project_repo_impl snapshots project.name before its workspace await and later passes that snapshot to update_project, which rewrites the name along with the repository fields. If this rename completes during that interval, the later repository update silently restores the old name; the primary-repository and removal paths have the same read-then-full-row-write pattern. Updating only the name here protects one race ordering but not the reverse, so the other project metadata writers also need to become column-scoped or otherwise preserve a concurrently renamed value.

Useful? React with 👍 / 👎.

)?;
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> {
Expand Down
22 changes: 22 additions & 0 deletions apps/staged/src-tauri/src/store/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down
6 changes: 6 additions & 0 deletions apps/staged/src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ export async function createProject(
return project;
}

export async function renameProject(projectId: string, name: string): Promise<Project> {
const project = await invokeCommand<Project>('rename_project', { projectId, name });
await invalidateCacheByCommand('list_projects');
return project;
}

export async function deleteProject(id: string): Promise<void> {
await invokeCommand('delete_project', { id });
await Promise.all([
Expand Down
12 changes: 12 additions & 0 deletions apps/staged/src/lib/features/projects/ProjectHome.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<Project> {
const project = await commands.renameProject(projectId, name);
projectsDataStore.projectRenamed(project.id, project.name);
return project;
}

function handleDeleteCurrentProjectShortcut(event: Event) {
if (
!selectedProject ||
Expand Down Expand Up @@ -975,6 +986,7 @@
{deletingBranches}
{worktreeErrors}
{workspaceErrors}
onRenameProject={handleRenameProject}
onDeleteBranch={(branchId) => handleDeleteBranchRequest(branchId, project)}
onRenameBranch={(branchId, branchName) =>
handleRenameBranch(branchId, project.id, branchName)}
Expand Down
Loading
Loading