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
42 changes: 42 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { dialog } from "electron";
import type { BrowserWindow } from "electron";
import { basename } from "node:path";
import { nanoid } from "nanoid";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { db } from "../../../../main/lib/db";
import type { Project } from "../../../../main/lib/db/schemas";
import { getGitRoot } from "../workspaces/utils/git";
import { assignRandomColor } from "./utils/colors";

export const createProjectsRouter = (window: BrowserWindow) => {
return router({
Expand Down Expand Up @@ -55,6 +57,8 @@ export const createProjectsRouter = (window: BrowserWindow) => {
id: nanoid(),
mainRepoPath,
name,
color: assignRandomColor(),
tabOrder: null,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
};
Expand All @@ -69,6 +73,44 @@ export const createProjectsRouter = (window: BrowserWindow) => {
project,
};
}),

reorder: publicProcedure
.input(
z.object({
fromIndex: z.number(),
toIndex: z.number(),
}),
)
.mutation(async ({ input }) => {
await db.update((data) => {
const { fromIndex, toIndex } = input;

const activeProjects = data.projects
.filter((p) => p.tabOrder !== null)
.sort((a, b) => a.tabOrder! - b.tabOrder!);

if (
fromIndex < 0 ||
fromIndex >= activeProjects.length ||
toIndex < 0 ||
toIndex >= activeProjects.length
) {
throw new Error("Invalid fromIndex or toIndex");
}

const [removed] = activeProjects.splice(fromIndex, 1);
activeProjects.splice(toIndex, 0, removed);

activeProjects.forEach((project, index) => {
const p = data.projects.find((p) => p.id === project.id);
if (p) {
p.tabOrder = index;
}
});
});

return { success: true };
}),
});
};

Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/utils/colors/colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import colors from "tailwindcss/colors";

const PROJECT_COLORS = [
colors.blue[500],
colors.green[500],
colors.yellow[500],
colors.red[500],
colors.purple[500],
colors.cyan[500],
colors.orange[500],
colors.slate[500],
] as const;

export function assignRandomColor(): string {
return PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { assignRandomColor } from "./colors";
157 changes: 99 additions & 58 deletions apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export const createWorkspacesRouter = () => {
}),
)
.mutation(async ({ input }) => {
// Find the project
const project = db.data.projects.find((p) => p.id === input.projectId);
if (!project) {
throw new Error(`Project ${input.projectId} not found`);
Expand All @@ -33,10 +32,8 @@ export const createWorkspacesRouter = () => {
branch,
);

// Create git worktree
await createWorktree(project.mainRepoPath, branch, worktreePath);

// Create worktree record
const worktree = {
id: nanoid(),
projectId: input.projectId,
Expand All @@ -45,43 +42,50 @@ export const createWorkspacesRouter = () => {
createdAt: Date.now(),
};

// Set order to be at the end of the list
const maxOrder =
db.data.workspaces.length > 0
? Math.max(...db.data.workspaces.map((w) => w.order))
const projectWorkspaces = db.data.workspaces.filter(
(w) => w.projectId === input.projectId,
);
const maxTabOrder =
projectWorkspaces.length > 0
? Math.max(...projectWorkspaces.map((w) => w.tabOrder))
: -1;

const workspace = {
id: nanoid(),
projectId: input.projectId,
worktreeId: worktree.id,
name: input.name ?? branch,
order: maxOrder + 1,
tabOrder: maxTabOrder + 1,
createdAt: Date.now(),
updatedAt: Date.now(),
lastOpenedAt: Date.now(),
};

// Save to database
await db.update((data) => {
data.worktrees.push(worktree);
data.workspaces.push(workspace);
data.settings.lastActiveWorkspaceId = workspace.id;

// Update project lastOpenedAt
const p = data.projects.find((p) => p.id === input.projectId);
if (p) {
p.lastOpenedAt = Date.now();

if (p.tabOrder === null) {
const activeProjects = data.projects.filter(
(proj) => proj.tabOrder !== null,
);
const maxProjectTabOrder =
activeProjects.length > 0
? Math.max(...activeProjects.map((proj) => proj.tabOrder!))
: -1;
p.tabOrder = maxProjectTabOrder + 1;
}
}
});

return workspace;
}),

/**
* Get a workspace by ID
* Throws if workspace not found
*/
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
Expand All @@ -92,20 +96,61 @@ export const createWorkspacesRouter = () => {
return workspace;
}),

/**
* Get all workspaces sorted by order
*/
getAll: publicProcedure.query(() => {
return db.data.workspaces
.slice()
.sort((a, b) => a.order - b.order);
.sort((a, b) => a.tabOrder - b.tabOrder);
}),

getAllGrouped: publicProcedure.query(() => {
const activeProjects = db.data.projects.filter(
(p) => p.tabOrder !== null,
);

const groupsMap = new Map<
string,
{
project: { id: string; name: string; color: string; tabOrder: number };
workspaces: Array<{
id: string;
projectId: string;
worktreeId: string;
name: string;
tabOrder: number;
createdAt: number;
updatedAt: number;
lastOpenedAt: number;
}>;
}
>();

for (const project of activeProjects) {
groupsMap.set(project.id, {
project: {
id: project.id,
name: project.name,
color: project.color,
tabOrder: project.tabOrder!,
},
workspaces: [],
});
}

const workspaces = db.data.workspaces
.slice()
.sort((a, b) => a.tabOrder - b.tabOrder);

for (const workspace of workspaces) {
if (groupsMap.has(workspace.projectId)) {
groupsMap.get(workspace.projectId)!.workspaces.push(workspace);
}
}

return Array.from(groupsMap.values()).sort(
(a, b) => a.project.tabOrder - b.project.tabOrder,
);
}),

/**
* Get the last active workspace
* Returns null if no active workspace set (valid state)
* Throws if active workspace ID exists but workspace not found (data inconsistency)
*/
getActive: publicProcedure.query(() => {
const { lastActiveWorkspaceId } = db.data.settings;

Expand All @@ -125,10 +170,6 @@ export const createWorkspacesRouter = () => {
return workspace;
}),

/**
* Update a workspace
* Supports partial updates to workspace properties
*/
update: publicProcedure
.input(
z.object({
Expand All @@ -145,22 +186,17 @@ export const createWorkspacesRouter = () => {
throw new Error(`Workspace ${input.id} not found`);
}

// Apply patches
if (input.patch.name !== undefined) {
workspace.name = input.patch.name;
}

// Update timestamps
workspace.updatedAt = Date.now();
workspace.lastOpenedAt = Date.now();
});

return { success: true };
}),

/**
* Delete a workspace and its associated worktree
*/
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
Expand All @@ -170,39 +206,43 @@ export const createWorkspacesRouter = () => {
return { success: false, error: "Workspace not found" };
}

// Find associated worktree and project
const worktree = db.data.worktrees.find(
(wt) => wt.id === workspace.worktreeId,
);
const project = db.data.projects.find(
(p) => p.id === workspace.projectId,
);

// Remove git worktree if it exists
if (worktree && project) {
try {
await removeWorktree(project.mainRepoPath, worktree.path);
} catch (error) {
console.error("Failed to remove worktree:", error);
// Continue with database cleanup even if git operation fails
}
}

// Remove from database
await db.update((data) => {
// Remove workspace
data.workspaces = data.workspaces.filter((w) => w.id !== input.id);

// Remove worktree
if (worktree) {
data.worktrees = data.worktrees.filter(
(wt) => wt.id !== worktree.id,
);
}

// Update last active workspace if needed
if (project) {
const remainingWorkspaces = data.workspaces.filter(
(w) => w.projectId === workspace.projectId,
);
if (remainingWorkspaces.length === 0) {
const p = data.projects.find((p) => p.id === workspace.projectId);
if (p) {
p.tabOrder = null;
}
}
}

if (data.settings.lastActiveWorkspaceId === input.id) {
// Set to the most recently opened workspace, if any
const sorted = data.workspaces
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
Expand All @@ -213,9 +253,6 @@ export const createWorkspacesRouter = () => {
return { success: true };
}),

/**
* Set active workspace
*/
setActive: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
Expand All @@ -233,34 +270,38 @@ export const createWorkspacesRouter = () => {
return { success: true };
}),

/**
* Reorder workspaces
*/
reorder: publicProcedure
.input(
z.object({
projectId: z.string(),
fromIndex: z.number(),
toIndex: z.number(),
}),
)
.mutation(async ({ input }) => {
await db.update((data) => {
const { fromIndex, toIndex } = input;

// Get all workspaces sorted by order
const workspaces = data.workspaces
.slice()
.sort((a, b) => a.order - b.order);
const { projectId, fromIndex, toIndex } = input;

const projectWorkspaces = data.workspaces
.filter((w) => w.projectId === projectId)
.sort((a, b) => a.tabOrder - b.tabOrder);

if (
fromIndex < 0 ||
fromIndex >= projectWorkspaces.length ||
toIndex < 0 ||
toIndex >= projectWorkspaces.length
) {
throw new Error("Invalid fromIndex or toIndex");
}

// Move workspace from fromIndex to toIndex
const [removed] = workspaces.splice(fromIndex, 1);
workspaces.splice(toIndex, 0, removed);
const [removed] = projectWorkspaces.splice(fromIndex, 1);
projectWorkspaces.splice(toIndex, 0, removed);

// Update order fields to reflect new positions
workspaces.forEach((workspace, index) => {
projectWorkspaces.forEach((workspace, index) => {
const ws = data.workspaces.find((w) => w.id === workspace.id);
if (ws) {
ws.order = index;
ws.tabOrder = index;
}
});
});
Expand Down
Loading
Loading