diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index ed866c87aca..5574a701ab9 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -4,7 +4,7 @@ import { basename, join } from "node:path"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { db } from "main/lib/db"; -import type { Project } from "main/lib/db/schemas"; +import type { Project, Workspace } from "main/lib/db/schemas"; import { nanoid } from "nanoid"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import simpleGit from "simple-git"; @@ -28,18 +28,40 @@ export type OpenNewResult = | OpenNewNeedsGitInit | OpenNewError; +/** + * Creates a main branch workspace for a project + */ +function createMainWorkspace( + projectId: string, + defaultBranch: string, +): Workspace { + return { + id: nanoid(), + projectId, + worktreeId: undefined, + type: "branch", + branch: defaultBranch, + name: defaultBranch, + tabOrder: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + }; +} + /** * Creates or updates a project record in the database. * If a project with the same mainRepoPath exists, updates lastOpenedAt. - * Otherwise, creates a new project. + * Otherwise, creates a new project with a main branch workspace. */ async function upsertProject( mainRepoPath: string, defaultBranch: string, -): Promise { +): Promise<{ project: Project; isNew: boolean }> { const name = basename(mainRepoPath); let project = db.data.projects.find((p) => p.mainRepoPath === mainRepoPath); + const isNew = !project; if (project) { await db.update((data) => { @@ -61,13 +83,32 @@ async function upsertProject( defaultBranch, }; + const mainWorkspace = createMainWorkspace(project.id, defaultBranch); + await db.update((data) => { // biome-ignore lint/style/noNonNullAssertion: project is assigned above, TypeScript can't see it inside callback data.projects.push(project!); + data.workspaces.push(mainWorkspace); + data.settings.lastActiveWorkspaceId = mainWorkspace.id; + + // Activate the project + const activeProjects = data.projects.filter( + (proj) => proj.tabOrder !== null, + ); + const maxProjectTabOrder = + activeProjects.length > 0 + ? // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null + Math.max(...activeProjects.map((proj) => proj.tabOrder!)) + : -1; + // biome-ignore lint/style/noNonNullAssertion: project is assigned above + const p = data.projects.find((p) => p.id === project!.id); + if (p) { + p.tabOrder = maxProjectTabOrder + 1; + } }); } - return project; + return { project, isNew }; } // Safe filename regex: letters, numbers, dots, underscores, hyphens, spaces, and common unicode @@ -180,7 +221,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } const defaultBranch = await getDefaultBranch(mainRepoPath); - const project = await upsertProject(mainRepoPath, defaultBranch); + const { project } = await upsertProject(mainRepoPath, defaultBranch); return { canceled: false, @@ -230,7 +271,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const branchSummary = await git.branch(); const defaultBranch = branchSummary.current || "main"; - const project = await upsertProject(input.path, defaultBranch); + const { project } = await upsertProject(input.path, defaultBranch); return { project }; }), @@ -334,23 +375,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const git = simpleGit(); await git.clone(input.url, clonePath); - // Create new project - const name = basename(clonePath); + // Create new project with main workspace const defaultBranch = await getDefaultBranch(clonePath); - const project: Project = { - id: nanoid(), - mainRepoPath: clonePath, - name, - color: assignRandomColor(), - tabOrder: null, - lastOpenedAt: Date.now(), - createdAt: Date.now(), - defaultBranch, - }; - - await db.update((data) => { - data.projects.push(project); - }); + const { project } = await upsertProject(clonePath, defaultBranch); return { canceled: false as const, diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 85562a274bc..29c4814635b 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -5,7 +5,7 @@ import { db } from "main/lib/db"; import { terminalManager } from "main/lib/terminal"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { getWorktreePath } from "../workspaces/utils/worktree"; +import { getWorkspacePath } from "../workspaces/utils/worktree"; import { resolveCwd } from "./utils"; /** @@ -47,12 +47,12 @@ export const createTerminalRouter = () => { initialCommands, } = input; - // Resolve cwd: absolute paths stay as-is, relative paths resolve against worktree + // Resolve cwd: absolute paths stay as-is, relative paths resolve against workspace path const workspace = db.data.workspaces.find((w) => w.id === workspaceId); - const worktreePath = workspace - ? getWorktreePath(workspace.worktreeId) + const workspacePath = workspace + ? getWorkspacePath(workspace) : undefined; - const cwd = resolveCwd(cwdOverride, worktreePath); + const cwd = resolveCwd(cwdOverride, workspacePath); // Get project info for environment variables const project = workspace @@ -64,7 +64,7 @@ export const createTerminalRouter = () => { tabId, workspaceId, workspaceName: workspace?.name, - workspacePath: worktreePath, + workspacePath, rootPath: project?.mainRepoPath, cwd, cols, @@ -126,6 +126,15 @@ export const createTerminalRouter = () => { await terminalManager.kill(input); }), + /** + * Kill all terminals for a workspace + */ + killByWorkspaceId: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .mutation(async ({ input }) => { + return terminalManager.killByWorkspaceId(input.workspaceId); + }), + /** * Detach from terminal (keep session alive) */ @@ -171,10 +180,7 @@ export const createTerminalRouter = () => { return undefined; } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); - return worktree?.path; + return getWorkspacePath(workspace); }), /** diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 6c748dff06f..2aa63fe2490 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -444,3 +444,187 @@ export async function branchExistsOnRemote( return false; } } + +/** + * Lists all local and remote branches in a repository + * @param repoPath - Path to the repository + * @param options.fetch - Whether to fetch and prune remote refs first (default: false) + * @returns Object with local and remote branch arrays + */ +export async function listBranches( + repoPath: string, + options?: { fetch?: boolean }, +): Promise<{ local: string[]; remote: string[] }> { + const git = simpleGit(repoPath); + + // Optionally fetch and prune to get up-to-date remote refs + if (options?.fetch) { + try { + await git.fetch(["--prune"]); + } catch { + // Ignore fetch errors (e.g., offline) + } + } + + // Get local branches + const localResult = await git.branchLocal(); + const local = localResult.all; + + // Get remote branches (strip "origin/" prefix) + const remoteResult = await git.branch(["-r"]); + const remote = remoteResult.all + .filter((b) => b.startsWith("origin/") && !b.includes("->")) + .map((b) => b.replace("origin/", "")); + + return { local, remote }; +} + +/** + * Checks out a branch in a repository. + * If the branch only exists on remote, creates a local tracking branch. + * @param repoPath - Path to the repository + * @param branch - The branch name to checkout + */ +/** + * Result of pre-checkout safety checks + */ +export interface CheckoutSafetyResult { + safe: boolean; + error?: string; + hasUncommittedChanges?: boolean; + hasUntrackedFiles?: boolean; +} + +/** + * Performs safety checks before a branch checkout: + * 1. Checks for uncommitted changes (staged/unstaged) + * 2. Checks for untracked files that might be overwritten + * 3. Runs git fetch --prune to clean up stale remote refs + * @param repoPath - Path to the repository + * @returns Safety check result indicating if checkout is safe + */ +export async function checkBranchCheckoutSafety( + repoPath: string, +): Promise { + const git = simpleGit(repoPath); + + try { + // Check for uncommitted changes + const status = await git.status(); + + const hasUncommittedChanges = + status.staged.length > 0 || + status.modified.length > 0 || + status.deleted.length > 0; + + const hasUntrackedFiles = status.not_added.length > 0; + + if (hasUncommittedChanges) { + return { + safe: false, + error: + "Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first.", + hasUncommittedChanges: true, + hasUntrackedFiles, + }; + } + + // Fetch and prune stale remote refs (best-effort) + try { + await git.fetch(["--prune"]); + } catch { + // Ignore fetch errors (e.g., offline) - not critical for safety + } + + return { + safe: true, + hasUncommittedChanges: false, + hasUntrackedFiles, + }; + } catch (error) { + return { + safe: false, + error: `Failed to check repository status: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Gets the current branch name (HEAD) + * @param repoPath - Path to the repository + * @returns The current branch name, or null if in detached HEAD state + */ +export async function getCurrentBranch( + repoPath: string, +): Promise { + const git = simpleGit(repoPath); + try { + const branch = await git.revparse(["--abbrev-ref", "HEAD"]); + const trimmed = branch.trim(); + // "HEAD" means detached HEAD state + return trimmed === "HEAD" ? null : trimmed; + } catch { + return null; + } +} + +export async function checkoutBranch( + repoPath: string, + branch: string, +): Promise { + const git = simpleGit(repoPath); + + // Check if branch exists locally + const localBranches = await git.branchLocal(); + if (localBranches.all.includes(branch)) { + await git.checkout(branch); + return; + } + + // Branch doesn't exist locally - check if it exists on remote and create tracking branch + const remoteBranches = await git.branch(["-r"]); + const remoteBranchName = `origin/${branch}`; + if (remoteBranches.all.includes(remoteBranchName)) { + // Create local branch tracking the remote + await git.checkout(["-b", branch, "--track", remoteBranchName]); + return; + } + + // Branch doesn't exist anywhere - let git checkout fail with its normal error + await git.checkout(branch); +} + +/** + * Safe branch checkout that performs safety checks first. + * This is the preferred method for branch workspaces. + * @param repoPath - Path to the repository + * @param branch - Branch to checkout + * @throws Error if safety checks fail or checkout fails + */ +export async function safeCheckoutBranch( + repoPath: string, + branch: string, +): Promise { + // Check if we're already on the target branch - no checkout needed + const currentBranch = await getCurrentBranch(repoPath); + if (currentBranch === branch) { + return; + } + + // Run safety checks before switching branches + const safety = await checkBranchCheckoutSafety(repoPath); + if (!safety.safe) { + throw new Error(safety.error); + } + + // Proceed with checkout + await checkoutBranch(repoPath, branch); + + // Verify we landed on the correct branch + const verifyBranch = await getCurrentBranch(repoPath); + if (verifyBranch !== branch) { + throw new Error( + `Branch checkout verification failed: expected "${branch}" but HEAD is on "${verifyBranch ?? "detached HEAD"}"`, + ); + } +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts index 1300840572e..1514e691c2d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts @@ -1,9 +1,37 @@ import { db } from "main/lib/db"; +import type { Workspace } from "main/lib/db/schemas"; /** - * Gets the worktree path for a workspace + * Gets the worktree path for a workspace by worktree ID + * @deprecated Use getWorkspacePath() instead for type-aware path resolution */ export function getWorktreePath(worktreeId: string): string | undefined { const worktree = db.data.worktrees.find((w) => w.id === worktreeId); return worktree?.path; } + +/** + * Gets the file path for a workspace, handling both worktree and branch types. + * - For worktree type: returns the worktree path + * - For branch type: returns the main repo path + */ +export function getWorkspacePath(workspace: Workspace): string | undefined { + if (workspace.type === "branch") { + const project = db.data.projects.find((p) => p.id === workspace.projectId); + return project?.mainRepoPath; + } + // Worktree type - use worktree path + if (workspace.worktreeId) { + return getWorktreePath(workspace.worktreeId); + } + return undefined; +} + +/** + * Gets the file path for a workspace by ID + */ +export function getWorkspacePathById(workspaceId: string): string | undefined { + const workspace = db.data.workspaces.find((w) => w.id === workspaceId); + if (!workspace) return undefined; + return getWorkspacePath(workspace); +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 2e3e69257d6..fea27430fcb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -11,17 +11,20 @@ import { createWorktree, fetchDefaultBranch, generateBranchName, + getCurrentBranch, getDefaultBranch, hasOriginRemote, hasUncommittedChanges, hasUnpushedCommits, + listBranches, removeWorktree, + safeCheckoutBranch, worktreeExists, } from "./utils/git"; import { fetchGitHubPRStatus } from "./utils/github"; import { loadSetupConfig } from "./utils/setup"; import { runTeardown } from "./utils/teardown"; -import { getWorktreePath } from "./utils/worktree"; +import { getWorkspacePath } from "./utils/worktree"; export const createWorkspacesRouter = () => { return router({ @@ -109,6 +112,8 @@ export const createWorkspacesRouter = () => { id: nanoid(), projectId: input.projectId, worktreeId: worktree.id, + type: "worktree" as const, + branch, name: input.name ?? branch, tabOrder: maxTabOrder + 1, createdAt: Date.now(), @@ -150,6 +155,159 @@ export const createWorkspacesRouter = () => { }; }), + createBranchWorkspace: publicProcedure + .input( + z.object({ + projectId: z.string(), + branch: z.string(), + name: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + // Only allow one branch workspace per project at a time. + // Multiple branch workspaces would share the same repo path, + // causing branch switches in one to affect all others. + const existingBranchWorkspace = db.data.workspaces.find( + (w) => w.projectId === input.projectId && w.type === "branch", + ); + if (existingBranchWorkspace) { + if (existingBranchWorkspace.branch === input.branch) { + throw new Error( + `A workspace for branch "${input.branch}" already exists`, + ); + } + throw new Error( + `Close the "${existingBranchWorkspace.branch}" branch workspace before opening "${input.branch}". ` + + `Only one branch workspace per project is allowed (they share the same directory).`, + ); + } + + // Checkout the branch in the main repo (with safety checks) + await safeCheckoutBranch(project.mainRepoPath, input.branch); + + 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: undefined, + type: "branch" as const, + branch: input.branch, + name: input.name ?? input.branch, + tabOrder: maxTabOrder + 1, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + }; + + await db.update((data) => { + data.workspaces.push(workspace); + data.settings.lastActiveWorkspaceId = workspace.id; + + 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 + ? // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null + Math.max(...activeProjects.map((proj) => proj.tabOrder!)) + : -1; + p.tabOrder = maxProjectTabOrder + 1; + } + } + }); + + return { + workspace, + worktreePath: project.mainRepoPath, + projectId: project.id, + }; + }), + + getBranches: publicProcedure + .input( + z.object({ + projectId: z.string(), + fetch: z.boolean().optional(), // Whether to fetch remote refs (default: false, avoids UI stalls) + }), + ) + .query(async ({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + return listBranches(project.mainRepoPath, { fetch: input.fetch }); + }), + + // Switch an existing branch workspace to a different branch + switchBranchWorkspace: publicProcedure + .input( + z.object({ + projectId: z.string(), + branch: z.string(), + }), + ) + .mutation(async ({ input }) => { + const project = db.data.projects.find((p) => p.id === input.projectId); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + const workspace = db.data.workspaces.find( + (w) => w.projectId === input.projectId && w.type === "branch", + ); + if (!workspace) { + throw new Error("No branch workspace found for this project"); + } + + // Checkout the new branch with safety checks (terminals continue running on the new branch) + await safeCheckoutBranch(project.mainRepoPath, input.branch); + + // Send newline to terminals so their prompts refresh with new branch + terminalManager.refreshPromptsForWorkspace(workspace.id); + + // Update the workspace - preserve custom name (alias), only update branch + await db.update((data) => { + const ws = data.workspaces.find((w) => w.id === workspace.id); + if (ws) { + ws.branch = input.branch; + // Note: intentionally NOT resetting ws.name to preserve user's custom alias + ws.updatedAt = Date.now(); + ws.lastOpenedAt = Date.now(); + } + data.settings.lastActiveWorkspaceId = workspace.id; + }); + + const updatedWorkspace = db.data.workspaces.find( + (w) => w.id === workspace.id, + ); + if (!updatedWorkspace) { + throw new Error(`Workspace ${workspace.id} not found after update`); + } + + return { + workspace: updatedWorkspace, + worktreePath: project.mainRepoPath, + }; + }), + get: publicProcedure .input(z.object({ id: z.string() })) .query(({ input }) => { @@ -181,8 +339,10 @@ export const createWorkspacesRouter = () => { workspaces: Array<{ id: string; projectId: string; - worktreeId: string; + worktreeId?: string; worktreePath: string; + type: "worktree" | "branch"; + branch: string; name: string; tabOrder: number; createdAt: number; @@ -213,7 +373,7 @@ export const createWorkspacesRouter = () => { if (groupsMap.has(workspace.projectId)) { groupsMap.get(workspace.projectId)?.workspaces.push({ ...workspace, - worktreePath: getWorktreePath(workspace.worktreeId) ?? "", + worktreePath: getWorkspacePath(workspace) ?? "", }); } } @@ -242,13 +402,13 @@ export const createWorkspacesRouter = () => { const project = db.data.projects.find( (p) => p.id === workspace.projectId, ); - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + const worktree = workspace.worktreeId + ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) + : null; return { ...workspace, - worktreePath: getWorktreePath(workspace.worktreeId) ?? "", + worktreePath: getWorkspacePath(workspace) ?? "", project: project ? { id: project.id, @@ -328,9 +488,22 @@ export const createWorkspacesRouter = () => { }; } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + // Branch type workspaces can always be deleted (no worktree to clean up) + if (workspace.type === "branch") { + return { + canDelete: true, + reason: null, + workspace, + warning: null, + activeTerminalCount, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + + const worktree = workspace.worktreeId + ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) + : null; const project = db.data.projects.find( (p) => p.id === workspace.projectId, ); @@ -407,53 +580,59 @@ export const createWorkspacesRouter = () => { input.id, ); - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); const project = db.data.projects.find( (p) => p.id === workspace.projectId, ); let teardownError: string | undefined; + let worktree: ReturnType; - if (worktree && project) { - // Run teardown scripts before removing worktree - const exists = await worktreeExists( - project.mainRepoPath, - worktree.path, + // For worktree type workspaces, handle worktree cleanup + if (workspace.type === "worktree" && workspace.worktreeId) { + worktree = db.data.worktrees.find( + (wt) => wt.id === workspace.worktreeId, ); - if (exists) { - const teardownResult = runTeardown( + if (worktree && project) { + // Run teardown scripts before removing worktree + const exists = await worktreeExists( project.mainRepoPath, worktree.path, - workspace.name, ); - if (!teardownResult.success) { - teardownError = teardownResult.error; - } - } - try { if (exists) { - await removeWorktree(project.mainRepoPath, worktree.path); - } else { - console.warn( - `Worktree ${worktree.path} not found in git, skipping removal`, + const teardownResult = runTeardown( + project.mainRepoPath, + worktree.path, + workspace.name, ); + if (!teardownResult.success) { + teardownError = teardownResult.error; + } + } + + try { + if (exists) { + await removeWorktree(project.mainRepoPath, worktree.path); + } else { + console.warn( + `Worktree ${worktree.path} not found in git, skipping removal`, + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error("Failed to remove worktree:", errorMessage); + return { + success: false, + error: `Failed to remove worktree: ${errorMessage}`, + }; } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - console.error("Failed to remove worktree:", errorMessage); - return { - success: false, - error: `Failed to remove worktree: ${errorMessage}`, - }; } } + // Branch type workspaces: just delete DB record, no worktree cleanup needed - // Only proceed with DB cleanup if worktree was successfully removed (or doesn't exist) + // Proceed with DB cleanup await db.update((data) => { data.workspaces = data.workspaces.filter((w) => w.id !== input.id); @@ -494,15 +673,30 @@ export const createWorkspacesRouter = () => { setActive: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { - await db.update((data) => { - const workspace = data.workspaces.find((w) => w.id === input.id); - if (!workspace) { - throw new Error(`Workspace ${input.id} not found`); + const workspace = db.data.workspaces.find((w) => w.id === input.id); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } + + // For branch workspaces, checkout the branch in the main repo with safety checks + if (workspace.type === "branch" && workspace.branch) { + const project = db.data.projects.find( + (p) => p.id === workspace.projectId, + ); + if (project) { + await safeCheckoutBranch(project.mainRepoPath, workspace.branch); + // Refresh terminal prompts to show the new branch + terminalManager.refreshPromptsForWorkspace(workspace.id); } + } - data.settings.lastActiveWorkspaceId = input.id; - workspace.lastOpenedAt = Date.now(); - workspace.updatedAt = Date.now(); + await db.update((data) => { + const ws = data.workspaces.find((w) => w.id === input.id); + if (ws) { + data.settings.lastActiveWorkspaceId = input.id; + ws.lastOpenedAt = Date.now(); + ws.updatedAt = Date.now(); + } }); return { success: true }; @@ -557,15 +751,6 @@ export const createWorkspacesRouter = () => { throw new Error(`Workspace ${input.workspaceId} not found`); } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); - if (!worktree) { - throw new Error( - `Worktree for workspace ${input.workspaceId} not found`, - ); - } - const project = db.data.projects.find( (p) => p.id === workspace.projectId, ); @@ -584,6 +769,42 @@ export const createWorkspacesRouter = () => { }); } + // Branch type workspaces: use mainRepoPath for git status + if (workspace.type === "branch") { + // Fetch default branch to get latest + try { + await fetchDefaultBranch(project.mainRepoPath, defaultBranch); + } catch { + // Ignore fetch errors (e.g., offline) + } + + // Get current branch from HEAD + const currentBranch = await getCurrentBranch(project.mainRepoPath); + + // Check if branch is behind origin/{defaultBranch} + const needsRebase = await checkNeedsRebase( + project.mainRepoPath, + defaultBranch, + ); + + return { + gitStatus: { + branch: currentBranch ?? workspace.branch, + needsRebase, + lastRefreshed: Date.now(), + }, + }; + } + + const worktree = workspace.worktreeId + ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) + : null; + if (!worktree) { + throw new Error( + `Worktree for workspace ${input.workspaceId} not found`, + ); + } + // Fetch default branch to get latest await fetchDefaultBranch(project.mainRepoPath, defaultBranch); @@ -620,9 +841,29 @@ export const createWorkspacesRouter = () => { return null; } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + // Branch type workspaces: fetch status using main repo path + if (workspace.type === "branch") { + const project = db.data.projects.find( + (p) => p.id === workspace.projectId, + ); + if (!project) return null; + + // Verify HEAD is on the expected branch before fetching PR status + const currentBranch = await getCurrentBranch(project.mainRepoPath); + if (currentBranch !== workspace.branch) { + console.warn( + `[getGitHubStatus] Branch mismatch: workspace expects "${workspace.branch}" but HEAD is on "${currentBranch ?? "detached HEAD"}"`, + ); + // Still fetch status but log warning - the PR status returned + // will be for whatever branch HEAD is actually on + } + + return fetchGitHubPRStatus(project.mainRepoPath); + } + + const worktree = workspace.worktreeId + ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) + : null; if (!worktree) { return null; } @@ -653,9 +894,20 @@ export const createWorkspacesRouter = () => { return null; } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + // Branch type workspaces return branch info directly + if (workspace.type === "branch") { + return { + worktreeName: workspace.branch, + workspaceType: "branch" as const, + createdAt: workspace.createdAt, + gitStatus: null, + githubStatus: null, + }; + } + + const worktree = workspace.worktreeId + ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) + : null; if (!worktree) { return null; } @@ -665,6 +917,7 @@ export const createWorkspacesRouter = () => { return { worktreeName, + workspaceType: "worktree" as const, createdAt: worktree.createdAt, gitStatus: worktree.gitStatus ?? null, githubStatus: worktree.githubStatus ?? null, diff --git a/apps/desktop/src/main/lib/db/index.ts b/apps/desktop/src/main/lib/db/index.ts index 85089ef4ed9..ef36317f39a 100644 --- a/apps/desktop/src/main/lib/db/index.ts +++ b/apps/desktop/src/main/lib/db/index.ts @@ -1,18 +1,144 @@ import { JSONFilePreset } from "lowdb/node"; import { DB_PATH } from "../app-environment"; -import type { Database } from "./schemas"; +import type { Database, Workspace } from "./schemas"; import { defaultDatabase } from "./schemas"; type DB = Awaited>>; let _db: DB | null = null; +/** + * Migrate existing workspaces to have type and branch fields. + * Pre-existing workspaces without these fields are worktree-based. + */ +function migrateWorkspaceFields(db: DB): boolean { + let needsWrite = false; + + for (const workspace of db.data.workspaces) { + // Cast to check for missing fields (old schema) + const ws = workspace as Partial & { + id: string; + worktreeId?: string; + }; + + // If type is missing, this is a pre-existing worktree workspace + if (!ws.type) { + ws.type = "worktree"; + needsWrite = true; + } + + // If branch is missing, copy from associated worktree + if (!ws.branch && ws.worktreeId) { + const worktree = db.data.worktrees.find((w) => w.id === ws.worktreeId); + if (worktree) { + ws.branch = worktree.branch; + needsWrite = true; + } + } + + // Fallback: if still no branch, use empty string (shouldn't happen) + if (!ws.branch) { + ws.branch = ""; + needsWrite = true; + } + } + + return needsWrite; +} + +/** + * Clean up duplicate branch workspaces per project. + * Only one branch workspace is allowed per project (they share the same directory). + * Keeps the most recently opened one and removes the rest. + */ +function cleanupDuplicateBranchWorkspaces(db: DB): boolean { + // Group branch workspaces by projectId + const branchWorkspacesByProject = new Map(); + for (const workspace of db.data.workspaces) { + if (workspace.type === "branch") { + const existing = branchWorkspacesByProject.get(workspace.projectId) ?? []; + existing.push(workspace); + branchWorkspacesByProject.set(workspace.projectId, existing); + } + } + + // Find projects with duplicates + const idsToRemove: string[] = []; + const keptWorkspaceIds: string[] = []; + for (const [projectId, workspaces] of branchWorkspacesByProject) { + if (workspaces.length > 1) { + // Sort by lastOpenedAt descending, keep the first (most recent) + workspaces.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + const [keep, ...remove] = workspaces; + keptWorkspaceIds.push(keep.id); + for (const ws of remove) { + idsToRemove.push(ws.id); + } + console.log( + `[migration] Project ${projectId} has ${workspaces.length} branch workspaces, removing ${remove.length} duplicates`, + ); + } + } + + if (idsToRemove.length > 0) { + // If lastActiveWorkspaceId points to a removed workspace, update it + const lastActiveId = db.data.settings.lastActiveWorkspaceId; + if (lastActiveId && idsToRemove.includes(lastActiveId)) { + // Find which project this was for and use the kept workspace + const removedWorkspace = db.data.workspaces.find( + (w) => w.id === lastActiveId, + ); + if (removedWorkspace) { + const keptForProject = db.data.workspaces.find( + (w) => + w.projectId === removedWorkspace.projectId && + w.type === "branch" && + keptWorkspaceIds.includes(w.id), + ); + db.data.settings.lastActiveWorkspaceId = + keptForProject?.id ?? undefined; + console.log( + `[migration] Updated lastActiveWorkspaceId from removed workspace to ${keptForProject?.id ?? "undefined"}`, + ); + } + } + + db.data.workspaces = db.data.workspaces.filter( + (w) => !idsToRemove.includes(w.id), + ); + return true; + } + + return false; +} + export async function initDb(): Promise { if (_db) return; const dbPath = DB_PATH; _db = await JSONFilePreset(dbPath, defaultDatabase); console.log(`Database initialized at: ${dbPath}`); + + // Run migrations + const migrations = [ + { name: "workspace fields", fn: migrateWorkspaceFields }, + { + name: "duplicate branch workspaces", + fn: cleanupDuplicateBranchWorkspaces, + }, + ]; + + let needsWrite = false; + for (const { name, fn } of migrations) { + if (fn(_db)) { + console.log(`[migration] Applied: ${name}`); + needsWrite = true; + } + } + + if (needsWrite) { + await _db.write(); + } } export const db = new Proxy({} as DB, { diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index f4f9f6d412c..f69d3b96fe3 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -50,10 +50,14 @@ export interface Worktree { githubStatus?: GitHubStatus; } +export type WorkspaceType = "worktree" | "branch"; + export interface Workspace { id: string; projectId: string; - worktreeId: string; + worktreeId?: string; // Only set for type="worktree" + type: WorkspaceType; + branch: string; // Branch name for both types name: string; tabOrder: number; createdAt: number; diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index 5444399585a..0466e081210 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -284,6 +284,21 @@ export class TerminalManager extends EventEmitter { return { killed, failed: results.length - killed }; } + /** + * Send a newline to all terminals in a workspace to refresh their prompts. + * Useful after branch switches so the prompt shows the new branch. + */ + refreshPromptsForWorkspace(workspaceId: string): number { + let refreshed = 0; + for (const [, session] of this.sessions) { + if (session.workspaceId === workspaceId && session.isAlive) { + session.pty.write("\n"); + refreshed++; + } + } + return refreshed; + } + private async killSessionWithTimeout( paneId: string, session: TerminalSession, diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 179548a4a93..c4f5de080d5 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -1,3 +1,4 @@ +export { useCreateBranchWorkspace } from "./useCreateBranchWorkspace"; export { useCreateWorkspace } from "./useCreateWorkspace"; export { useDeleteWorkspace } from "./useDeleteWorkspace"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts new file mode 100644 index 00000000000..798ce1d3400 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts @@ -0,0 +1,30 @@ +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +/** + * Mutation hook for creating a new branch workspace + * Automatically invalidates all workspace queries on success + * Adds a tab for the new workspace + */ +export function useCreateBranchWorkspace( + options?: Parameters< + typeof trpc.workspaces.createBranchWorkspace.useMutation + >[0], +) { + const utils = trpc.useUtils(); + const addTab = useTabsStore((state) => state.addTab); + + return trpc.workspaces.createBranchWorkspace.useMutation({ + ...options, + onSuccess: async (data, ...rest) => { + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + + // Add a tab for the new workspace + addTab(data.workspace.id); + + // Call user's onSuccess if provided + await options?.onSuccess?.(data, ...rest); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddBranchDialog/AddBranchDialog.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddBranchDialog/AddBranchDialog.tsx new file mode 100644 index 00000000000..eea020c2317 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddBranchDialog/AddBranchDialog.tsx @@ -0,0 +1,149 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { toast } from "@superset/ui/sonner"; +import { useState } from "react"; +import { HiMagnifyingGlass } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; + +interface AddBranchDialogProps { + projectId: string; + projectName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AddBranchDialog({ + projectId, + projectName, + open, + onOpenChange, +}: AddBranchDialogProps) { + const [search, setSearch] = useState(""); + const [selectedBranch, setSelectedBranch] = useState(null); + + const { data: branches, isLoading } = trpc.workspaces.getBranches.useQuery( + { projectId }, + { enabled: open }, + ); + + const createBranchWorkspace = useCreateBranchWorkspace(); + + // Combine local and remote branches, deduplicate + const allBranches = branches + ? Array.from(new Set([...branches.local, ...branches.remote])) + : []; + + // Filter branches by search + const filteredBranches = allBranches.filter((branch) => + branch.toLowerCase().includes(search.toLowerCase()), + ); + + const handleCreate = async () => { + if (!selectedBranch) return; + + toast.promise( + createBranchWorkspace.mutateAsync({ + projectId, + branch: selectedBranch, + }), + { + loading: `Creating workspace for ${selectedBranch}...`, + success: () => { + onOpenChange(false); + setSelectedBranch(null); + setSearch(""); + return `Workspace created for ${selectedBranch}`; + }, + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }, + ); + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setSelectedBranch(null); + setSearch(""); + } + onOpenChange(newOpen); + }; + + return ( + + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + + Add Existing Branch + + Select a branch from {projectName} to create a workspace + + + +
+
+ + setSearch(e.target.value)} + placeholder="Search branches..." + className="w-full rounded-md border border-border bg-muted/50 pl-9 pr-3 py-2 text-sm outline-none focus:border-primary focus:bg-background" + /> +
+ +
+ {isLoading ? ( +
+ Loading branches... +
+ ) : filteredBranches.length === 0 ? ( +
+ {search ? "No matching branches" : "No branches found"} +
+ ) : ( +
+ {filteredBranches.map((branch) => ( + + ))} +
+ )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddBranchDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddBranchDialog/index.ts new file mode 100644 index 00000000000..dbbaecab4fa --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddBranchDialog/index.ts @@ -0,0 +1 @@ +export { AddBranchDialog } from "./AddBranchDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx index 79d19c65acc..efbc4052f8c 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx @@ -15,6 +15,7 @@ import { useDeleteWorkspace } from "renderer/react-query/workspaces"; interface DeleteWorkspaceDialogProps { workspaceId: string; workspaceName: string; + workspaceType?: "worktree" | "branch"; open: boolean; onOpenChange: (open: boolean) => void; } @@ -22,6 +23,7 @@ interface DeleteWorkspaceDialogProps { export function DeleteWorkspaceDialog({ workspaceId, workspaceName, + workspaceType = "worktree", open, onOpenChange, }: DeleteWorkspaceDialogProps) { @@ -60,29 +62,33 @@ export function DeleteWorkspaceDialog({ const handleDelete = () => { onOpenChange(false); + const isBranch = workspaceType === "branch"; + const action = isBranch ? "Closing" : "Deleting"; + const actionPast = isBranch ? "closed" : "deleted"; + toast.promise(deleteWorkspace.mutateAsync({ id: workspaceId }), { - loading: `Deleting "${workspaceName}"...`, + loading: `${action} "${workspaceName}"...`, success: (result) => { if (result.teardownError || result.terminalWarning) { setTimeout(() => { if (result.teardownError) { - toast.warning("Workspace deleted with teardown warning", { + toast.warning(`Workspace ${actionPast} with teardown warning`, { description: result.teardownError, }); } if (result.terminalWarning) { - toast.warning("Workspace deleted with terminal warning", { + toast.warning(`Workspace ${actionPast} with terminal warning`, { description: result.terminalWarning, }); } }, 100); } - return `Workspace "${workspaceName}" deleted`; + return `Workspace "${workspaceName}" ${actionPast}`; }, error: (error) => error instanceof Error - ? `Failed to delete workspace: ${error.message}` - : "Failed to delete workspace", + ? `Failed to ${isBranch ? "close" : "delete"} workspace: ${error.message}` + : `Failed to ${isBranch ? "close" : "delete"} workspace`, }); }; @@ -97,7 +103,11 @@ export function DeleteWorkspaceDialog({ - Delete Workspace + + {workspaceType === "branch" + ? "Close Workspace" + : "Delete Workspace"} + {isLoading ? ( Checking workspace status... @@ -130,8 +140,9 @@ export function DeleteWorkspaceDialog({ )} - This will remove the workspace and its associated git - worktree. This action cannot be undone. + {workspaceType === "branch" + ? "This will close this branch workspace. Your branch and commits will remain in the repository." + : "This will remove the workspace and its associated git worktree. This action cannot be undone."} )} @@ -145,9 +156,13 @@ export function DeleteWorkspaceDialog({ handleDelete(); }} disabled={!canDelete || isLoading} - className="bg-destructive text-white hover:bg-destructive/90" + className={ + workspaceType === "branch" + ? "" + : "bg-destructive text-white hover:bg-destructive/90" + } > - Delete + {workspaceType === "branch" ? "Close" : "Delete"} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx index 1266cad88d7..9a8c0edca99 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceDropdown.tsx @@ -9,16 +9,24 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useRef, useState } from "react"; import { + HiCheck, HiChevronDown, HiChevronUp, + HiMagnifyingGlass, HiMiniFolderOpen, HiMiniPlus, } from "react-icons/hi2"; +import { LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; -import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { + useCreateBranchWorkspace, + useCreateWorkspace, + useSetActiveWorkspace, +} from "renderer/react-query/workspaces"; const INITIAL_PROJECTS_LIMIT = 5; +const INITIAL_BRANCHES_LIMIT = 6; /** * Formats a path for display, replacing the home directory with ~ and @@ -55,15 +63,76 @@ export interface WorkspaceDropdownProps { export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { const [isOpen, setIsOpen] = useState(false); const [showAllProjects, setShowAllProjects] = useState(false); + const [showAllBranches, setShowAllBranches] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); const primaryButtonRef = useRef(null); const dropdownTriggerRef = useRef(null); + const searchInputRef = useRef(null); + const utils = trpc.useUtils(); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const { data: homeDir } = trpc.window.getHomeDir.useQuery(); + const { data: allWorkspaces } = trpc.workspaces.getAllGrouped.useQuery(); const createWorkspace = useCreateWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); + const setActiveWorkspace = useSetActiveWorkspace(); const openNew = useOpenNew(); + // Get branches for current project when dropdown is open + const currentProjectId = activeWorkspace?.projectId; + const { data: branches } = trpc.workspaces.getBranches.useQuery( + { projectId: currentProjectId ?? "" }, + { enabled: isOpen && !!currentProjectId }, + ); + + // Get current project's workspaces to check which branches are already open + const currentProjectWorkspaces = + allWorkspaces?.find((g) => g.project.id === currentProjectId)?.workspaces ?? + []; + // Find existing branch workspace (only one allowed per project) + const existingBranchWorkspace = currentProjectWorkspaces.find( + (w) => w.type === "branch", + ); + // Map worktree workspaces by branch for quick lookup + const worktreeWorkspaceMap = new Map( + currentProjectWorkspaces + .filter((w) => w.type === "worktree") + .map((w) => [w.branch, w.id]), + ); + + const switchBranchWorkspace = + trpc.workspaces.switchBranchWorkspace.useMutation({ + onSuccess: () => { + utils.workspaces.invalidate(); + }, + }); + + // Combine and dedupe branches, with main/master at top + const allBranches = branches + ? Array.from(new Set([...branches.local, ...branches.remote])).sort( + (a, b) => { + // Main/master always first + if (a === "main" || a === "master") return -1; + if (b === "main" || b === "master") return 1; + // Then alphabetically + return a.localeCompare(b); + }, + ) + : []; + + // Filter branches by search + const filteredBranches = branchSearch + ? allBranches.filter((b) => + b.toLowerCase().includes(branchSearch.toLowerCase()), + ) + : allBranches; + + const visibleBranches = showAllBranches + ? filteredBranches + : filteredBranches.slice(0, INITIAL_BRANCHES_LIMIT); + const hasMoreBranches = filteredBranches.length > INITIAL_BRANCHES_LIMIT; + const currentProject = recentProjects.find( (p) => p.id === activeWorkspace?.projectId, ); @@ -78,10 +147,70 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { const closeDropdown = () => { setIsOpen(false); setShowAllProjects(false); + setShowAllBranches(false); + setBranchSearch(""); primaryButtonRef.current?.blur(); dropdownTriggerRef.current?.blur(); }; + const handleBranchClick = async (branch: string) => { + if (!currentProjectId) return; + + // Check if there's a worktree workspace for this branch + const worktreeWorkspaceId = worktreeWorkspaceMap.get(branch); + if (worktreeWorkspaceId) { + setActiveWorkspace.mutate({ id: worktreeWorkspaceId }); + closeDropdown(); + return; + } + + // Check if the existing branch workspace is already on this branch + if (existingBranchWorkspace?.branch === branch) { + setActiveWorkspace.mutate({ id: existingBranchWorkspace.id }); + closeDropdown(); + return; + } + + // If there's an existing branch workspace on a different branch, switch it + if (existingBranchWorkspace) { + toast.promise( + switchBranchWorkspace.mutateAsync({ + projectId: currentProjectId, + branch, + }), + { + loading: `Switching to ${branch}...`, + success: () => { + // Explicitly activate the workspace for immediate view switch + setActiveWorkspace.mutate({ id: existingBranchWorkspace.id }); + closeDropdown(); + return `Switched to ${branch}`; + }, + error: (err) => + err instanceof Error ? err.message : "Failed to switch branch", + }, + ); + return; + } + + // No branch workspace exists, create one + toast.promise( + createBranchWorkspace.mutateAsync({ + projectId: currentProjectId, + branch, + }), + { + loading: `Switching to ${branch}...`, + success: () => { + closeDropdown(); + return `Switched to ${branch}`; + }, + error: (err) => + err instanceof Error ? err.message : "Failed to switch branch", + }, + ); + }; + const handleCreateWorkspace = async (projectId: string) => { toast.promise(createWorkspace.mutateAsync({ projectId }), { loading: "Creating workspace...", @@ -185,11 +314,15 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { More options - + + {/* New workspace header */}

New Workspace

- Select a project to create a workspace + Create a new worktree branch

{currentProject && ( @@ -260,6 +393,107 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { )} )} + {/* Branches section - switch to existing branch */} + {currentProject && allBranches.length > 0 && ( +
+

+ + Branches in {currentProject.name} +

+ + {/* Search input - only show if many branches */} + {allBranches.length > INITIAL_BRANCHES_LIMIT && ( +
+
+ + setBranchSearch(e.target.value)} + placeholder="Search branches..." + className="w-full rounded-md border border-border bg-muted/50 pl-7 pr-2 py-1.5 text-xs outline-none focus:border-primary focus:bg-background placeholder:text-muted-foreground/60" + onKeyDown={(e) => e.stopPropagation()} + /> +
+
+ )} + +
+ {visibleBranches.length === 0 ? ( +

+ No branches found +

+ ) : ( + visibleBranches.map((branch) => { + const isActive = activeWorkspace?.branch === branch; + const hasWorktreeWorkspace = + worktreeWorkspaceMap.has(branch); + const isMainBranch = + branch === "main" || branch === "master"; + + return ( + + ); + }) + )} +
+ {hasMoreBranches && !branchSearch && ( + + )} +
+ )} +
- ))} -
- - - - - - + <> + + {children} + +
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > +

+ Workspace group name +

+ setName(event.target.value)} + onBlur={handleBlur} + onKeyDown={handleNameKeyDown} + className="w-full rounded-md border border-border bg-muted/50 px-2 py-1 text-sm text-foreground outline-none focus:border-primary focus:bg-background" + placeholder="Workspace group" + /> +
+ + + +
+ {PROJECT_COLORS.map((color) => ( + + ))} +
+ + + + + + + + +
+
+ + + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx index f7bfbfbe8c3..8454d80adf1 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx @@ -1,4 +1,5 @@ import { useDrag, useDrop } from "react-dnd"; +import { HiChevronLeft, HiChevronRight, HiFolder } from "react-icons/hi2"; import { useReorderProjects } from "renderer/react-query/projects"; import { WorkspaceGroupContextMenu } from "./WorkspaceGroupContextMenu"; @@ -13,6 +14,19 @@ interface WorkspaceGroupHeaderProps { onToggleCollapse: () => void; } +/** + * Determines if a color is light or dark to choose appropriate text color + */ +function isLightColor(hexColor: string): boolean { + const hex = hexColor.replace("#", ""); + const r = Number.parseInt(hex.substring(0, 2), 16); + const g = Number.parseInt(hex.substring(2, 4), 16); + const b = Number.parseInt(hex.substring(4, 6), 16); + // Using relative luminance formula + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.6; +} + export function WorkspaceGroupHeader({ projectId, projectName, @@ -22,6 +36,12 @@ export function WorkspaceGroupHeader({ onToggleCollapse, }: WorkspaceGroupHeaderProps) { const reorderProjects = useReorderProjects(); + const textColor = isLightColor(projectColor) + ? "rgba(0,0,0,0.8)" + : "rgba(255,255,255,0.95)"; + const subtleTextColor = isLightColor(projectColor) + ? "rgba(0,0,0,0.5)" + : "rgba(255,255,255,0.7)"; const [{ isDragging }, drag] = useDrag( () => ({ @@ -54,17 +74,17 @@ export function WorkspaceGroupHeader({ ); return ( - -
-
-
+ + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx index 9d6874045f6..c1016b02915 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -34,6 +34,7 @@ export function WorkspaceHoverCardContent({ const pr = githubStatus?.pr; const needsRebase = worktreeInfo?.gitStatus?.needsRebase; + const isBranchType = worktreeInfo?.workspaceType === "branch"; const worktreeName = worktreeInfo?.worktreeName; const hasCustomAlias = @@ -77,8 +78,15 @@ export function WorkspaceHoverCardContent({ )} - {/* Needs Rebase Warning */} - {needsRebase && ( + {/* Direct branch indicator */} + {isBranchType && ( +
+ Direct branch workspace +
+ )} + + {/* Needs Rebase Warning - only for worktree type */} + {needsRebase && !isBranchType && (
Behind main, needs rebase diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index 72e486114c6..a3ecf9eccf1 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; -import { HiMiniXMark } from "react-icons/hi2"; +import { HiMiniXMark, HiOutlineCodeBracketSquare } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useDeleteWorkspace, @@ -24,6 +24,7 @@ interface WorkspaceItemProps { id: string; projectId: string; worktreePath: string; + workspaceType?: "worktree" | "branch"; title: string; isActive: boolean; index: number; @@ -36,6 +37,7 @@ export function WorkspaceItem({ id, projectId, worktreePath, + workspaceType = "worktree", title, isActive, index, @@ -62,10 +64,35 @@ export function WorkspaceItem({ // Prevent double-clicks and race conditions if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; + const isBranch = workspaceType === "branch"; + try { // Always fetch fresh data before deciding const { data: canDeleteData } = await canDeleteQuery.refetch(); + // For branch workspaces, only show dialog if there are active terminals + // (no destructive action - branch stays in repo) + if (isBranch) { + if ( + canDeleteData?.activeTerminalCount && + canDeleteData.activeTerminalCount > 0 + ) { + setShowDeleteDialog(true); + } else { + // Close directly without confirmation + toast.promise(deleteWorkspace.mutateAsync({ id }), { + loading: `Closing "${title}"...`, + success: `Workspace "${title}" closed`, + error: (error) => + error instanceof Error + ? `Failed to close workspace: ${error.message}` + : "Failed to close workspace", + }); + } + return; + } + + // For worktree workspaces, check all conditions const isEmpty = canDeleteData?.canDelete && canDeleteData.activeTerminalCount === 0 && @@ -199,6 +226,9 @@ export function WorkspaceItem({ /> ) : ( <> + {workspaceType === "branch" && ( + + )} diff --git a/apps/desktop/src/shared/ipc-channels/tab.ts b/apps/desktop/src/shared/ipc-channels/tab.ts index 7b90b4338b2..626d77580d8 100644 --- a/apps/desktop/src/shared/ipc-channels/tab.ts +++ b/apps/desktop/src/shared/ipc-channels/tab.ts @@ -19,7 +19,7 @@ export interface TabChannels { "tab-delete": { request: { workspaceId: string; - worktreeId: string; + worktreeId?: string; tabId: string; }; response: IpcResponse; @@ -33,7 +33,7 @@ export interface TabChannels { "tab-update-name": { request: { workspaceId: string; - worktreeId: string; + worktreeId?: string; tabId: string; name: string; }; @@ -43,7 +43,7 @@ export interface TabChannels { "tab-reorder": { request: { workspaceId: string; - worktreeId: string; + worktreeId?: string; parentTabId?: string; tabIds: string[]; }; @@ -53,7 +53,7 @@ export interface TabChannels { "tab-move": { request: { workspaceId: string; - worktreeId: string; + worktreeId?: string; tabId: string; sourceParentTabId?: string; targetParentTabId?: string; @@ -65,7 +65,7 @@ export interface TabChannels { "tab-update-mosaic-tree": { request: { workspaceId: string; - worktreeId: string; + worktreeId?: string; tabId: string; mosaicTree: MosaicNode | null | undefined; }; diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index ebac5467e81..37f52f57af4 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -104,7 +104,7 @@ export interface CreateWorktreeInput { export interface CreateTabInput { workspaceId: string; - worktreeId: string; + worktreeId?: string; // Optional for branch workspaces (they use mainRepoPath instead) parentTabId?: string; // Optional parent tab (for tabs inside a group) name: string; type?: TabType; // Optional - defaults to "terminal" @@ -116,7 +116,7 @@ export interface CreateTabInput { export interface UpdatePreviewTabInput { workspaceId: string; - worktreeId: string; + worktreeId?: string; // Optional for branch workspaces tabId: string; url: string; }