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
138 changes: 129 additions & 9 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
settings,
workspaces,
} from "@superset/local-db";
import { desc, eq, inArray } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, inArray, isNull, not } from "drizzle-orm";
import type { BrowserWindow } from "electron";
import { dialog } from "electron";
import { track } from "main/lib/analytics";
Expand All @@ -18,6 +19,13 @@ import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
activateProject,
getBranchWorkspace,
setLastActiveWorkspace,
touchWorkspace,
} from "../workspaces/utils/db-helpers";
import {
getCurrentBranch,
getDefaultBranch,
getGitRoot,
refreshDefaultBranch,
Expand Down Expand Up @@ -79,6 +87,96 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
return project;
}

/**
* Ensures a project has a main (branch) workspace.
* If one doesn't exist, creates it automatically.
* This is called after opening/creating a project to provide a default workspace.
*/
async function ensureMainWorkspace(project: Project): Promise<void> {
const existingBranchWorkspace = getBranchWorkspace(project.id);

// If branch workspace already exists, just touch it and return
if (existingBranchWorkspace) {
touchWorkspace(existingBranchWorkspace.id);
setLastActiveWorkspace(existingBranchWorkspace.id);
return;
}

// Get current branch from main repo
const branch = await getCurrentBranch(project.mainRepoPath);
if (!branch) {
console.warn(
`[ensureMainWorkspace] Could not determine current branch for project ${project.id}`,
);
return;
}

// Insert new branch workspace with conflict handling for race conditions
// The unique partial index (projectId WHERE type='branch') prevents duplicates
const insertResult = localDb
.insert(workspaces)
.values({
projectId: project.id,
type: "branch",
branch,
name: branch,
tabOrder: 0,
})
.onConflictDoNothing()
.returning()
.all();

const wasExisting = insertResult.length === 0;

// Only shift existing workspaces if we successfully inserted
if (!wasExisting) {
const newWorkspaceId = insertResult[0].id;
const projectWorkspaces = localDb
.select()
.from(workspaces)
.where(
and(
eq(workspaces.projectId, project.id),
not(eq(workspaces.id, newWorkspaceId)),
isNull(workspaces.deletingAt),
),
)
.all();

for (const ws of projectWorkspaces) {
localDb
.update(workspaces)
.set({ tabOrder: ws.tabOrder + 1 })
.where(eq(workspaces.id, ws.id))
.run();
}
}

// Get the workspace (either newly created or existing from race condition)
const workspace = insertResult[0] ?? getBranchWorkspace(project.id);

if (!workspace) {
console.warn(
`[ensureMainWorkspace] Failed to create or find branch workspace for project ${project.id}`,
);
return;
}

setLastActiveWorkspace(workspace.id);

if (!wasExisting) {
activateProject(project);

track("workspace_opened", {
workspace_id: workspace.id,
project_id: project.id,
type: "branch",
was_existing: false,
auto_created: true,
});
}
}

// Safe filename regex: letters, numbers, dots, underscores, hyphens, spaces, and common unicode
// Allows most valid Git repo names while avoiding path traversal characters
const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/;
Expand Down Expand Up @@ -144,14 +242,21 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
return router({
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }): Project | null => {
return (
localDb
.select()
.from(projects)
.where(eq(projects.id, input.id))
.get() ?? null
);
.query(({ input }): Project => {
const project = localDb
.select()
.from(projects)
.where(eq(projects.id, input.id))
.get();

if (!project) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Project ${input.id} not found`,
});
}

return project;
}),

getRecents: publicProcedure.query((): Project[] => {
Expand Down Expand Up @@ -311,6 +416,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
const defaultBranch = await getDefaultBranch(mainRepoPath);
const project = upsertProject(mainRepoPath, defaultBranch);

// Auto-create main workspace if it doesn't exist
await ensureMainWorkspace(project);

track("project_opened", {
project_id: project.id,
method: "open",
Expand Down Expand Up @@ -366,6 +474,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {

const project = upsertProject(input.path, defaultBranch);

// Auto-create main workspace if it doesn't exist
await ensureMainWorkspace(project);

track("project_opened", {
project_id: project.id,
method: "init",
Expand Down Expand Up @@ -441,6 +552,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.where(eq(projects.id, existingProject.id))
.run();

// Auto-create main workspace if it doesn't exist
await ensureMainWorkspace({
...existingProject,
lastOpenedAt: Date.now(),
});

track("project_opened", {
project_id: existingProject.id,
method: "clone",
Expand Down Expand Up @@ -487,6 +604,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.returning()
.get();

// Auto-create main workspace if it doesn't exist
await ensureMainWorkspace(project);

track("project_opened", {
project_id: project.id,
method: "clone",
Expand Down
132 changes: 37 additions & 95 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { projects, settings, workspaces, worktrees } from "@superset/local-db";
import { and, eq, isNotNull, isNull } from "drizzle-orm";
import { projects, workspaces, worktrees } from "@superset/local-db";
import { TRPCError } from "@trpc/server";
import { eq, isNotNull, isNull } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { z } from "zod";
import { publicProcedure, router } from "../../..";
Expand All @@ -16,7 +17,10 @@ export const createQueryProcedures = () => {
.query(async ({ input }) => {
const workspace = getWorkspace(input.id);
if (!workspace) {
throw new Error(`Workspace ${input.id} not found`);
throw new TRPCError({
code: "NOT_FOUND",
message: `Workspace ${input.id} not found`,
});
}

const project = localDb
Expand Down Expand Up @@ -192,104 +196,42 @@ export const createQueryProcedures = () => {
);
}),

getActive: publicProcedure.query(async () => {
const settingsRow = localDb.select().from(settings).get();
const lastActiveWorkspaceId = settingsRow?.lastActiveWorkspaceId;
getPreviousWorkspace: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const allWorkspaces = localDb
.select()
.from(workspaces)
.where(isNull(workspaces.deletingAt))
.all()
.sort((a, b) => a.tabOrder - b.tabOrder);

if (!lastActiveWorkspaceId) {
return null;
}
const currentIndex = allWorkspaces.findIndex((w) => w.id === input.id);

if (currentIndex > 0) {
return allWorkspaces[currentIndex - 1].id;
}

const workspace = localDb
.select()
.from(workspaces)
.where(
and(
eq(workspaces.id, lastActiveWorkspaceId),
isNull(workspaces.deletingAt),
),
)
.get();
if (!workspace) {
// Active workspace not found or is being deleted - return null
// The UI will handle showing another workspace or empty state
return null;
}
}),

const project = localDb
.select()
.from(projects)
.where(eq(projects.id, workspace.projectId))
.get();
const worktree = workspace.worktreeId
? localDb
.select()
.from(worktrees)
.where(eq(worktrees.id, workspace.worktreeId))
.get()
: null;
getNextWorkspace: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const allWorkspaces = localDb
.select()
.from(workspaces)
.where(isNull(workspaces.deletingAt))
.all()
.sort((a, b) => a.tabOrder - b.tabOrder);

// Detect and persist base branch for existing worktrees that don't have it
// We use undefined to mean "not yet attempted" and null to mean "attempted but not found"
let baseBranch = worktree?.baseBranch;
if (worktree && baseBranch === undefined && project) {
// Only attempt detection if there's a remote origin
const hasRemote = await hasOriginRemote(project.mainRepoPath);
if (hasRemote) {
try {
const defaultBranch = project.defaultBranch || "main";
const detected = await detectBaseBranch(
worktree.path,
worktree.branch,
defaultBranch,
);
if (detected) {
baseBranch = detected;
}
// Persist the result (detected branch or null sentinel)
localDb
.update(worktrees)
.set({ baseBranch: detected ?? null })
.where(eq(worktrees.id, worktree.id))
.run();
} catch {
// Detection failed, persist null to avoid retrying
localDb
.update(worktrees)
.set({ baseBranch: null })
.where(eq(worktrees.id, worktree.id))
.run();
}
} else {
// No remote - persist null to avoid retrying
localDb
.update(worktrees)
.set({ baseBranch: null })
.where(eq(worktrees.id, worktree.id))
.run();
const currentIndex = allWorkspaces.findIndex((w) => w.id === input.id);

if (currentIndex !== -1 && currentIndex < allWorkspaces.length - 1) {
return allWorkspaces[currentIndex + 1].id;
}
}

return {
...workspace,
type: workspace.type as "worktree" | "branch",
worktreePath: getWorkspacePath(workspace) ?? "",
project: project
? {
id: project.id,
name: project.name,
mainRepoPath: project.mainRepoPath,
}
: null,
worktree: worktree
? {
branch: worktree.branch,
baseBranch,
// Normalize to null to ensure consistent "incomplete init" detection in UI
gitStatus: worktree.gitStatus ?? null,
}
: null,
};
}),
return null;
}),
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,10 @@ import { and, eq, isNull } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { z } from "zod";
import { publicProcedure, router } from "../../..";
import {
getWorkspaceNotDeleting,
setLastActiveWorkspace,
touchWorkspace,
} from "../utils/db-helpers";
import { getWorkspaceNotDeleting, touchWorkspace } from "../utils/db-helpers";

export const createStatusProcedures = () => {
return router({
setActive: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const workspace = getWorkspaceNotDeleting(input.id);
if (!workspace) {
throw new Error(
`Workspace ${input.id} not found or is being deleted`,
);
}

// Track if workspace was unread before clearing
const wasUnread = workspace.isUnread ?? false;

// Auto-clear unread state when switching to workspace
touchWorkspace(input.id, { isUnread: false });
setLastActiveWorkspace(input.id);

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

reorder: publicProcedure
.input(
z.object({
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ import { createStatusProcedures } from "./procedures/status";
* Procedures are organized into logical groups:
* - create: create, createBranchWorkspace, openWorktree
* - delete: delete, close, canDelete
* - query: get, getAll, getAllGrouped, getActive
* - query: get, getAll, getAllGrouped
* - branch: getBranches, switchBranchWorkspace
* - git-status: refreshGitStatus, getGitHubStatus, getWorktreeInfo, getWorktreesByProject
* - status: setActive, reorder, update, setUnread
* - status: reorder, update, setUnread
* - init: onInitProgress, retryInit, getInitProgress, getSetupCommands
*/
export const createWorkspacesRouter = () => {
Expand Down
Loading
Loading