diff --git a/apps/desktop/docs/BRANCH_WORKSPACE_IMPROVEMENTS.md b/apps/desktop/docs/BRANCH_WORKSPACE_IMPROVEMENTS.md new file mode 100644 index 0000000000..3725583505 --- /dev/null +++ b/apps/desktop/docs/BRANCH_WORKSPACE_IMPROVEMENTS.md @@ -0,0 +1,66 @@ +# Branch Workspace Improvements + +Potential improvements identified during code review. Create Linear tickets as needed. + +## Medium Priority + +### 1. Cache `hasOrigin` at project level +**Location**: `workspaces.ts:460`, `projects.ts` + +Currently `hasOriginRemote()` is called on every `getActive` poll until base branch detection completes. Cache this at the project level in the database to avoid repeated git calls. + +### 2. Race condition in worktree creation +**Location**: `workspaces.ts:78-86` + +The branch existence check and the actual worktree creation are not atomic. If a branch is deleted between the check and use, it fails with a generic git error. Consider: +- Wrapping in a retry with exponential backoff +- Catching the specific git error and providing a clear message + +### 3. `getDefaultBranch` for local repos uses current branch +**Location**: `git.ts:287-306` + +For repos without a remote, `getDefaultBranch` returns the current branch. If the user is on a feature branch, that becomes the "default" for new worktrees, which may not be intended. Consider always preferring `main`/`master` if they exist locally. + +### 4. Disable Create button when no branches available +**Location**: `NewWorkspaceModal.tsx` + +If `getBranches` returns an empty array (e.g., new repo with no commits), the Create button is still enabled. Should be disabled with a helpful message. + +## Low Priority + +### 5. Rename `fetch` parameter to `gitFetch` +**Location**: `workspaces.ts` - `getBranches` procedure + +The `fetch` parameter controls whether to run `git fetch`, but could be confused with React Query's fetch behavior. Rename to `gitFetch` or `refreshRemote` for clarity. + +### 6. Extract magic numbers to constants +**Location**: `WorkspaceHeader.tsx` + +Values like `max-w-[480px]`, `h-[22px]`, `max-w-[180px]` should be design tokens or named constants. + +### 7. Lazy compute branch arrays +**Location**: `projects.ts:185-196` + +`localBranches` and `remoteBranches` arrays are always built but only used in the fallback path. Could be computed lazily for minor perf improvement. + +### 8. Add loading skeleton to base branch picker +**Location**: `NewWorkspaceModal.tsx` + +Currently shows a disabled button while branches load. A skeleton would feel more polished. + +### 9. Optimistic UI for branch switching +**Location**: `BranchSwitcher.tsx` + +The `switchBranchWorkspace` mutation could use optimistic updates to feel snappier. + +## Testing + +### 10. Add unit tests for git utilities +**Location**: `git.ts` + +Functions like `getDefaultBranch`, `branchExistsOnRemote`, `detectBaseBranch`, and `checkBranchCheckoutSafety` have complex logic that would benefit from unit tests with mocked `simple-git`. + +Priority candidates: +- `getDefaultBranch` - many code paths +- `detectBaseBranch` - merge-base logic +- `checkBranchCheckoutSafety` - safety checks before checkout diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index ed866c87ac..33383c5cd2 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -151,6 +151,111 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); }), + getBranches: publicProcedure + .input(z.object({ projectId: z.string() })) + .query( + async ({ + input, + }): Promise<{ + branches: Array<{ name: string; lastCommitDate: number }>; + defaultBranch: string; + }> => { + const project = db.data.projects.find( + (p) => p.id === input.projectId, + ); + if (!project) { + throw new Error(`Project ${input.projectId} not found`); + } + + const git = simpleGit(project.mainRepoPath); + + // Check if origin remote exists + let hasOrigin = false; + try { + const remotes = await git.getRemotes(); + hasOrigin = remotes.some((r) => r.name === "origin"); + } catch { + // If we can't get remotes, assume no origin + } + + // Get all branches (local and remote) + const branchSummary = await git.branch(["-a"]); + + const localBranches: string[] = []; + const remoteBranches: string[] = []; + + for (const name of Object.keys(branchSummary.branches)) { + if (name.startsWith("remotes/origin/")) { + if (name === "remotes/origin/HEAD") continue; + const remoteName = name.replace("remotes/origin/", ""); + remoteBranches.push(remoteName); + } else { + localBranches.push(name); + } + } + + // Get branch dates for sorting + let branches: Array<{ name: string; lastCommitDate: number }> = []; + + // Determine which ref pattern to use based on whether origin exists + const refPattern = hasOrigin ? "refs/remotes/origin/" : "refs/heads/"; + + try { + const branchInfo = await git.raw([ + "for-each-ref", + "--sort=-committerdate", + "--format=%(refname:short) %(committerdate:unix)", + refPattern, + ]); + + const seen = new Set(); + for (const line of branchInfo.trim().split("\n")) { + if (!line) continue; + const lastSpaceIdx = line.lastIndexOf(" "); + let branch = line.substring(0, lastSpaceIdx); + const timestamp = Number.parseInt( + line.substring(lastSpaceIdx + 1), + 10, + ); + + // Normalize remote branch names + if (branch.startsWith("origin/")) { + branch = branch.replace("origin/", ""); + } + + // Skip duplicates and HEAD + if (seen.has(branch)) continue; + if (branch === "HEAD") continue; + seen.add(branch); + + branches.push({ + name: branch, + lastCommitDate: timestamp * 1000, + }); + } + } catch { + // Fallback: just list branches without dates + const branchList = hasOrigin ? remoteBranches : localBranches; + branches = branchList.map((name) => ({ name, lastCommitDate: 0 })); + } + + // Determine default branch + let defaultBranch = project.defaultBranch; + if (!defaultBranch) { + defaultBranch = await getDefaultBranch(project.mainRepoPath); + } + + // Sort: default branch first, then by date + branches.sort((a, b) => { + if (a.name === defaultBranch) return -1; + if (b.name === defaultBranch) return 1; + return b.lastCommitDate - a.lastCommitDate; + }); + + return { branches, defaultBranch }; + }, + ), + openNew: publicProcedure.mutation(async (): Promise => { const window = getWindow(); if (!window) { 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 f5cba4e8ac..91e3f2295c 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -248,33 +248,63 @@ export async function hasOriginRemote(mainRepoPath: string): Promise { export async function getDefaultBranch(mainRepoPath: string): Promise { const git = simpleGit(mainRepoPath); - try { - const headRef = await git.raw(["symbolic-ref", "refs/remotes/origin/HEAD"]); - const match = headRef.trim().match(/refs\/remotes\/origin\/(.+)/); - if (match) return match[1]; - } catch {} + // First check if we have an origin remote + const hasRemote = await hasOriginRemote(mainRepoPath); - try { - const branches = await git.branch(["-r"]); - const remoteBranches = branches.all.map((b) => b.replace("origin/", "")); + if (hasRemote) { + // Try to get the default branch from origin/HEAD + try { + const headRef = await git.raw([ + "symbolic-ref", + "refs/remotes/origin/HEAD", + ]); + const match = headRef.trim().match(/refs\/remotes\/origin\/(.+)/); + if (match) return match[1]; + } catch {} - for (const candidate of ["main", "master", "develop", "trunk"]) { - if (remoteBranches.includes(candidate)) { - return candidate; + // Check remote branches for common default branch names + try { + const branches = await git.branch(["-r"]); + const remoteBranches = branches.all.map((b) => b.replace("origin/", "")); + + for (const candidate of ["main", "master", "develop", "trunk"]) { + if (remoteBranches.includes(candidate)) { + return candidate; + } } - } - } catch {} + } catch {} - try { - const hasRemote = await hasOriginRemote(mainRepoPath); - if (hasRemote) { + // Try ls-remote as last resort for remote repos + try { const result = await git.raw(["ls-remote", "--symref", "origin", "HEAD"]); const symrefMatch = result.match(/ref:\s+refs\/heads\/(.+?)\tHEAD/); if (symrefMatch) { return symrefMatch[1]; } - } - } catch {} + } catch {} + } else { + // No remote - use the current local branch or check for common branch names + try { + const currentBranch = await getCurrentBranch(mainRepoPath); + if (currentBranch) { + return currentBranch; + } + } catch {} + + // Fallback: check for common default branch names locally + try { + const localBranches = await git.branchLocal(); + for (const candidate of ["main", "master", "develop", "trunk"]) { + if (localBranches.all.includes(candidate)) { + return candidate; + } + } + // If we have any local branches, use the first one + if (localBranches.all.length > 0) { + return localBranches.all[0]; + } + } catch {} + } return "main"; } @@ -359,6 +389,58 @@ export async function branchExistsOnRemote( } } +/** + * Detect which branch a worktree was likely based off of. + * Uses merge-base to find the closest common ancestor with candidate base branches. + */ +export async function detectBaseBranch( + worktreePath: string, + currentBranch: string, + defaultBranch: string, +): Promise { + const git = simpleGit(worktreePath); + + // Candidate base branches to check, in priority order + const candidates = [ + defaultBranch, + "main", + "master", + "develop", + "development", + ].filter((b, i, arr) => arr.indexOf(b) === i); // dedupe + + let bestCandidate: string | null = null; + let bestAheadCount = Number.POSITIVE_INFINITY; + + for (const candidate of candidates) { + // Skip if this is the current branch + if (candidate === currentBranch) continue; + + try { + // Check if the remote branch exists + const remoteBranch = `origin/${candidate}`; + await git.raw(["rev-parse", "--verify", remoteBranch]); + + // Count how many commits the current branch is ahead of the merge-base + // The branch with the fewest commits "ahead" is likely the base + const mergeBase = await git.raw(["merge-base", "HEAD", remoteBranch]); + const aheadCount = await git.raw([ + "rev-list", + "--count", + `${mergeBase.trim()}..HEAD`, + ]); + + const count = Number.parseInt(aheadCount.trim(), 10); + if (count < bestAheadCount) { + bestAheadCount = count; + bestCandidate = candidate; + } + } catch {} + } + + return bestCandidate; +} + /** * Lists all local and remote branches in a repository * @param repoPath - Path to the repository diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 8dcf444b72..e4bbd5bb0c 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -7,8 +7,10 @@ import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { + branchExistsOnRemote, checkNeedsRebase, createWorktree, + detectBaseBranch, fetchDefaultBranch, generateBranchName, getCurrentBranch, @@ -34,6 +36,7 @@ export const createWorkspacesRouter = () => { projectId: z.string(), name: z.string().optional(), branchName: z.string().optional(), + baseBranch: z.string().optional(), }), ) .mutation(async ({ input }) => { @@ -63,22 +66,36 @@ export const createWorkspacesRouter = () => { }); } + // Use provided baseBranch or fall back to default + const targetBranch = input.baseBranch || defaultBranch; + // Check if this repo has a remote origin const hasRemote = await hasOriginRemote(project.mainRepoPath); // Determine the start point for the worktree let startPoint: string; if (hasRemote) { - // Fetch default branch to ensure we're branching from latest (best-effort) + // Verify the branch exists on remote before attempting to use it + const existsOnRemote = await branchExistsOnRemote( + project.mainRepoPath, + targetBranch, + ); + if (!existsOnRemote) { + throw new Error( + `Branch "${targetBranch}" does not exist on origin. Please select a different base branch.`, + ); + } + + // Fetch the target branch to ensure we're branching from latest (best-effort) try { - await fetchDefaultBranch(project.mainRepoPath, defaultBranch); + await fetchDefaultBranch(project.mainRepoPath, targetBranch); } catch { - // Silently continue - branch still exists locally, just might be stale + // Silently continue - branch exists on remote, just couldn't fetch } - startPoint = `origin/${defaultBranch}`; + startPoint = `origin/${targetBranch}`; } else { - // For local-only repos, use the local default branch - startPoint = defaultBranch; + // For local-only repos, use the local branch + startPoint = targetBranch; } await createWorktree( @@ -93,10 +110,11 @@ export const createWorkspacesRouter = () => { projectId: input.projectId, path: worktreePath, branch, + baseBranch: targetBranch, createdAt: Date.now(), gitStatus: { branch, - needsRebase: false, // Fresh off main, doesn't need rebase + needsRebase: false, // Fresh off base branch, doesn't need rebase lastRefreshed: Date.now(), }, }; @@ -423,7 +441,7 @@ export const createWorkspacesRouter = () => { ); }), - getActive: publicProcedure.query(() => { + getActive: publicProcedure.query(async () => { const { lastActiveWorkspaceId } = db.data.settings; if (!lastActiveWorkspaceId) { @@ -446,6 +464,50 @@ export const createWorkspacesRouter = () => { ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) : null; + // Detect and persist base branch for existing worktrees that don't have it + // We use undefined to mean "not yet attempted" and null to mean "attempted but not found" + let baseBranch = worktree?.baseBranch; + if (worktree && baseBranch === undefined && project) { + // Only attempt detection if there's a remote origin + const hasRemote = await hasOriginRemote(project.mainRepoPath); + if (hasRemote) { + try { + const defaultBranch = project.defaultBranch || "main"; + const detected = await detectBaseBranch( + worktree.path, + worktree.branch, + defaultBranch, + ); + if (detected) { + baseBranch = detected; + } + // Persist the result (detected branch or null sentinel) + await db.update((data) => { + const wt = data.worktrees.find((w) => w.id === worktree.id); + if (wt) { + wt.baseBranch = detected ?? null; + } + }); + } catch { + // Detection failed, persist null to avoid retrying + await db.update((data) => { + const wt = data.worktrees.find((w) => w.id === worktree.id); + if (wt) { + wt.baseBranch = null; + } + }); + } + } else { + // No remote - persist null to avoid retrying + await db.update((data) => { + const wt = data.worktrees.find((w) => w.id === worktree.id); + if (wt) { + wt.baseBranch = null; + } + }); + } + } + return { ...workspace, worktreePath: getWorkspacePath(workspace) ?? "", @@ -457,7 +519,11 @@ export const createWorkspacesRouter = () => { } : null, worktree: worktree - ? { branch: worktree.branch, gitStatus: worktree.gitStatus } + ? { + branch: worktree.branch, + baseBranch, + gitStatus: worktree.gitStatus, + } : null, }; }), diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index f69d3b96fe..3a3cb06114 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -45,6 +45,7 @@ export interface Worktree { projectId: string; path: string; branch: string; + baseBranch?: string | null; // The branch this worktree was created from (null = detection attempted, not found) createdAt: number; gitStatus?: GitStatus; githubStatus?: GitHubStatus; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 224b9f3ba7..d072a363c1 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -1,4 +1,11 @@ import { Button } from "@superset/ui/button"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; import { Dialog, DialogContent, @@ -7,6 +14,7 @@ import { DialogTitle, } from "@superset/ui/dialog"; import { Input } from "@superset/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Select, SelectContent, @@ -15,8 +23,10 @@ import { SelectValue, } from "@superset/ui/select"; import { toast } from "@superset/ui/sonner"; -import { useEffect, useState } from "react"; -import { HiPlus } from "react-icons/hi2"; +import { useEffect, useMemo, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiCheck, HiChevronUpDown, HiPlus } from "react-icons/hi2"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { trpc } from "renderer/lib/trpc"; import { useOpenNew } from "renderer/react-query/projects"; import { @@ -54,15 +64,36 @@ export function NewWorkspaceModal() { const [branchName, setBranchName] = useState(""); const [branchNameEdited, setBranchNameEdited] = useState(false); const [mode, setMode] = useState("new"); + const [baseBranch, setBaseBranch] = useState(null); + const [baseBranchOpen, setBaseBranchOpen] = useState(false); + const [branchSearch, setBranchSearch] = useState(""); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); + const { + data: branchData, + isLoading: isBranchesLoading, + isError: isBranchesError, + } = trpc.projects.getBranches.useQuery( + { projectId: selectedProjectId ?? "" }, + { enabled: !!selectedProjectId }, + ); const createWorkspace = useCreateWorkspace(); const createBranchWorkspace = useCreateBranchWorkspace(); const openNew = useOpenNew(); const currentProjectId = activeWorkspace?.projectId; + // Filter branches based on search + const filteredBranches = useMemo(() => { + if (!branchData?.branches) return []; + if (!branchSearch) return branchData.branches; + const searchLower = branchSearch.toLowerCase(); + return branchData.branches.filter((b) => + b.name.toLowerCase().includes(searchLower), + ); + }, [branchData?.branches, branchSearch]); + // Auto-select current project when modal opens useEffect(() => { if (isOpen && currentProjectId && !selectedProjectId) { @@ -70,6 +101,19 @@ export function NewWorkspaceModal() { } }, [isOpen, currentProjectId, selectedProjectId]); + // Auto-select default branch when branches load + useEffect(() => { + if (branchData?.defaultBranch && !baseBranch) { + setBaseBranch(branchData.defaultBranch); + } + }, [branchData?.defaultBranch, baseBranch]); + + // Reset base branch when project changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset when project changes + useEffect(() => { + setBaseBranch(null); + }, [selectedProjectId]); + // Auto-generate branch name from title (unless manually edited) useEffect(() => { if (!branchNameEdited) { @@ -83,6 +127,8 @@ export function NewWorkspaceModal() { setBranchName(""); setBranchNameEdited(false); setMode("new"); + setBaseBranch(null); + setBranchSearch(""); }; const handleClose = () => { @@ -106,6 +152,7 @@ export function NewWorkspaceModal() { projectId: selectedProjectId, name: workspaceName, branchName: customBranchName, + baseBranch: baseBranch || undefined, }), { loading: "Creating workspace...", @@ -244,7 +291,7 @@ export function NewWorkspaceModal() { htmlFor="branch" className="text-xs text-muted-foreground" > - Branch + Branch Name handleBranchNameChange(e.target.value)} /> + +
+ + Base branch + + {isBranchesError ? ( +
+ Failed to load branches +
+ ) : ( + + + + + + + + + No branches found + {filteredBranches.map((branch) => ( + { + setBaseBranch(branch.name); + setBaseBranchOpen(false); + setBranchSearch(""); + }} + className="flex items-center justify-between" + > + + + + {branch.name} + + {branch.name === + branchData?.defaultBranch && ( + + default + + )} + + + {branch.lastCommitDate > 0 && ( + + {formatRelativeTime( + branch.lastCommitDate, + )} + + )} + {baseBranch === branch.name && ( + + )} + + + ))} + + + + + )} +

+ Your new branch will be created from this branch +

+
) : ( Create Workspace diff --git a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx index 96dcdad6ea..0d65eb6d13 100644 --- a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx +++ b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx @@ -44,7 +44,7 @@ interface AppOption { icon: string; } -const APP_OPTIONS: AppOption[] = [ +export const APP_OPTIONS: AppOption[] = [ { id: "finder", label: "Finder", icon: finderIcon }, { id: "cursor", label: "Cursor", icon: cursorIcon }, { id: "vscode", label: "VS Code", icon: vscodeIcon }, @@ -55,7 +55,7 @@ const APP_OPTIONS: AppOption[] = [ { id: "terminal", label: "Terminal", icon: terminalIcon }, ]; -const JETBRAINS_OPTIONS: AppOption[] = [ +export const JETBRAINS_OPTIONS: AppOption[] = [ { id: "intellij", label: "IntelliJ IDEA", icon: intellijIcon }, { id: "webstorm", label: "WebStorm", icon: webstormIcon }, { id: "pycharm", label: "PyCharm", icon: pycharmIcon }, diff --git a/apps/desktop/src/renderer/components/OpenInButton/index.ts b/apps/desktop/src/renderer/components/OpenInButton/index.ts index 5c3551ebe0..690b59fc77 100644 --- a/apps/desktop/src/renderer/components/OpenInButton/index.ts +++ b/apps/desktop/src/renderer/components/OpenInButton/index.ts @@ -1,2 +1,7 @@ export type { OpenInButtonProps } from "./OpenInButton"; -export { getAppOption, OpenInButton } from "./OpenInButton"; +export { + APP_OPTIONS, + getAppOption, + JETBRAINS_OPTIONS, + OpenInButton, +} from "./OpenInButton"; 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 3eef594ac9..089d3487e8 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 @@ -207,7 +207,8 @@ export function WorkspaceItem({ onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={cn( - "flex items-center gap-1.5 rounded-t-md transition-all w-full shrink-0 pr-6 pl-3 h-[80%]", + "flex items-center gap-1.5 rounded-t-md transition-all w-full shrink-0 pl-3 h-[80%]", + isBranchWorkspace ? "pr-2" : "pr-6", isActive ? "text-foreground bg-tertiary-active" : "text-muted-foreground hover:text-foreground hover:bg-tertiary/30", @@ -289,35 +290,32 @@ export function WorkspaceItem({ )} - - - - - - {workspaceType === "branch" - ? "Close workspace" - : "Delete workspace"} - - + {/* Only show close button for worktree workspaces */} + {!isBranchWorkspace && ( + + + + + + Delete workspace + + + )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index 7511587610..3346634281 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -10,7 +10,7 @@ export function Sidebar() { const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; return ( -