diff --git a/apps/desktop/docs/BRANCH_WORKSPACE_UX.md b/apps/desktop/docs/BRANCH_WORKSPACE_UX.md new file mode 100644 index 00000000000..3139db1bc79 --- /dev/null +++ b/apps/desktop/docs/BRANCH_WORKSPACE_UX.md @@ -0,0 +1,174 @@ +# Branch Workspace UX Design + +Summary of UX patterns from PR #359 (efficient-haddock-4249d8) for non-worktree branch workspaces. + +## Core Concept + +Two workspace types: +1. **Worktree Workspaces** (`type: "worktree"`): Each has its own isolated directory via git worktrees. Multiple can exist per project. +2. **Branch Workspaces** (`type: "branch"`): Uses the main repo path directly. Only **one per project** (they share the same directory, so switching branches affects all). + +## Key UX Decisions + +### 1. One Branch Workspace Per Project + +Since branch workspaces share the main repo path, switching branches in one would affect all. The solution: +- Only allow **one branch workspace per project** at a time +- When user selects a different branch, **switch** the existing branch workspace (don't create new) +- This prevents confusion from having multiple tabs pointing at the same directory with different branch expectations + +### 2. Main Terminal on Project Open + +When opening a project without existing workspaces: +- Auto-create a branch workspace for the current branch (main/master) +- This provides immediate access to the main repo terminal +- Users can then create worktrees from this starting point + +### 3. Terminology: "Close" vs "Delete" + +| Workspace Type | Action | What Happens | +|---------------|--------|--------------| +| **Branch** | "Close" | Removes tab, kills terminals. Branch & commits stay in repo. Non-destructive. | +| **Worktree** | "Delete" | Removes worktree directory, deletes branch. Destructive with warnings. | + +### 4. Branch Switching UI + +From the workspace dropdown: +- Show list of project branches (local + remote, deduplicated) +- Main/master branches sorted to top with "default" label +- Active branch shows checkmark +- Branches with worktree workspaces show small indicator dot +- Search filter for many branches + +### 5. Visual Differentiation + +Branch workspaces show a code bracket icon (`HiOutlineCodeBracketSquare`) to differentiate from worktree workspaces. + +### 6. Safety Checks for Branch Switching + +Before switching branches: +1. Check for uncommitted changes (staged/unstaged) +2. Check for untracked files that might be overwritten +3. Fetch and prune stale remote refs +4. Verify checkout landed on correct branch + +### 7. Terminal Prompt Refresh + +After switching branches, send newline to all workspace terminals to refresh their prompts (so they show the new branch name). + +## Schema Changes + +```typescript +export type WorkspaceType = "worktree" | "branch"; + +export interface Workspace { + id: string; + projectId: string; + worktreeId?: string; // Only set for type="worktree" + type: WorkspaceType; // NEW: workspace type + branch: string; // NEW: current branch name + name: string; // User-customizable alias + tabOrder: number; + createdAt: number; + updatedAt: number; + lastOpenedAt: number; +} +``` + +## Backend Procedures + +### New Procedures + +1. **`createBranchWorkspace`** + - Input: `{ projectId, branch, name? }` + - Creates workspace pointing at main repo path + - Performs safe checkout to target branch + - Enforces one-branch-workspace-per-project rule + +2. **`getBranches`** + - Input: `{ projectId, fetch? }` + - Returns `{ local: string[], remote: string[] }` + - Optionally fetches/prunes remote refs first + +3. **`switchBranchWorkspace`** + - Input: `{ projectId, branch }` + - Finds existing branch workspace, switches its branch + - Refreshes terminal prompts + - Preserves custom workspace name (alias) + +### Modified Procedures + +- **`getAllGrouped`**: Include `type` and `branch` in workspace data +- **`getActive`**: Include `type` and `branch` +- **`delete`**: Handle branch workspaces (no worktree removal needed) +- **`canDelete`**: Skip git status checks for branch workspaces + +## Git Utilities + +```typescript +// List all branches (local + remote) +listBranches(repoPath, { fetch?: boolean }): Promise<{ local: string[], remote: string[] }> + +// Safe checkout with pre-flight checks +safeCheckoutBranch(repoPath, branch): Promise + +// Get current branch name +getCurrentBranch(repoPath): Promise + +// Pre-checkout safety check +checkBranchCheckoutSafety(repoPath): Promise +``` + +## UI Components + +### WorkspaceDropdown Changes +- Add "Branches in {project}" section +- Show branch list with search +- Handle branch click: switch or activate existing workspace + +### WorkspaceItem Changes +- Accept `workspaceType` prop +- Show branch icon for branch workspaces +- Use "Close" action for branch workspaces (vs "Delete") + +### DeleteWorkspaceDialog Changes +- Accept `workspaceType` prop +- Contextual title: "Close Workspace" vs "Delete Workspace" +- Contextual description explaining impact +- Non-destructive styling for branch workspace close + +### AddBranchDialog (New) +- Modal to select branch from list +- Search/filter branches +- Create branch workspace on selection + +## Workspace Path Resolution + +```typescript +function getWorkspacePath(workspace: Workspace): string | null { + if (workspace.type === "branch") { + const project = db.data.projects.find(p => p.id === workspace.projectId); + return project?.mainRepoPath ?? null; + } + // For worktree type, use worktree path + const worktree = db.data.worktrees.find(wt => wt.id === workspace.worktreeId); + return worktree?.path ?? null; +} +``` + +## Migration Considerations + +Existing workspaces need: +1. Add `type: "worktree"` (default for existing) +2. Add `branch` field (copy from associated worktree) + +```typescript +// DB migration in index.ts +if (!workspace.type) { + workspace.type = "worktree"; +} +if (!workspace.branch && workspace.worktreeId) { + const worktree = db.data.worktrees.find(wt => wt.id === workspace.worktreeId); + workspace.branch = worktree?.branch ?? ""; +} +``` diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 85562a274bc..190bc9abae6 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) : 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, 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 7f2075dece4..f5cba4e8ac5 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -358,3 +358,209 @@ 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 }; +} + +/** + * 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; + } +} + +/** + * 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/created/renamed) + * 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(); + + // Check all forms of uncommitted changes: + // - staged: files added to index + // - modified: tracked files with unstaged changes + // - deleted: tracked files deleted but not staged + // - created: new files staged for commit + // - renamed: files renamed (staged) + // - conflicted: merge conflicts + const hasUncommittedChanges = + status.staged.length > 0 || + status.modified.length > 0 || + status.deleted.length > 0 || + status.created.length > 0 || + status.renamed.length > 0 || + status.conflicted.length > 0; + + // Untracked files that could be overwritten by checkout + 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, + }; + } + + // Block on untracked files as they could be overwritten + if (hasUntrackedFiles) { + return { + safe: false, + error: + "Cannot switch branches: you have untracked files that may be overwritten. Please commit, stash, or remove them first.", + hasUncommittedChanges: false, + hasUntrackedFiles: true, + }; + } + + // 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: false, + }; + } catch (error) { + return { + safe: false, + error: `Failed to check repository status: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * 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 + */ +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..1a83fdeae20 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,32 @@ 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 worktreeId */ export function getWorktreePath(worktreeId: string): string | undefined { const worktree = db.data.worktrees.find((w) => w.id === worktreeId); return worktree?.path; } + +/** + * Gets the working directory path for a workspace. + * For worktree workspaces: returns the worktree path + * For branch workspaces: returns the main repo path + */ +export function getWorkspacePath(workspace: Workspace): string | null { + if (workspace.type === "branch") { + const project = db.data.projects.find((p) => p.id === workspace.projectId); + return project?.mainRepoPath ?? null; + } + + // For worktree type, use worktree path + if (workspace.worktreeId) { + const worktree = db.data.worktrees.find( + (wt) => wt.id === workspace.worktreeId, + ); + return worktree?.path ?? null; + } + + return null; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 8ddfda9dea5..8dcf444b729 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({ @@ -110,6 +113,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(), @@ -151,6 +156,198 @@ export const createWorkspacesRouter = () => { }; }), + createBranchWorkspace: publicProcedure + .input( + z.object({ + projectId: z.string(), + branch: z.string().optional(), // If not provided, uses current branch + 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`); + } + + // Determine the branch - use provided or get current + const branch = + input.branch || (await getCurrentBranch(project.mainRepoPath)); + if (!branch) { + throw new Error("Could not determine current branch"); + } + + // If a specific branch was requested, check for conflict before checkout + if (input.branch) { + const existingBranchWorkspace = db.data.workspaces.find( + (w) => w.projectId === input.projectId && w.type === "branch", + ); + if ( + existingBranchWorkspace && + existingBranchWorkspace.branch !== branch + ) { + throw new Error( + `A main workspace already exists on branch "${existingBranchWorkspace.branch}". ` + + `Use the branch switcher to change branches.`, + ); + } + await safeCheckoutBranch(project.mainRepoPath, input.branch); + } + + // Prepare new workspace (may not be used if existing found) + const workspace = { + id: nanoid(), + projectId: input.projectId, + worktreeId: undefined, + type: "branch" as const, + branch, + name: branch, // Name is always the branch for branch workspaces + tabOrder: 0, // Main workspace is always first + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + }; + + // Track which workspace "wins" - makes concurrent calls idempotent + let returnedWorkspace: typeof workspace = workspace; + let wasExisting = false; + + await db.update((data) => { + // Atomic check: if branch workspace already exists, activate it + const existing = data.workspaces.find( + (w) => w.projectId === input.projectId && w.type === "branch", + ); + + if (existing) { + wasExisting = true; + returnedWorkspace = existing as typeof workspace; + data.settings.lastActiveWorkspaceId = existing.id; + existing.lastOpenedAt = Date.now(); + return; + } + + // Create new workspace - shift existing ones to make room at front + for (const ws of data.workspaces) { + if (ws.projectId === input.projectId) { + ws.tabOrder += 1; + } + } + 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: returnedWorkspace, + worktreePath: project.mainRepoPath, + projectId: project.id, + wasExisting, + }; + }), + + 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`); + } + + const branches = await listBranches(project.mainRepoPath, { + fetch: input.fetch, + }); + + // Get branches that are in use by worktrees, with their workspace IDs + const projectWorkspaces = db.data.workspaces.filter( + (w) => w.projectId === input.projectId, + ); + const worktreeBranchMap: Record = {}; + for (const ws of projectWorkspaces) { + if (ws.type === "worktree" && ws.branch) { + worktreeBranchMap[ws.branch] = ws.id; + } + } + + return { + ...branches, + inUse: Object.keys(worktreeBranchMap), + inUseWorkspaces: worktreeBranchMap, // branch -> workspaceId + }; + }), + + // 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 - name is always the branch for branch workspaces + await db.update((data) => { + const ws = data.workspaces.find((w) => w.id === workspace.id); + if (ws) { + ws.branch = input.branch; + ws.name = input.branch; // Name is always the branch + 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 }) => { @@ -182,8 +379,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; @@ -214,7 +413,7 @@ export const createWorkspacesRouter = () => { if (groupsMap.has(workspace.projectId)) { groupsMap.get(workspace.projectId)?.workspaces.push({ ...workspace, - worktreePath: getWorktreePath(workspace.worktreeId) ?? "", + worktreePath: getWorkspacePath(workspace) ?? "", }); } } @@ -243,13 +442,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, @@ -315,6 +514,19 @@ export const createWorkspacesRouter = () => { const activeTerminalCount = terminalManager.getSessionCountByWorkspaceId(input.id); + // Branch workspaces are non-destructive to close - no git checks needed + if (workspace.type === "branch") { + return { + canDelete: true, + reason: null, + workspace, + warning: null, + activeTerminalCount, + hasChanges: false, + hasUnpushedCommits: false, + }; + } + // If skipping git checks, return early with just terminal count // This is used during polling to avoid expensive git operations if (input.skipGitChecks) { @@ -329,9 +541,9 @@ export const createWorkspacesRouter = () => { }; } - 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; const project = db.data.projects.find( (p) => p.id === workspace.projectId, ); @@ -408,53 +620,58 @@ 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: (typeof db.data.worktrees)[0] | undefined; - if (worktree && project) { - // Run teardown scripts before removing worktree - const exists = await worktreeExists( - project.mainRepoPath, - worktree.path, + // Branch workspaces don't have worktrees - skip worktree operations + 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}`, - }; } } - // 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); @@ -742,6 +959,8 @@ export const createWorkspacesRouter = () => { id: nanoid(), projectId: worktree.projectId, worktreeId: worktree.id, + type: "worktree" as const, + branch: worktree.branch, name: input.name ?? worktree.branch, tabOrder: maxTabOrder + 1, createdAt: Date.now(), diff --git a/apps/desktop/src/main/lib/db/index.ts b/apps/desktop/src/main/lib/db/index.ts index 85089ef4ed9..c3c8c60ef8c 100644 --- a/apps/desktop/src/main/lib/db/index.ts +++ b/apps/desktop/src/main/lib/db/index.ts @@ -1,18 +1,69 @@ 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 include type and branch fields. + * Existing workspaces are all worktree-based. + */ +async function migrateWorkspaces(database: DB): Promise { + let needsWrite = false; + + for (const workspace of database.data.workspaces) { + // Cast to allow checking for missing fields + const ws = workspace as Workspace & { type?: string; branch?: string }; + + // Add type field if missing (existing workspaces are all worktree type) + if (!ws.type) { + ws.type = "worktree"; + needsWrite = true; + } + + // Add branch field if missing (copy from associated worktree) + if (!ws.branch) { + if (ws.worktreeId) { + const worktree = database.data.worktrees.find( + (wt) => wt.id === ws.worktreeId, + ); + if (worktree) { + ws.branch = worktree.branch; + } else { + console.warn( + `Migration: Worktree ${ws.worktreeId} not found for workspace ${ws.id}, using fallback branch`, + ); + ws.branch = "unknown"; + } + } else { + // Workspace without worktreeId (shouldn't happen for existing data, but be safe) + console.warn( + `Migration: Workspace ${ws.id} has no worktreeId, using fallback branch`, + ); + ws.branch = "unknown"; + } + needsWrite = true; + } + } + + if (needsWrite) { + await database.write(); + console.log("Migrated workspaces to include type and branch fields"); + } +} + 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 + await migrateWorkspaces(_db); } 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..d038d7928fd 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -360,6 +360,25 @@ export class TerminalManager extends EventEmitter { ).length; } + /** + * Send a newline to all terminals in a workspace to refresh their prompts. + * Useful after switching branches to update the branch name in prompts. + */ + refreshPromptsForWorkspace(workspaceId: string): void { + for (const [paneId, session] of this.sessions.entries()) { + if (session.workspaceId === workspaceId && session.isAlive) { + try { + session.pty.write("\n"); + } catch (error) { + console.warn( + `[TerminalManager] Failed to refresh prompt for pane ${paneId}:`, + error, + ); + } + } + } + } + detachAllListeners(): void { for (const event of this.eventNames()) { const name = String(event); diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index cf373ddb6f5..224b9f3ba75 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -19,7 +19,10 @@ import { useEffect, useState } from "react"; import { HiPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; -import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { + useCreateBranchWorkspace, + useCreateWorkspace, +} from "renderer/react-query/workspaces"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, @@ -55,6 +58,7 @@ export function NewWorkspaceModal() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const createWorkspace = useCreateWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); const openNew = useOpenNew(); const currentProjectId = activeWorkspace?.projectId; @@ -134,6 +138,8 @@ export function NewWorkspaceModal() { }); return; } + // Create a main workspace on the current branch for the new project + await createBranchWorkspace.mutateAsync({ projectId: result.project.id }); setSelectedProjectId(result.project.id); } catch (error) { toast.error("Failed to open project", { diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 912c28f3d2c..60a9c29b75d 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -1,4 +1,5 @@ export { useCloseWorkspace } from "./useCloseWorkspace"; +export { useCreateBranchWorkspace } from "./useCreateBranchWorkspace"; export { useCreateWorkspace } from "./useCreateWorkspace"; export { useDeleteWorkspace } from "./useDeleteWorkspace"; export { useOpenWorktree } from "./useOpenWorktree"; 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..dd77a99cfd3 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts @@ -0,0 +1,32 @@ +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 newly created workspaces (not existing ones) + */ +export function useCreateBranchWorkspace( + options?: Parameters< + typeof trpc.workspaces.createBranchWorkspace.useMutation + >[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.createBranchWorkspace.useMutation({ + ...options, + onSuccess: async (data, ...rest) => { + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + + // Only add a tab for newly created workspaces (not existing ones being activated) + // The store's addTab is idempotent, so duplicate calls are safe + if (!data.wasExisting) { + useTabsStore.getState().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/StartView/index.tsx b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx index 41f0eb49c4a..8f72eed14ec 100644 --- a/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/StartView/index.tsx @@ -5,7 +5,7 @@ import { LuChevronUp, LuFolderGit, LuFolderOpen, LuX } from "react-icons/lu"; import { formatPathWithProject } from "renderer/lib/formatPath"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; -import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; import { ActionCard } from "./ActionCard"; import { CloneRepoDialog } from "./CloneRepoDialog"; import { InitGitDialog } from "./InitGitDialog"; @@ -15,7 +15,7 @@ export function StartView() { const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const { data: homeDir } = trpc.window.getHomeDir.useQuery(); const openNew = useOpenNew(); - const createWorkspace = useCreateWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); const [error, setError] = useState(null); const [isCloneDialogOpen, setIsCloneDialogOpen] = useState(false); const [initGitDialog, setInitGitDialog] = useState<{ @@ -47,7 +47,8 @@ export function StartView() { return; } - createWorkspace.mutate({ projectId: result.project.id }); + // Create a main workspace on the current branch + createBranchWorkspace.mutate({ projectId: result.project.id }); }, onError: (err) => { setError(err.message || "Failed to open project"); @@ -57,11 +58,12 @@ export function StartView() { const handleOpenRecentProject = (projectId: string) => { setError(null); - createWorkspace.mutate( + // Create/activate main workspace on current branch + createBranchWorkspace.mutate( { projectId }, { onError: (err) => { - setError(err.message || "Failed to create workspace"); + setError(err.message || "Failed to open workspace"); }, }, ); @@ -72,7 +74,7 @@ export function StartView() { ? recentProjects.slice(0, visibleCount) : recentProjects.slice(0, 5); const hasMoreToLoad = showAllProjects && recentProjects.length > visibleCount; - const isLoading = openNew.isPending || createWorkspace.isPending; + const isLoading = openNew.isPending || createBranchWorkspace.isPending; return (
diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx new file mode 100644 index 00000000000..0bb7e432b36 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx @@ -0,0 +1,206 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Input } from "@superset/ui/input"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useMemo, useState } from "react"; +import { HiCheck, HiChevronDown } from "react-icons/hi2"; +import { LuGitBranch, LuGitFork, LuLoader } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; + +interface BranchSwitcherProps { + projectId: string; + currentBranch: string; + className?: string; +} + +export function BranchSwitcher({ + projectId, + currentBranch, + className, +}: BranchSwitcherProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + + const utils = trpc.useUtils(); + const setActiveWorkspace = useSetActiveWorkspace(); + + // Fetch branches when dropdown opens + const { data: branchesData, isLoading } = + trpc.workspaces.getBranches.useQuery( + { projectId, fetch: false }, + { enabled: isOpen }, + ); + + const switchBranch = trpc.workspaces.switchBranchWorkspace.useMutation({ + onSuccess: () => { + utils.workspaces.invalidate(); + }, + }); + + // Branches in use by worktrees (branch -> workspaceId) + const inUseWorkspaces = useMemo(() => { + return branchesData?.inUseWorkspaces ?? {}; + }, [branchesData]); + + // Set of branch names in use for quick lookup + const inUseBranches = useMemo(() => { + return new Set(Object.keys(inUseWorkspaces)); + }, [inUseWorkspaces]); + + // Combine and dedupe branches, prioritize main/master + const branches = useMemo(() => { + if (!branchesData) return []; + + const allBranches = new Set([ + ...branchesData.local, + ...branchesData.remote, + ]); + const sorted = Array.from(allBranches).sort((a, b) => { + // Prioritize main/master/develop + const priority = ["main", "master", "develop"]; + const aIndex = priority.indexOf(a); + const bIndex = priority.indexOf(b); + if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + // Then prioritize branches in use + const aInUse = inUseBranches.has(a); + const bInUse = inUseBranches.has(b); + if (aInUse && !bInUse) return -1; + if (!aInUse && bInUse) return 1; + return a.localeCompare(b); + }); + return sorted; + }, [branchesData, inUseBranches]); + + // Filter by search + const filteredBranches = useMemo(() => { + if (!search.trim()) return branches; + const term = search.toLowerCase(); + return branches.filter((b) => b.toLowerCase().includes(term)); + }, [branches, search]); + + const handleBranchClick = (branch: string) => { + if (branch === currentBranch) { + setIsOpen(false); + return; + } + + // If branch is in use by a worktree, jump to that workspace + const worktreeWorkspaceId = inUseWorkspaces[branch]; + if (worktreeWorkspaceId) { + setActiveWorkspace.mutate({ id: worktreeWorkspaceId }); + setIsOpen(false); + return; + } + + // Otherwise switch this workspace to the new branch + toast.promise(switchBranch.mutateAsync({ projectId, branch }), { + loading: `Switching to ${branch}...`, + success: `Switched to ${branch}`, + error: (err) => + err instanceof Error ? err.message : "Failed to switch branch", + }); + setIsOpen(false); + }; + + return ( + + + + + e.stopPropagation()} + > + {/* Search input */} +
+ setSearch(e.target.value)} + className="h-7 text-xs" + autoFocus + /> +
+ + {/* Branch list */} +
+ {isLoading ? ( +
+ +
+ ) : filteredBranches.length === 0 ? ( +
+ {search ? "No branches match" : "No branches found"} +
+ ) : ( + <> + {filteredBranches.slice(0, 50).map((branch) => { + const isDefault = ["main", "master", "develop"].includes( + branch, + ); + const isCurrent = branch === currentBranch; + const isInUse = inUseBranches.has(branch); + + return ( + handleBranchClick(branch)} + disabled={switchBranch.isPending} + className="flex items-center gap-2 px-2 py-1.5" + > + {isInUse ? ( + + ) : ( + + )} + {branch} + {isInUse && ( + + worktree + + )} + {isDefault && !isInUse && ( + + default + + )} + {isCurrent && ( + + )} + + ); + })} + {filteredBranches.length > 50 && ( + <> + +
+ {filteredBranches.length - 50} more branches... +
+ + )} + + )} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts new file mode 100644 index 00000000000..9f474d0d924 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts @@ -0,0 +1 @@ +export { BranchSwitcher } from "./BranchSwitcher"; 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 62689d94fd7..630313ca870 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 @@ -18,6 +18,7 @@ import { interface DeleteWorkspaceDialogProps { workspaceId: string; workspaceName: string; + workspaceType?: "worktree" | "branch"; open: boolean; onOpenChange: (open: boolean) => void; } @@ -25,9 +26,11 @@ interface DeleteWorkspaceDialogProps { export function DeleteWorkspaceDialog({ workspaceId, workspaceName, + workspaceType = "worktree", open, onOpenChange, }: DeleteWorkspaceDialogProps) { + const isBranch = workspaceType === "branch"; const deleteWorkspace = useDeleteWorkspace(); const closeWorkspace = useCloseWorkspace(); @@ -111,6 +114,48 @@ export function DeleteWorkspaceDialog({ const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false; const hasWarnings = hasChanges || hasUnpushedCommits; + // For branch workspaces, use simplified dialog (only close option) + if (isBranch) { + return ( + + + + + Close workspace "{workspaceName}"? + + +
+ + This will close the workspace and kill any active terminals. + Your branch and commits will remain in the repository. + +
+
+
+ + + + + +
+
+ ); + } + return ( 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 9b543a359d5..a73a6a4a9ae 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 @@ -6,7 +6,10 @@ import { useRef } from "react"; import { HiChevronDown, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; -import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { + useCreateBranchWorkspace, + useCreateWorkspace, +} from "renderer/react-query/workspaces"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; export interface WorkspaceDropdownProps { @@ -20,6 +23,7 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const createWorkspace = useCreateWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); const openNew = useOpenNew(); const openModal = useOpenNewWorkspaceModal(); @@ -63,13 +67,14 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { }); return; } + // Create a main workspace on the current branch for the new project toast.promise( - createWorkspace.mutateAsync({ projectId: result.project.id }), + createBranchWorkspace.mutateAsync({ projectId: result.project.id }), { - loading: "Creating workspace...", - success: "Workspace created", + loading: "Opening project...", + success: "Project opened", error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", + err instanceof Error ? err.message : "Failed to open project", }, ); } catch (error) { @@ -98,7 +103,11 @@ export function WorkspaceDropdown({ className }: WorkspaceDropdownProps) { aria-label="New workspace" className="ml-1 mt-1 size-7 text-muted-foreground hover:text-foreground group-hover/split:bg-accent/30 hover:!bg-accent" onClick={handlePrimaryAction} - disabled={createWorkspace.isPending || openNew.isPending} + disabled={ + createWorkspace.isPending || + createBranchWorkspace.isPending || + openNew.isPending + } > diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx index 469a26dd3a8..0b042f2ef52 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx @@ -7,6 +7,8 @@ interface Workspace { id: string; projectId: string; worktreePath: string; + type: "worktree" | "branch"; + branch: string; name: string; tabOrder: number; } @@ -73,6 +75,8 @@ export function WorkspaceGroup({ id={workspace.id} projectId={workspace.projectId} worktreePath={workspace.worktreePath} + workspaceType={workspace.type} + branch={workspace.branch} title={workspace.name} isActive={workspace.id === activeWorkspaceId} index={index} 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..3eef594ac9a 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 @@ -6,6 +6,7 @@ import { cn } from "@superset/ui/utils"; import { useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; +import { LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useDeleteWorkspace, @@ -14,6 +15,7 @@ import { } from "renderer/react-query/workspaces"; import { useCloseSettings } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { BranchSwitcher } from "./BranchSwitcher"; import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; import { useWorkspaceRename } from "./useWorkspaceRename"; import { WorkspaceItemContextMenu } from "./WorkspaceItemContextMenu"; @@ -24,6 +26,8 @@ interface WorkspaceItemProps { id: string; projectId: string; worktreePath: string; + workspaceType?: "worktree" | "branch"; + branch?: string; title: string; isActive: boolean; index: number; @@ -36,6 +40,8 @@ export function WorkspaceItem({ id, projectId, worktreePath, + workspaceType = "worktree", + branch, title, isActive, index, @@ -43,6 +49,7 @@ export function WorkspaceItem({ onMouseEnter, onMouseLeave, }: WorkspaceItemProps) { + const isBranchWorkspace = workspaceType === "branch"; const setActive = useSetActiveWorkspace(); const reorderWorkspaces = useReorderWorkspaces(); const deleteWorkspace = useDeleteWorkspace(); @@ -66,6 +73,29 @@ export function WorkspaceItem({ // 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 (isBranchWorkspace) { + 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 && @@ -154,6 +184,8 @@ export function WorkspaceItem({ worktreePath={worktreePath} workspaceAlias={title} onRename={rename.startRename} + canRename={!isBranchWorkspace} + showHoverCard={!isBranchWorkspace} >
{rename.isRenaming ? ( @@ -197,8 +227,47 @@ export function WorkspaceItem({ onMouseDown={(e) => e.stopPropagation()} className="flex-1 min-w-0 px-1 py-0.5" /> + ) : isBranchWorkspace ? ( +
+ + +
+
+ +
+ + {title} + +
+
+ +

+ Main repository +
+ + Switch branches without creating worktrees + +

+
+
+ {branch && ( + + )} +
) : ( <> + - Delete workspace + {workspaceType === "branch" + ? "Close workspace" + : "Delete workspace"}
@@ -249,6 +324,7 @@ export function WorkspaceItem({ diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx index a3aca09fa14..142b12651dc 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx @@ -20,6 +20,8 @@ interface WorkspaceItemContextMenuProps { worktreePath: string; workspaceAlias?: string; onRename: () => void; + canRename?: boolean; + showHoverCard?: boolean; } export function WorkspaceItemContextMenu({ @@ -28,6 +30,8 @@ export function WorkspaceItemContextMenu({ worktreePath, workspaceAlias, onRename, + canRename = true, + showHoverCard = true, }: WorkspaceItemContextMenuProps) { const openInFinder = trpc.external.openInFinder.useMutation(); @@ -37,6 +41,26 @@ export function WorkspaceItemContextMenu({ } }; + // For branch workspaces, just show context menu without hover card + if (!showHoverCard) { + return ( + + {children} + + {canRename && ( + <> + Rename + + + )} + + Open in Finder + + + + ); + } + return ( @@ -44,8 +68,12 @@ export function WorkspaceItemContextMenu({ {children} - Rename - + {canRename && ( + <> + Rename + + + )} Open in Finder diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx index 61f72a19538..b4af9ac6b0b 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx @@ -1,7 +1,10 @@ import { Fragment, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; +import { + useCreateBranchWorkspace, + useSetActiveWorkspace, +} from "renderer/react-query/workspaces"; import { useCurrentView, useIsSettingsTabOpen, @@ -20,6 +23,7 @@ export function WorkspacesTabs() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id || null; const setActiveWorkspace = useSetActiveWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); const currentView = useCurrentView(); const isSettingsTabOpen = useIsSettingsTabOpen(); const isSettingsActive = currentView === "settings"; @@ -32,6 +36,46 @@ export function WorkspacesTabs() { null, ); + // Track projects we've attempted to create workspaces for (persists across renders) + // Using ref to avoid re-triggering the effect + const attemptedProjectsRef = useRef>(new Set()); + const [isCreating, setIsCreating] = useState(false); + + // Auto-create main workspace for new projects (one-time per project) + // This only runs for projects we haven't attempted yet + useEffect(() => { + if (isCreating) return; + + for (const group of groups) { + const projectId = group.project.id; + const hasMainWorkspace = group.workspaces.some( + (w) => w.type === "branch", + ); + + // Skip if already has main workspace or we've already attempted this project + if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { + continue; + } + + // Mark as attempted before creating (prevents retries) + attemptedProjectsRef.current.add(projectId); + setIsCreating(true); + + // Auto-create fails silently - this is a background convenience feature + // Users can manually create the workspace via the dropdown if needed + createBranchWorkspace.mutate( + { projectId }, + { + onSettled: () => { + setIsCreating(false); + }, + }, + ); + // Only create one at a time + break; + } + }, [groups, isCreating, createBranchWorkspace.mutate]); + // Flatten workspaces for keyboard navigation const allWorkspaces = groups.flatMap((group) => group.workspaces); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 695ba38a299..c0d4683e3df 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -82,6 +82,30 @@ export const useTabsStore = create()( // Tab operations addTab: (workspaceId, options?: CreatePaneOptions) => { const state = get(); + + // Idempotency check: if a tab already exists for this workspace, just activate it + const existingTab = state.tabs.find( + (t) => t.workspaceId === workspaceId, + ); + if (existingTab) { + const paneId = + state.focusedPaneIds[existingTab.id] ?? + getFirstPaneId(existingTab.layout); + + set({ + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: existingTab.id, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [existingTab.id]: paneId, + }, + }); + + return { tabId: existingTab.id, paneId }; + } + const { tab, pane } = createTabWithPane( workspaceId, state.tabs,