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
69 changes: 69 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
) -> AppResult<Project> {
reorder_project_sources_inner(state.inner(), repo_path, order)
}

fn reorder_project_sources_inner(
state: &AppState,
repo_path: String,
order: Vec<String>,
) -> AppResult<Project> {
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<R: Runtime>(
app: AppHandle<R>,
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>(() =>
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,9 @@ export const api = {
removeProjectSource(repoPath: string, sourcePath: string): Promise<Project> {
return invoke<Project>("remove_project_source", { repoPath, sourcePath });
},
reorderProjectSources(repoPath: string, order: string[]): Promise<Project> {
return invoke<Project>("reorder_project_sources", { repoPath, order });
},
selectProjectParentFolder(title?: string): Promise<string | null> {
return invoke<string | null>("select_project_parent_folder", { title });
},
Expand Down
60 changes: 60 additions & 0 deletions src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
),
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,10 @@ interface AppStateModel {
repoPath: string,
sourcePath: string,
) => Promise<boolean>;
reorderProjectSources: (
repoPath: string,
orderedRootPaths: string[],
) => Promise<void>;
removeProjectWorktree: (
repoPath: string,
worktreePath: string,
Expand Down Expand Up @@ -3439,6 +3443,42 @@ export const useAppStore = create<AppStateModel>()(
}
},

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(
Expand Down
11 changes: 11 additions & 0 deletions tests/e2e/fixtures/tauriMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
Loading
Loading