Skip to content

Commit 50e0ebc

Browse files
committed
feat(desktop): add permanent worktree projects
1 parent 55ee6e5 commit 50e0ebc

9 files changed

Lines changed: 303 additions & 10 deletions

File tree

apps/electron/src/renderer/components/app-shell/WorkspaceProjectTree.tsx

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as React from "react"
22
import { useTranslation } from "react-i18next"
3+
// eslint-disable-next-line import/no-internal-modules
34
import { AnimatePresence } from "motion/react"
45
import { useSetAtom } from "jotai"
5-
import { ChevronDown, ChevronRight, Cloud, ExternalLink, Flag, Folder, FolderPlus, MessageSquare, Pencil, Pin, PinOff, Trash2 } from "lucide-react"
6+
import { ChevronDown, ChevronRight, Cloud, ExternalLink, Flag, Folder, FolderPlus, GitBranch, MessageSquare, Pencil, Pin, PinOff, Trash2 } from "lucide-react"
67
import { toast } from "sonner"
78

89
import { cn } from "@/lib/utils"
@@ -20,6 +21,16 @@ import {
2021
StyledContextMenuItem,
2122
StyledContextMenuSeparator,
2223
} from "@/components/ui/styled-context-menu"
24+
import { Button } from "@/components/ui/button"
25+
import {
26+
Dialog,
27+
DialogContent,
28+
DialogDescription,
29+
DialogFooter,
30+
DialogHeader,
31+
DialogTitle,
32+
} from "@/components/ui/dialog"
33+
import { Input } from "@/components/ui/input"
2334
import { ContextMenuProvider } from "@/components/ui/menu-context"
2435
import { RenameDialog } from "@/components/ui/rename-dialog"
2536
import { SessionMenu } from "./SessionMenu"
@@ -83,6 +94,11 @@ interface ProjectSessionMenuConfig {
8394

8495
const PROJECT_SESSION_PREVIEW_LIMIT = 5
8596

97+
function getDefaultWorktreeBranchName(workspace: Workspace, t: (key: string, defaultValue: string) => string): string {
98+
const name = getWorkspaceDisplayName(workspace, t).trim()
99+
return `${name || "worktree"}_2`
100+
}
101+
86102
function WorkspaceHeader({
87103
workspace,
88104
displayName,
@@ -97,12 +113,14 @@ function WorkspaceHeader({
97113
renameLabel,
98114
pinLabel,
99115
unpinLabel,
116+
createWorktreeLabel,
100117
removeLabel,
101118
onToggleCollapsed,
102119
onNewSession,
103120
onOpenInNewWindow,
104121
onRename,
105122
onTogglePinned,
123+
onCreateWorktree,
106124
onRemove,
107125
}: {
108126
workspace: Workspace
@@ -118,12 +136,14 @@ function WorkspaceHeader({
118136
renameLabel: string
119137
pinLabel: string
120138
unpinLabel: string
139+
createWorktreeLabel: string
121140
removeLabel: string
122141
onToggleCollapsed: () => void
123142
onNewSession: () => void
124143
onOpenInNewWindow: () => void
125144
onRename: () => void
126145
onTogglePinned: () => void
146+
onCreateWorktree: () => void
127147
onRemove: () => void
128148
}) {
129149
const header = (
@@ -208,6 +228,12 @@ function WorkspaceHeader({
208228
{isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
209229
<span className="flex-1">{isPinned ? unpinLabel : pinLabel}</span>
210230
</StyledContextMenuItem>
231+
{!workspace.remoteServer && (
232+
<StyledContextMenuItem onClick={onCreateWorktree}>
233+
<GitBranch className="h-3.5 w-3.5" />
234+
<span className="flex-1">{createWorktreeLabel}</span>
235+
</StyledContextMenuItem>
236+
)}
211237
<StyledContextMenuSeparator />
212238
</>
213239
)}
@@ -391,6 +417,10 @@ export function WorkspaceProjectTree({
391417
const [collapsedWorkspaceIds, setCollapsedWorkspaceIds] = React.useState<Set<string>>(() => new Set())
392418
const [expandedWorkspaceSessionIds, setExpandedWorkspaceSessionIds] = React.useState<Set<string>>(() => new Set())
393419
const [optimisticWorkspaceOrder, setOptimisticWorkspaceOrder] = React.useState<string[] | null>(null)
420+
const [createWorktreeDialogOpen, setCreateWorktreeDialogOpen] = React.useState(false)
421+
const [createWorktreeWorkspaceId, setCreateWorktreeWorkspaceId] = React.useState<string | null>(null)
422+
const [createWorktreeBranchName, setCreateWorktreeBranchName] = React.useState("")
423+
const [creatingWorktree, setCreatingWorktree] = React.useState(false)
394424
const hasRemoteWorkspaces = React.useMemo(() => workspaces.some(workspace => workspace.remoteServer), [workspaces])
395425
const workspaceOrderKey = React.useMemo(() => workspaces.map(workspace => workspace.id).join("\0"), [workspaces])
396426

@@ -459,6 +489,46 @@ export function WorkspaceProjectTree({
459489
void onSelectWorkspace(workspace.id)
460490
}, [onSelectWorkspace, onWorkspaceCreated, setFullscreenOverlayOpen, t])
461491

492+
const handleCreateWorktreeClick = React.useCallback((workspace: Workspace) => {
493+
if (isProtectedWorkspace(workspace) || workspace.remoteServer) return
494+
setCreateWorktreeWorkspaceId(workspace.id)
495+
setCreateWorktreeBranchName(getDefaultWorktreeBranchName(workspace, t))
496+
requestAnimationFrame(() => {
497+
setCreateWorktreeDialogOpen(true)
498+
})
499+
}, [t])
500+
501+
const handleCreateWorktreeDialogOpenChange = React.useCallback((open: boolean) => {
502+
setCreateWorktreeDialogOpen(open)
503+
if (!open) {
504+
setCreateWorktreeWorkspaceId(null)
505+
setCreateWorktreeBranchName("")
506+
}
507+
}, [])
508+
509+
const handleCreateWorktreeSubmit = React.useCallback(async () => {
510+
const branchName = createWorktreeBranchName.trim()
511+
if (!createWorktreeWorkspaceId || !branchName || creatingWorktree) return
512+
513+
setCreatingWorktree(true)
514+
try {
515+
const workspace = await window.electronAPI.createPermanentWorktree(createWorktreeWorkspaceId, branchName)
516+
toast.success(t("toast.createdWorktreeWorkspace", { name: workspace.name }))
517+
setCreateWorktreeDialogOpen(false)
518+
setCreateWorktreeWorkspaceId(null)
519+
setCreateWorktreeBranchName("")
520+
onWorkspaceCreated?.(workspace)
521+
void onSelectWorkspace(workspace.id)
522+
} catch (error) {
523+
const message = error instanceof Error ? error.message : t("toast.unknownError")
524+
toast.error(t("toast.failedToCreateWorktreeWorkspace"), {
525+
description: message,
526+
})
527+
} finally {
528+
setCreatingWorktree(false)
529+
}
530+
}, [createWorktreeBranchName, createWorktreeWorkspaceId, creatingWorktree, onSelectWorkspace, onWorkspaceCreated, t])
531+
462532
const handleRenameClick = React.useCallback((sessionId: string, currentName: string) => {
463533
setRenameSessionId(sessionId)
464534
setRenameName(currentName)
@@ -722,12 +792,14 @@ export function WorkspaceProjectTree({
722792
renameLabel={t("common.rename")}
723793
pinLabel={t("workspace.pinWorkspace")}
724794
unpinLabel={t("workspace.unpinWorkspace")}
795+
createWorktreeLabel={t("workspace.createPermanentWorktree")}
725796
removeLabel={t("workspace.removeWorkspace")}
726797
onToggleCollapsed={() => toggleWorkspaceCollapsed(workspace.id)}
727798
onNewSession={() => handleNewProjectSession(workspace.id)}
728799
onOpenInNewWindow={() => void onSelectWorkspace(workspace.id, true)}
729800
onRename={() => handleWorkspaceRenameClick(workspace)}
730801
onTogglePinned={() => void handleToggleWorkspacePinned(workspace)}
802+
onCreateWorktree={() => handleCreateWorktreeClick(workspace)}
731803
onRemove={() => void handleRemoveWorkspace(workspace)}
732804
/>
733805
{!isSorting && !isCollapsed && sessions.length > 0 ? (
@@ -810,6 +882,52 @@ export function WorkspaceProjectTree({
810882
)}
811883
</AnimatePresence>
812884

885+
<Dialog open={createWorktreeDialogOpen} onOpenChange={handleCreateWorktreeDialogOpenChange}>
886+
<DialogContent className="sm:max-w-[520px]">
887+
<form
888+
className="grid gap-5"
889+
onSubmit={(event) => {
890+
event.preventDefault()
891+
void handleCreateWorktreeSubmit()
892+
}}
893+
>
894+
<DialogHeader>
895+
<DialogTitle className="text-2xl leading-tight">
896+
{t("workspace.createWorktreeDialogTitle")}
897+
</DialogTitle>
898+
<DialogDescription className="text-base leading-6">
899+
{t("workspace.createWorktreeDialogDescription")}
900+
</DialogDescription>
901+
</DialogHeader>
902+
<Input
903+
autoFocus
904+
value={createWorktreeBranchName}
905+
onChange={(event) => setCreateWorktreeBranchName(event.target.value)}
906+
disabled={creatingWorktree}
907+
aria-label={t("workspace.branchNameLabel")}
908+
placeholder={t("workspace.branchNamePlaceholder")}
909+
className="h-12 text-base"
910+
/>
911+
<DialogFooter>
912+
<Button
913+
type="button"
914+
variant="outline"
915+
onClick={() => handleCreateWorktreeDialogOpenChange(false)}
916+
disabled={creatingWorktree}
917+
>
918+
{t("common.cancel")}
919+
</Button>
920+
<Button
921+
type="submit"
922+
disabled={!createWorktreeBranchName.trim() || creatingWorktree}
923+
>
924+
{creatingWorktree ? t("workspace.creating") : t("common.create")}
925+
</Button>
926+
</DialogFooter>
927+
</form>
928+
</DialogContent>
929+
</Dialog>
930+
813931
<div className="min-h-0 flex-1 overflow-y-auto pb-3 mask-fade-bottom">
814932
<div className="flex shrink-0 items-center justify-between px-3 pb-2 pt-1">
815933
<span className="text-[12px] font-semibold text-muted-foreground">

0 commit comments

Comments
 (0)