From a2494f856e8fdb7260143556db2806d8b748cbdb Mon Sep 17 00:00:00 2001 From: daeho im Date: Wed, 5 Aug 2026 16:30:30 +0900 Subject: [PATCH] fix(projects): sync source folder order from sidebar --- src-tauri/src/commands.rs | 69 +++++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/Sidebar.tsx | 11 +++ src/lib/api.ts | 3 + src/store.test.ts | 60 +++++++++++++++ src/store.ts | 40 ++++++++++ tests/e2e/fixtures/tauriMock.ts | 11 +++ tests/e2e/project-settings.spec.ts | 118 +++++++++++++++++++++++++++++ 8 files changed, 313 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8c8adf39..715546eb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6530,6 +6530,44 @@ pub fn remove_project_source( Ok(updated) } +/// Persist the sidebar order of a project's extra repository roots. The +/// primary root remains the project identity and is therefore excluded from +/// `source_paths`; unknown and omitted paths cannot change membership. +#[tauri::command] +pub fn reorder_project_sources( + state: State<'_, AppState>, + repo_path: String, + order: Vec, +) -> AppResult { + reorder_project_sources_inner(state.inner(), repo_path, order) +} + +fn reorder_project_sources_inner( + state: &AppState, + repo_path: String, + order: Vec, +) -> AppResult { + let project = state + .projects + .owner_of_root(&PathBuf::from(&repo_path)) + .ok_or_else(|| AppError::InvalidPath(format!("project is not registered: {repo_path}")))?; + let mut remaining = project.source_paths.clone(); + let mut source_paths = Vec::with_capacity(remaining.len()); + for path in order.into_iter().map(PathBuf::from) { + let Some(index) = remaining.iter().position(|candidate| candidate == &path) else { + continue; + }; + source_paths.push(remaining.remove(index)); + } + source_paths.extend(remaining); + let updated = state + .projects + .set_source_paths(&project.repo_path, source_paths) + .ok_or_else(|| AppError::InvalidPath(format!("project is not registered: {repo_path}")))?; + persist(state); + Ok(updated) +} + #[tauri::command] pub async fn select_project_parent_folder( app: AppHandle, @@ -15257,6 +15295,37 @@ mod tests { ); } + #[test] + fn reordering_project_sources_preserves_membership() { + let state = crate::state::AppState::default(); + let primary = PathBuf::from("/tmp/acorn-source-order-primary"); + let api = PathBuf::from("/tmp/acorn-source-order-api"); + let docs = PathBuf::from("/tmp/acorn-source-order-docs"); + let web = PathBuf::from("/tmp/acorn-source-order-web"); + state + .projects + .ensure(primary.clone(), "primary".to_string()); + state + .projects + .set_source_paths(&primary, vec![api.clone(), docs.clone(), web.clone()]) + .expect("project is registered"); + + let updated = super::reorder_project_sources_inner( + &state, + primary.display().to_string(), + vec![ + primary.display().to_string(), + web.display().to_string(), + "/tmp/not-a-project-root".to_string(), + api.display().to_string(), + web.display().to_string(), + ], + ) + .expect("reorder succeeds"); + + assert_eq!(updated.source_paths, vec![web, api, docs]); + } + #[test] fn merging_a_project_moves_every_root_it_spans() { let state = crate::state::AppState::default(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a3f6aa4..3a7b7313 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -721,6 +721,7 @@ pub fn run() { commands::merge_project_source, commands::split_project_source, commands::remove_project_source, + commands::reorder_project_sources, commands::select_project_parent_folder, commands::get_last_project_parent_folder, commands::has_git_identity, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4f0e6559..b7bcfd7e 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -340,6 +340,7 @@ export function Sidebar() { const addProjectSource = useAppStore((s) => s.addProjectSource); const createNewProject = useAppStore((s) => s.createNewProject); const reorderProjects = useAppStore((s) => s.reorderProjects); + const reorderProjectSources = useAppStore((s) => s.reorderProjectSources); const reorderProjectFolders = useAppStore((s) => s.reorderProjectFolders); const reorderSessions = useAppStore((s) => s.reorderSessions); const [collapsed, setCollapsed] = useState>(() => @@ -1030,6 +1031,16 @@ export function Sidebar() { ) .map((item) => item.folderGroup.folder.id), ); + const orderedRootPaths = nextItems + .filter( + (item): item is ProjectTopLevelFolderItem => + item.type === "folder" && + isDefaultProjectFolder(item.folderGroup.folder), + ) + .map((item) => item.folderGroup.folder.repoPath); + if (orderedRootPaths.length > 1) { + void reorderProjectSources(project.repoPath, orderedRootPaths); + } const sessionIds = nextItems .filter( (item): item is ProjectTopLevelSessionItem => item.type === "session", diff --git a/src/lib/api.ts b/src/lib/api.ts index 571ee5b8..28e1d6bd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -415,6 +415,9 @@ export const api = { removeProjectSource(repoPath: string, sourcePath: string): Promise { return invoke("remove_project_source", { repoPath, sourcePath }); }, + reorderProjectSources(repoPath: string, order: string[]): Promise { + return invoke("reorder_project_sources", { repoPath, order }); + }, selectProjectParentFolder(title?: string): Promise { return invoke("select_project_parent_folder", { title }); }, diff --git a/src/store.test.ts b/src/store.test.ts index de1dcfb8..28e911ba 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -55,6 +55,15 @@ vi.mock("./lib/api", () => { position: i, })), ), + reorderProjectSources: vi.fn( + async (repoPath: string, orderedRootPaths: string[]) => ({ + repo_path: repoPath, + name: repoPath, + created_at: "2026-01-01", + position: 0, + source_paths: orderedRootPaths.filter((path) => path !== repoPath), + }), + ), reorderSessions: vi.fn(async (_repoPath: string, _ids: string[]) => [] as Session[], ), @@ -3571,6 +3580,57 @@ describe("reorderProjects", () => { }); }); +describe("reorderProjectSources", () => { + const SOURCE_A = `${REPO_A}/api`; + const SOURCE_B = `${REPO_A}/docs`; + + it("optimistically reorders source folders and commits the backend result", async () => { + await seed( + [ + { + ...project(REPO_A, 0), + source_paths: [SOURCE_A, SOURCE_B], + }, + ], + [], + ); + mockApi.reorderProjectSources.mockResolvedValueOnce({ + ...project(REPO_A, 0), + source_paths: [SOURCE_B, SOURCE_A], + }); + + await useAppStore + .getState() + .reorderProjectSources(REPO_A, [REPO_A, SOURCE_B, SOURCE_A]); + + expect(useAppStore.getState().projects[0]?.source_paths).toEqual([ + SOURCE_B, + SOURCE_A, + ]); + expect(mockApi.reorderProjectSources).toHaveBeenCalledWith(REPO_A, [ + REPO_A, + SOURCE_B, + SOURCE_A, + ]); + }); + + it("rolls back the source order on failure", async () => { + const original = { + ...project(REPO_A, 0), + source_paths: [SOURCE_A, SOURCE_B], + }; + await seed([original], []); + mockApi.reorderProjectSources.mockRejectedValueOnce(new Error("nope")); + + await useAppStore + .getState() + .reorderProjectSources(REPO_A, [SOURCE_B, SOURCE_A]); + + expect(useAppStore.getState().projects).toEqual([original]); + expect(useAppStore.getState().error).toBe("nope"); + }); +}); + describe("createSession", () => { // Each control-session test reaches into localStorage; clear it so // an earlier test's "don't show again" flag does not leak forward. diff --git a/src/store.ts b/src/store.ts index 0772840f..54868367 100644 --- a/src/store.ts +++ b/src/store.ts @@ -573,6 +573,10 @@ interface AppStateModel { repoPath: string, sourcePath: string, ) => Promise; + reorderProjectSources: ( + repoPath: string, + orderedRootPaths: string[], + ) => Promise; removeProjectWorktree: ( repoPath: string, worktreePath: string, @@ -3439,6 +3443,42 @@ export const useAppStore = create()( } }, + async reorderProjectSources(repoPath, orderedRootPaths) { + const previous = get().projects; + const project = previous.find((candidate) => + projectRootPaths(candidate).includes(repoPath), + ); + if (!project || (project.source_paths?.length ?? 0) < 2) return; + + const remaining = new Set(project.source_paths ?? []); + const sourcePaths: string[] = []; + for (const path of orderedRootPaths) { + if (!remaining.delete(path)) continue; + sourcePaths.push(path); + } + sourcePaths.push(...remaining); + const optimistic = previous.map((candidate) => + candidate.repo_path === project.repo_path + ? { ...candidate, source_paths: sourcePaths } + : candidate, + ); + set({ projects: optimistic }); + try { + const updated = await api.reorderProjectSources( + project.repo_path, + orderedRootPaths, + ); + set((state) => ({ + projects: state.projects.map((candidate) => + candidate.repo_path === updated.repo_path ? updated : candidate, + ), + error: null, + })); + } catch (e) { + set({ projects: previous, error: errorMessage(e) }); + } + }, + async createNewProject(parentPath, name, ignoreSafeName = false, initCommit = true) { try { const project = await api.createNewProject( diff --git a/tests/e2e/fixtures/tauriMock.ts b/tests/e2e/fixtures/tauriMock.ts index 7dc69b9a..14078deb 100644 --- a/tests/e2e/fixtures/tauriMock.ts +++ b/tests/e2e/fixtures/tauriMock.ts @@ -157,6 +157,17 @@ export const tauriMockSource = ` source_paths: [], }); } + if (cmd === 'reorder_project_sources') { + return Promise.resolve({ + repo_path: args?.repoPath ?? '/tmp/picked', + name: 'picked', + created_at: '2026-01-01T00:00:00Z', + position: 0, + source_paths: (args?.order ?? []).filter( + (path) => path !== args?.repoPath, + ), + }); + } if (cmd === 'select_project_parent_folder') { return Promise.resolve('/tmp'); } diff --git a/tests/e2e/project-settings.spec.ts b/tests/e2e/project-settings.spec.ts index c17c2770..4af21249 100644 --- a/tests/e2e/project-settings.spec.ts +++ b/tests/e2e/project-settings.spec.ts @@ -1,3 +1,4 @@ +import type { Locator, Page } from "@playwright/test"; import { test, expect, @@ -6,7 +7,124 @@ import { modalShell, } from "./support"; +async function dragBetween( + page: Page, + source: Locator, + target: Locator, +): Promise { + const sourceBox = await source.boundingBox(); + const targetBox = await target.boundingBox(); + if (!sourceBox || !targetBox) { + throw new Error("drag source or target is not visible"); + } + await page.mouse.move( + sourceBox.x + Math.min(60, sourceBox.width / 2), + sourceBox.y + sourceBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + sourceBox.x + Math.min(84, sourceBox.width - 2), + sourceBox.y + sourceBox.height / 2, + { steps: 3 }, + ); + await page.mouse.move( + targetBox.x + targetBox.width / 2, + targetBox.y + targetBox.height / 2, + { steps: 10 }, + ); + await page.mouse.up(); +} + test.describe("project settings", () => { + test("keeps source folders in the order set by sidebar drag and drop", async ({ + page, + tauri, + }) => { + await tauri.handle("list_projects", () => { + const w = window as unknown as { + __projects?: Array>; + }; + w.__projects = w.__projects ?? [ + { + repo_path: "/tmp/acorn", + name: "acorn", + created_at: "2026-01-01T00:00:00Z", + position: 0, + source_paths: ["/tmp/acorn-api", "/tmp/acorn-docs"], + }, + ]; + return w.__projects; + }); + await tauri.respond("list_sessions", []); + await tauri.handle("reorder_project_sources", (args) => { + const w = window as unknown as { + __projects?: Array>; + __reorderSourceCalls?: unknown[]; + }; + const input = args as { repoPath: string; order: string[] }; + w.__reorderSourceCalls = [...(w.__reorderSourceCalls ?? []), input]; + w.__projects = (w.__projects ?? []).map((project) => + project.repo_path === input.repoPath + ? { + ...project, + source_paths: input.order.filter( + (path) => path !== input.repoPath, + ), + } + : project, + ); + return w.__projects.find( + (project) => project.repo_path === input.repoPath, + ); + }); + + await page.goto("/"); + + const sidebar = page.locator("aside"); + const api = sidebar.locator('[data-sidebar-workspace-id="/tmp/acorn-api"]'); + const docs = sidebar.locator( + '[data-sidebar-workspace-id="/tmp/acorn-docs"]', + ); + await dragBetween(page, docs, api); + + await expect + .poll(async () => { + const docsBox = await docs.boundingBox(); + const apiBox = await api.boundingBox(); + return docsBox && apiBox && docsBox.y < apiBox.y; + }) + .toBe(true); + + await page + .getByRole("button", { name: "Project acorn" }) + .click({ button: "right" }); + await page.getByRole("menuitem", { name: "Project Settings" }).click(); + const modal = page.getByRole("dialog", { name: "Project Settings" }); + await modal.getByRole("button", { name: "Source folders" }).click(); + + const paths = await modal.locator("li p.font-mono").allTextContents(); + expect(paths).toEqual([ + "/tmp/acorn", + "/tmp/acorn-docs", + "/tmp/acorn-api", + ]); + expect( + await page.evaluate( + () => + ( + window as unknown as { + __reorderSourceCalls?: unknown[]; + } + ).__reorderSourceCalls, + ), + ).toEqual([ + { + repoPath: "/tmp/acorn", + order: ["/tmp/acorn", "/tmp/acorn-docs", "/tmp/acorn-api"], + }, + ]); + }); + test("manages project worktrees from the Worktrees settings tab", async ({ page, tauri,