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
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dotenv": "^17.2.3",
"electron-router-dom": "^2.1.0",
"electron-store": "^11.0.2",
"execa": "^9.6.0",
"fast-glob": "^3.3.3",
"framer-motion": "^12.23.24",
"http-proxy": "^1.18.1",
Expand All @@ -61,6 +62,7 @@
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.8.2",
"react-syntax-highlighter": "^16.1.0",
"simple-git": "^3.30.0",
"superjson": "^2.2.5",
"tailwind-merge": "^2.6.0",
"trpc-electron": "^0.1.2",
Expand Down
104 changes: 44 additions & 60 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,74 @@
import { dialog } from "electron";
import type { BrowserWindow } from "electron";
import { basename } from "node:path";
import { z } from "zod";
import { nanoid } from "nanoid";
import { publicProcedure, router } from "../..";
import { db } from "../../../../main/lib/db";
import type { RecentProject } from "../../../../main/lib/db/schemas";
import type { Project } from "../../../../main/lib/db/schemas";
import { getGitRoot } from "../workspaces/utils/git";

/**
* Projects router
* Handles project selection, recents management, and workspace creation
*/
export const createProjectsRouter = (window: BrowserWindow) => {
return router({
/**
* Open a new project via folder picker
* Adds to recents and returns path for UI to handle
*/
openProject: publicProcedure.mutation(async () => {
getRecents: publicProcedure.query((): Project[] => {
return db.data.projects
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),

openNew: publicProcedure.mutation(async () => {
const result = await dialog.showOpenDialog(window, {
properties: ["openDirectory"],
title: "Open Project",
});

if (result.canceled || result.filePaths.length === 0) {
return { success: false as const };
return { success: false };
}

const path = result.filePaths[0];
const name = basename(path);
const selectedPath = result.filePaths[0];

await db.update((data) => {
const existingIndex = data.recentProjects.findIndex(
(p) => p.path === path,
);
if (existingIndex !== -1) {
data.recentProjects[existingIndex].lastOpenedAt = Date.now();
} else {
data.recentProjects.push({
path,
name,
lastOpenedAt: Date.now(),
});
}
});
let mainRepoPath: string;
try {
mainRepoPath = await getGitRoot(selectedPath);
} catch (_error) {
return {
success: false,
error: "Selected folder is not in a git repository",
};
}

return {
success: true as const,
path,
name,
};
}),
openRecent: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {
const { path } = input;
const name = basename(path);
const name = basename(mainRepoPath);

let project = db.data.projects.find(
(p) => p.mainRepoPath === mainRepoPath,
);

if (project) {
await db.update((data) => {
const recent = data.recentProjects.find((p) => p.path === path);
if (recent) {
recent.lastOpenedAt = Date.now();
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
}
});

return {
success: true as const,
path,
} else {
project = {
id: nanoid(),
mainRepoPath,
name,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};
}),
getRecents: publicProcedure.query((): RecentProject[] => {
return db.data.recentProjects
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),
removeRecent: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {

await db.update((data) => {
data.recentProjects = data.recentProjects.filter(
(p) => p.path !== input.path,
);
data.projects.push(project!);
});
}
Comment on lines +42 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential race condition between project lookup and database update.

The code finds a project at lines 42-44, then later updates it inside db.update() at lines 47-52 by searching again by project?.id. Between the initial lookup and the database update, the project could theoretically be deleted by another operation, making the second lookup fail silently.

Additionally, at line 63, the non-null assertion project! assumes the if/else logic is exhaustive, but if there's a logic error, this could cause a runtime error.

Apply this diff to make the code more defensive:

 		if (project) {
 			await db.update((data) => {
 				const p = data.projects.find((p) => p.id === project?.id);
 				if (p) {
 					p.lastOpenedAt = Date.now();
 				}
 			});
+			// Refresh project reference after update
+			project = db.data.projects.find((p) => p.id === project?.id) ?? project;
 		} else {
 			project = {
 				id: nanoid(),
 				mainRepoPath,
 				name,
 				lastOpenedAt: Date.now(),
 				createdAt: Date.now(),
 			};
 
 			await db.update((data) => {
 				data.projects.push(project!);
 			});
 		}
 
 		return {
 			success: true as const,
-			project,
+			project: project!,
 		};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let project = db.data.projects.find(
(p) => p.mainRepoPath === mainRepoPath,
);
if (project) {
await db.update((data) => {
const recent = data.recentProjects.find((p) => p.path === path);
if (recent) {
recent.lastOpenedAt = Date.now();
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
}
});
return {
success: true as const,
path,
} else {
project = {
id: nanoid(),
mainRepoPath,
name,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};
}),
getRecents: publicProcedure.query((): RecentProject[] => {
return db.data.recentProjects
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),
removeRecent: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {
await db.update((data) => {
data.recentProjects = data.recentProjects.filter(
(p) => p.path !== input.path,
);
data.projects.push(project!);
});
}
let project = db.data.projects.find(
(p) => p.mainRepoPath === mainRepoPath,
);
if (project) {
await db.update((data) => {
const p = data.projects.find((p) => p.id === project?.id);
if (p) {
p.lastOpenedAt = Date.now();
}
});
// Refresh project reference after update
project = db.data.projects.find((p) => p.id === project?.id) ?? project;
} else {
project = {
id: nanoid(),
mainRepoPath,
name,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};
await db.update((data) => {
data.projects.push(project!);
});
}
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/projects/projects.ts around lines 42 to 65,
avoid the race between the initial find and the later db.update and remove the
unsafe non-null assertion by making the create-or-update atomic inside
db.update: call db.update once and inside its callback search for the project by
mainRepoPath; if found set its lastOpenedAt = Date.now(); if not, construct a
new project object (with id = nanoid(), mainRepoPath, name, lastOpenedAt and
createdAt timestamps) and push it into data.projects; this ensures the lookup
and mutation happen under the same lock and eliminates the project! non-null
assertion.


return { success: true };
}),
return {
success: true as const,
project,
};
}),
});
};

Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import simpleGit from "simple-git";
import { join } from "node:path";
import { mkdir } from "node:fs/promises";

export function generateBranchName(): string {
const adjectives = [
"azure",
"crimson",
"emerald",
"golden",
"indigo",
"jade",
"lavender",
"magenta",
"navy",
"olive",
"pearl",
"rose",
"silver",
"teal",
"violet",
];

const nouns = [
"cloud",
"forest",
"mountain",
"ocean",
"river",
"storm",
"sunset",
"thunder",
"wave",
"wind",
"meadow",
"canyon",
"glacier",
"valley",
"peak",
];

const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 100);

return `${adjective}-${noun}-${number}`;
}

export async function createWorktree(
mainRepoPath: string,
branch: string,
worktreePath: string,
): Promise<void> {
try {
const parentDir = join(worktreePath, "..");
await mkdir(parentDir, { recursive: true });

const git = simpleGit(mainRepoPath);
await git.raw(["worktree", "add", worktreePath, "-b", branch]);

console.log(`Created worktree at ${worktreePath} with branch ${branch}`);
} catch (error) {
console.error(`Failed to create worktree: ${error}`);
throw new Error(`Failed to create worktree: ${error}`);
}
}

export async function removeWorktree(
mainRepoPath: string,
worktreePath: string,
): Promise<void> {
try {
const git = simpleGit(mainRepoPath);
await git.raw(["worktree", "remove", worktreePath, "--force"]);

console.log(`Removed worktree at ${worktreePath}`);
} catch (error) {
console.error(`Failed to remove worktree: ${error}`);
throw new Error(`Failed to remove worktree: ${error}`);
}
}

export async function getGitRoot(path: string): Promise<string> {
try {
const git = simpleGit(path);
const root = await git.revparse(["--show-toplevel"]);
return root.trim();
} catch (error) {
throw new Error(`Not a git repository: ${path}`);
}
}
Loading
Loading