From e7a37798127e764bee6143c9ff470a5ffb5fe749 Mon Sep 17 00:00:00 2001 From: z3thon Date: Tue, 17 Mar 2026 17:00:41 +0700 Subject: [PATCH 01/15] feat: add per-project worktree mode setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `worktreeMode` setting (global + per-project override) with three values: "always" (current behavior), "optional" (user chooses per workspace via a toggle in the New Workspace modal), and "disabled" (never create worktrees — open directly in main repo). - Add WorktreeMode type and WORKTREE_MODES constant to local-db schema - Add worktree_mode column to both settings and projects tables - Add getWorktreeMode/setWorktreeMode tRPC procedures for global setting - Add worktreeMode to project update procedure for per-project override - Modify workspace create procedure to respect effective worktree mode - Add Worktree Mode dropdown to global Git & Worktrees settings page - Add per-project Worktree Mode override in project settings - Register GIT_WORKTREE_MODE in settings search - Show worktree toggle in New Workspace modal when mode is "optional" - Hide branch prefix/worktree location settings when mode is "disabled" --- .../src/lib/trpc/routers/projects/projects.ts | 5 + .../src/lib/trpc/routers/settings/index.ts | 21 ++++ .../routers/workspaces/procedures/create.ts | 83 +++++++++++++- .../components/PromptGroup/PromptGroup.tsx | 40 ++++++- .../components/GitSettings/GitSettings.tsx | 72 +++++++++++- .../ProjectSettings/ProjectSettings.tsx | 105 ++++++++++++++---- .../utils/settings-search/settings-search.ts | 19 ++++ .../drizzle/0037_add_worktree_mode.sql | 2 + packages/local-db/drizzle/meta/_journal.json | 7 ++ packages/local-db/src/schema/schema.ts | 3 + packages/local-db/src/schema/zod.ts | 7 ++ 11 files changed, 335 insertions(+), 29 deletions(-) create mode 100644 packages/local-db/drizzle/0037_add_worktree_mode.sql diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 17f671a1944..ddc1b507dd4 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -7,6 +7,7 @@ import { projects, type SelectProject, settings, + WORKTREE_MODES, workspaceSections, workspaces, worktrees, @@ -1230,6 +1231,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { worktreeBaseDir: z.string().nullable().optional(), hideImage: z.boolean().optional(), defaultApp: z.enum(EXTERNAL_APPS).nullable().optional(), + worktreeMode: z.enum(WORKTREE_MODES).nullable().optional(), }), }), ) @@ -1268,6 +1270,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { ...(input.patch.defaultApp !== undefined && { defaultApp: input.patch.defaultApp, }), + ...(input.patch.worktreeMode !== undefined && { + worktreeMode: input.patch.worktreeMode, + }), lastOpenedAt: Date.now(), }) .where(eq(projects.id, input.id)) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 26ffdc0cc04..58db837db3f 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -9,6 +9,7 @@ import { settings, TERMINAL_LINK_BEHAVIORS, type TerminalPreset, + WORKTREE_MODES, } from "@superset/local-db"; import { AGENT_PRESET_COMMANDS, @@ -749,6 +750,26 @@ export const createSettingsRouter = () => { return { success: true }; }), + getWorktreeMode: publicProcedure.query(() => { + const row = getSettings(); + return row.worktreeMode ?? "always"; + }), + + setWorktreeMode: publicProcedure + .input(z.object({ mode: z.enum(WORKTREE_MODES) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, worktreeMode: input.mode }) + .onConflictDoUpdate({ + target: settings.id, + set: { worktreeMode: input.mode }, + }) + .run(); + + return { success: true }; + }), + getOpenLinksInApp: publicProcedure.query(() => { const row = getSettings(); return row.openLinksInApp ?? DEFAULT_OPEN_LINKS_IN_APP; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 97285fc73ad..d1faf7e2fdb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -295,6 +295,7 @@ export const createCreateProcedures = () => { baseBranch: z.string().optional(), useExistingBranch: z.boolean().optional(), applyPrefix: z.boolean().optional().default(true), + useWorktree: z.boolean().optional(), }), ) .mutation(async ({ input }) => { @@ -307,6 +308,87 @@ export const createCreateProcedures = () => { throw new Error(`Project ${input.projectId} not found`); } + // Resolve effective worktree mode + const globalSettings = localDb.select().from(settings).get(); + const effectiveWorktreeMode = + project.worktreeMode ?? globalSettings?.worktreeMode ?? "always"; + + // If worktrees are disabled (or user explicitly chose no worktree), + // open directly in the main repo + if ( + effectiveWorktreeMode === "disabled" || + input.useWorktree === false + ) { + const branch = + input.branchName?.trim() || + (await getCurrentBranch(project.mainRepoPath)); + if (!branch) { + throw new Error("Could not determine current branch"); + } + + const existing = getBranchWorkspace(input.projectId); + + if (existing) { + if (existing.branch !== branch) { + localDb + .update(workspaces) + .set({ branch }) + .where(eq(workspaces.id, existing.id)) + .run(); + } + touchWorkspace(existing.id); + setLastActiveWorkspace(existing.id); + return { + workspace: { + ...existing, + branch, + lastOpenedAt: Date.now(), + }, + worktreePath: project.mainRepoPath, + projectId: project.id, + isInitializing: false, + wasExisting: true, + }; + } + + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + type: "branch", + branch, + name: input.name ?? branch, + tabOrder: maxTabOrder + 1, + }) + .onConflictDoNothing() + .returning() + .all(); + + const ws = workspace[0] ?? getBranchWorkspace(input.projectId); + if (!ws) { + throw new Error("Failed to create or find branch workspace"); + } + + setLastActiveWorkspace(ws.id); + activateProject(project); + + track("workspace_created", { + workspace_id: ws.id, + project_id: project.id, + type: "branch", + }); + + return { + workspace: ws, + initialCommands: null, + worktreePath: project.mainRepoPath, + projectId: project.id, + isInitializing: false, + wasExisting: false, + }; + } + let existingBranchName: string | undefined; if (input.useExistingBranch) { existingBranchName = input.branchName?.trim(); @@ -332,7 +414,6 @@ export const createCreateProcedures = () => { let branchPrefix: string | undefined; if (input.applyPrefix) { - const globalSettings = localDb.select().from(settings).get(); const projectOverrides = project.branchPrefixMode != null; const prefixMode = projectOverrides ? project.branchPrefixMode diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx index 314b246558d..a86d5685ef3 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -27,8 +27,10 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; import { cn } from "@superset/ui/utils"; import { AnimatePresence, motion } from "framer-motion"; import { @@ -471,6 +473,12 @@ function PromptGroupInner({ const { data: globalBranchPrefix } = electronTrpc.settings.getBranchPrefix.useQuery(); const { data: gitInfo } = electronTrpc.settings.getGitInfo.useQuery(); + const { data: globalWorktreeMode } = + electronTrpc.settings.getWorktreeMode.useQuery(); + + const effectiveWorktreeMode = + project?.worktreeMode ?? globalWorktreeMode ?? "always"; + const [useWorktreeToggle, setUseWorktreeToggle] = useState(true); const resolvedPrefix = useMemo(() => { const projectOverrides = project?.branchPrefixMode != null; @@ -637,6 +645,12 @@ function PromptGroupInner({ ? sanitizeBranchNameWithMaxLength(branchName.trim()) : branchSlug) || undefined, baseBranch: baseBranch || undefined, + ...(effectiveWorktreeMode === "optional" && { + useWorktree: useWorktreeToggle, + }), + ...(effectiveWorktreeMode === "disabled" && { + useWorktree: false, + }), }, { agentLaunchRequest: launchRequest ?? undefined, @@ -661,10 +675,12 @@ function PromptGroupInner({ convertBlobUrlToDataUrl, createFromPr, createWorkspace, + effectiveWorktreeMode, linkedPR, projectId, runAsyncAction, trimmedPrompt, + useWorktreeToggle, workspaceName, workspaceNameEdited, ]); @@ -903,9 +919,27 @@ function PromptGroupInner({ )} - - {modKey}+↵ to create - +
+ {effectiveWorktreeMode === "optional" && ( +
+ + +
+ )} + + {modKey}+↵ to create + +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx index 39a190cef10..618f9020014 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx @@ -1,4 +1,4 @@ -import type { BranchPrefixMode } from "@superset/local-db"; +import type { BranchPrefixMode, WorktreeMode } from "@superset/local-db"; import { Input } from "@superset/ui/input"; import { Label } from "@superset/ui/label"; import { @@ -23,11 +23,21 @@ import { type SettingItemId, } from "../../../utils/settings-search"; +const WORKTREE_MODE_LABELS: Record = { + always: "Always use worktrees", + optional: "Ask each time", + disabled: "Never use worktrees", +}; + interface GitSettingsProps { visibleItems?: SettingItemId[] | null; } export function GitSettings({ visibleItems }: GitSettingsProps) { + const showWorktreeMode = isItemVisible( + SETTING_ITEM_ID.GIT_WORKTREE_MODE, + visibleItems, + ); const showDeleteLocalBranch = isItemVisible( SETTING_ITEM_ID.GIT_DELETE_LOCAL_BRANCH, visibleItems, @@ -43,6 +53,28 @@ export function GitSettings({ visibleItems }: GitSettingsProps) { const utils = electronTrpc.useUtils(); + const { data: worktreeMode, isLoading: isWorktreeModeLoading } = + electronTrpc.settings.getWorktreeMode.useQuery(); + const setWorktreeMode = electronTrpc.settings.setWorktreeMode.useMutation({ + onMutate: async ({ mode }) => { + await utils.settings.getWorktreeMode.cancel(); + const previous = utils.settings.getWorktreeMode.getData(); + utils.settings.getWorktreeMode.setData(undefined, mode); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getWorktreeMode.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getWorktreeMode.invalidate(); + }, + }); + + const effectiveWorktreeMode = worktreeMode ?? "always"; + const isWorktreeDisabled = effectiveWorktreeMode === "disabled"; + const { data: deleteLocalBranch, isLoading: isDeleteBranchLoading } = electronTrpc.settings.getDeleteLocalBranch.useQuery(); const setDeleteLocalBranch = @@ -154,7 +186,41 @@ export function GitSettings({ visibleItems }: GitSettingsProps) {
- {showDeleteLocalBranch && ( + {showWorktreeMode && ( +
+
+ +

+ Control whether new workspaces use git worktrees +

+
+ +
+ )} + + {showDeleteLocalBranch && !isWorktreeDisabled && (
@@ -59,13 +62,29 @@ export function CloseProjectDialog({ Cancel + {hasWorktrees && ( + + )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index a71eaa1342f..ab52990195a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -12,7 +12,7 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { HiChevronRight, HiMiniPlus } from "react-icons/hi2"; import { LuFolderOpen, @@ -52,6 +52,18 @@ interface ProjectHeaderProps { onToggleCollapse: () => void; workspaceCount: number; onNewWorkspace: () => void; + /** Whether this project has worktree-type workspaces on disk */ + hasWorktrees?: boolean; + /** True when the project has only a single branch workspace (no worktrees) */ + isBranchOnly?: boolean; + /** Branch name for inline display on branch-only projects */ + branchOnlyBranch?: string; + /** Worktree path for diff stats on branch-only projects */ + branchOnlyWorktreePath?: string; + /** Whether this branch-only project's workspace is currently active */ + isActive?: boolean; + /** Called when clicking a branch-only project to navigate directly */ + onNavigateToWorkspace?: () => void; } export function ProjectHeader({ @@ -67,6 +79,12 @@ export function ProjectHeader({ onToggleCollapse, workspaceCount, onNewWorkspace, + hasWorktrees = false, + isBranchOnly = false, + branchOnlyBranch, + branchOnlyWorktreePath, + isActive = false, + onNavigateToWorkspace, }: ProjectHeaderProps) { const utils = electronTrpc.useUtils(); const navigate = useNavigate(); @@ -74,6 +92,24 @@ export function ProjectHeader({ const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); const rename = useProjectRename(projectId, projectName); + // Diff stats for branch-only inline display + const { data: branchOnlyChanges } = electronTrpc.changes.getStatus.useQuery( + { worktreePath: branchOnlyWorktreePath ?? "" }, + { enabled: isBranchOnly && !!branchOnlyWorktreePath, staleTime: 5000 }, + ); + const branchOnlyDiffStats = useMemo(() => { + if (!branchOnlyChanges) return null; + const files = [ + ...branchOnlyChanges.staged, + ...branchOnlyChanges.unstaged, + ...branchOnlyChanges.untracked, + ]; + const additions = files.reduce((sum, f) => sum + (f.additions || 0), 0); + const deletions = files.reduce((sum, f) => sum + (f.deletions || 0), 0); + if (additions === 0 && deletions === 0) return null; + return { additions, deletions }; + }, [branchOnlyChanges]); + const closeProject = electronTrpc.projects.close.useMutation({ onMutate: async ({ id }) => { let shouldNavigate = false; @@ -128,8 +164,11 @@ export function ProjectHeader({ setIsCloseDialogOpen(true); }; - const handleConfirmClose = () => { - closeProject.mutate({ id: projectId }); + const handleConfirmClose = (options: { deleteWorktrees: boolean }) => { + closeProject.mutate({ + id: projectId, + deleteWorktrees: options.deleteWorktrees, + }); }; const handleOpenInFinder = () => { @@ -268,6 +307,7 @@ export function ProjectHeader({ {rename.isRenaming ? ( @@ -307,58 +348,88 @@ export function ProjectHeader({ ) : ( )} - - + {!isBranchOnly && ( + <> + + + + + + New workspace + + + - - - New workspace - - - - + + )}
@@ -406,6 +477,7 @@ export function ProjectHeader({ sum + s.workspaces.length, 0); + const isBranchOnly = worktreeMode === "disabled"; + const hasWorktrees = !isBranchOnly; + + const matchRoute = useMatchRoute(); + const isBranchOnlyActive = + isBranchOnly && + workspaces.length > 0 && + !!matchRoute({ + to: "/workspace/$workspaceId", + params: { workspaceId: workspaces[0].id }, + }); + + const handleNavigateToWorkspace = () => { + if (isBranchOnly && workspaces.length > 0) { + navigateToWorkspace(workspaces[0].id, navigate); + } + }; + const { orderedWorkspaceIds, topLevelChildren } = useMemo(() => { const topLevelWorkspacesById = new Map( workspaces.map((workspace) => [workspace.id, workspace]), @@ -197,6 +217,9 @@ export function ProjectSection({ }, }); + // For branch-only projects, always show workspace (no collapse needed) + const showWorkspaces = isBranchOnly || !isCollapsed; + if (isSidebarCollapsed) { return (
toggleProjectCollapsed(projectId)} workspaceCount={totalWorkspaceCount} onNewWorkspace={handleNewWorkspace} + hasWorktrees={hasWorktrees} + isBranchOnly={isBranchOnly} + isActive={isBranchOnlyActive} + onNavigateToWorkspace={handleNavigateToWorkspace} /> - {!isCollapsed && ( + {showWorkspaces && ( toggleProjectCollapsed(projectId)} workspaceCount={totalWorkspaceCount} onNewWorkspace={handleNewWorkspace} + hasWorktrees={hasWorktrees} + isBranchOnly={isBranchOnly} + branchOnlyBranch={ + isBranchOnly && workspaces.length > 0 + ? workspaces[0].branch + : undefined + } + branchOnlyWorktreePath={ + isBranchOnly && workspaces.length > 0 + ? workspaces[0].worktreePath + : undefined + } + isActive={isBranchOnlyActive} + onNavigateToWorkspace={handleNavigateToWorkspace} /> - - {!isCollapsed && ( - -
- {topLevelChildren.length === 0 && ( -
- )} - {topLevelChildren.map((item) => - item.kind === "workspace" ? ( - - ) : ( - + {showWorkspaces && ( + +
+ {!isBranchOnly && topLevelChildren.length === 0 && ( +
- ), - )} -
- - )} - + )} + {topLevelChildren.map((item) => + item.kind === "workspace" ? ( + + ) : ( + + ), + )} +
+
+ )} + + )}
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx index 513c4e02590..4d7f8f8a2ca 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx @@ -24,6 +24,7 @@ import { LuFolderPlus, LuMinus, LuPencil, + LuX, } from "react-icons/lu"; import { useCreateSectionFromWorkspaces, @@ -48,6 +49,7 @@ interface WorkspaceContextMenuProps { onCopyPath: () => void; onSetUnread: (isUnread: boolean) => void; onResetStatus: () => void; + onClose: () => void; children: React.ReactNode; } @@ -64,6 +66,7 @@ export function WorkspaceContextMenu({ onCopyPath, onSetUnread, onResetStatus, + onClose, children, }: WorkspaceContextMenuProps) { const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); @@ -180,7 +183,20 @@ export function WorkspaceContextMenu({ return ( {children} - {commonContextMenuItems} + + {commonContextMenuItems} + + + + Close Workspace + + ); } @@ -202,6 +218,17 @@ export function WorkspaceContextMenu({ {commonContextMenuItems} + + + + Close Workspace + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 313355ff83a..b5cfa76aa2c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -18,6 +18,7 @@ import { useWorkspaceSelectionStore } from "renderer/stores/workspace-selection" import { getHighestPriorityStatus } from "shared/tabs-types"; import { CollapsedWorkspaceItem } from "./CollapsedWorkspaceItem"; import { DeleteWorkspaceDialog } from "./components"; +import { CloseWorkspaceDialog } from "./components/CloseWorkspaceDialog"; import { GITHUB_STATUS_STALE_TIME, MAX_KEYBOARD_SHORTCUT_INDEX, @@ -120,6 +121,26 @@ export function WorkspaceListItem({ const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); + const [showCloseDialog, setShowCloseDialog] = useState(false); + const closeWorkspace = electronTrpc.workspaces.close.useMutation({ + onSuccess: () => utils.workspaces.getAllGrouped.invalidate(), + onError: (error) => + toast.error(`Failed to close workspace: ${error.message}`), + }); + const deleteWorkspace = electronTrpc.workspaces.delete.useMutation({ + onSuccess: () => utils.workspaces.getAllGrouped.invalidate(), + onError: (error) => + toast.error(`Failed to close workspace: ${error.message}`), + }); + + const handleCloseConfirm = (options: { moveToTrash: boolean }) => { + if (options.moveToTrash) { + deleteWorkspace.mutate({ id }); + } else { + closeWorkspace.mutate({ id }); + } + }; + const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( { workspaceId: id }, @@ -436,6 +457,7 @@ export function WorkspaceListItem({ onCopyPath={handleCopyPath} onSetUnread={(unread) => setUnread.mutate({ id, isUnread: unread })} onResetStatus={() => resetWorkspaceStatus(id)} + onClose={() => setShowCloseDialog(true)} > {content} @@ -446,6 +468,13 @@ export function WorkspaceListItem({ open={showDeleteDialog} onOpenChange={setShowDeleteDialog} /> + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/CloseWorkspaceDialog/CloseWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/CloseWorkspaceDialog/CloseWorkspaceDialog.tsx new file mode 100644 index 00000000000..a82eb3fc150 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/CloseWorkspaceDialog/CloseWorkspaceDialog.tsx @@ -0,0 +1,88 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; + +interface CloseWorkspaceDialogProps { + workspaceName: string; + isWorktree: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (options: { moveToTrash: boolean }) => void; +} + +export function CloseWorkspaceDialog({ + workspaceName, + isWorktree, + open, + onOpenChange, + onConfirm, +}: CloseWorkspaceDialogProps) { + return ( + + + + + Close workspace "{workspaceName}"? + + +
+ + This will kill all active terminals in this workspace. + + {isWorktree && ( + + Close Workspace removes the workspace from + the sidebar. Worktree files stay on disk. +
+ Recycle Worktree closes the workspace and + moves the worktree folder to Trash. +
+ )} +
+
+
+ + + + + {isWorktree && ( + + )} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/CloseWorkspaceDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/CloseWorkspaceDialog/index.ts new file mode 100644 index 00000000000..e6b9ead2ed6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/CloseWorkspaceDialog/index.ts @@ -0,0 +1 @@ +export { CloseWorkspaceDialog } from "./CloseWorkspaceDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 4198ed8636c..04c14dd0731 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -88,6 +88,7 @@ export function WorkspaceSidebar({ mainRepoPath={group.project.mainRepoPath} hideImage={group.project.hideImage} iconUrl={group.project.iconUrl} + worktreeMode={group.project.worktreeMode} workspaces={group.workspaces} sections={group.sections ?? []} topLevelItems={group.topLevelItems} diff --git a/apps/desktop/src/renderer/stores/worktree-choice-dialog.ts b/apps/desktop/src/renderer/stores/worktree-choice-dialog.ts new file mode 100644 index 00000000000..bc4884d8c88 --- /dev/null +++ b/apps/desktop/src/renderer/stores/worktree-choice-dialog.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +interface WorktreeChoiceDialogState { + isOpen: boolean; + projectName: string; + onChoice: ((enableWorktrees: boolean) => void) | null; + open: (params: { + projectName: string; + onChoice: (enableWorktrees: boolean) => void; + }) => void; + close: () => void; +} + +export const useWorktreeChoiceDialogStore = create()( + devtools( + (set) => ({ + isOpen: false, + projectName: "", + onChoice: null, + + open: ({ projectName, onChoice }) => { + set({ isOpen: true, projectName, onChoice }); + }, + + close: () => { + set({ isOpen: false, projectName: "", onChoice: null }); + }, + }), + { name: "WorktreeChoiceDialogStore" }, + ), +); From 6867bd235b4d538f6c1929f8222e59cbc7888534 Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 15:52:56 +0700 Subject: [PATCH 03/15] fix(desktop): worktree recycle uses Trash, sidebar refreshes on project open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `trash` option to workspaces.delete procedure — uses shell.trashItem() + git worktree prune instead of permanent delete - "Disable & Recycle" in project settings now sends worktrees to Trash - "Recycle Worktree" in workspace close dialog also uses Trash - Invalidate getAllGrouped + getRecents after worktree choice dialog so new projects appear in the sidebar immediately - Capitalize "Worktrees" in dialog titles, use "Enable"/"Disable" button labels instead of "With/Without Worktrees" --- .../routers/workspaces/procedures/delete.ts | 30 +++-- .../WorktreeChoiceDialog.tsx | 16 +-- .../react-query/projects/useOpenProject.tsx | 3 +- .../ProjectSettings/ProjectSettings.tsx | 106 ++++++++++++++++++ .../WorkspaceListItem/WorkspaceListItem.tsx | 2 +- 5 files changed, 140 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 21cf02c02b1..ea36d470f79 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -154,6 +154,7 @@ export const createDeleteProcedures = () => { id: z.string(), deleteLocalBranch: z.boolean().optional(), force: z.boolean().optional(), + trash: z.boolean().optional(), }), ) .mutation(async ({ input }) => { @@ -252,13 +253,28 @@ export const createDeleteProcedures = () => { await workspaceInitManager.acquireProjectLock(project.id); try { - const removeResult = await removeWorktreeFromDisk({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - }); - if (!removeResult.success) { - clearWorkspaceDeletingStatus(input.id); - return removeResult; + if (input.trash) { + // Move to Trash (recoverable) instead of permanent delete + const { existsSync } = await import("node:fs"); + if (existsSync(worktree.path)) { + const { shell } = await import("electron"); + await shell.trashItem(worktree.path); + } + // Clean up stale git worktree references + const { getSimpleGitWithShellPath } = await import( + "../utils/git-client" + ); + const git = await getSimpleGitWithShellPath(project.mainRepoPath); + await git.raw(["worktree", "prune"]); + } else { + const removeResult = await removeWorktreeFromDisk({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktree.path, + }); + if (!removeResult.success) { + clearWorkspaceDeletingStatus(input.id); + return removeResult; + } } } finally { workspaceInitManager.releaseProjectLock(project.id); diff --git a/apps/desktop/src/renderer/components/WorktreeChoiceDialog/WorktreeChoiceDialog.tsx b/apps/desktop/src/renderer/components/WorktreeChoiceDialog/WorktreeChoiceDialog.tsx index 2449af88396..2f7238771b3 100644 --- a/apps/desktop/src/renderer/components/WorktreeChoiceDialog/WorktreeChoiceDialog.tsx +++ b/apps/desktop/src/renderer/components/WorktreeChoiceDialog/WorktreeChoiceDialog.tsx @@ -26,18 +26,18 @@ export function WorktreeChoiceDialog({ - Enable worktrees for "{projectName}"? + Enable Worktrees for "{projectName}"?
- With Worktrees — each workspace gets its own - isolated copy of the repo. You can run multiple agents in - parallel without conflicts. + Enable — each workspace gets its own isolated + copy of the repo. You can run multiple agents in parallel + without conflicts. - Without Worktrees — work directly in the - project folder. One workspace, no copies. + Disable — work directly in the project folder. + One workspace, no copies.
@@ -53,7 +53,7 @@ export function WorktreeChoiceDialog({ onChoice(false); }} > - Without Worktrees + Disable
diff --git a/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx b/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx index 13cd2ede75b..52827280ed9 100644 --- a/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx +++ b/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx @@ -90,8 +90,9 @@ export function useOpenProject() { id: project.id, patch: { worktreeMode: "disabled" }, }); - await utils.workspaces.getAllGrouped.invalidate(); } + await utils.workspaces.getAllGrouped.invalidate(); + await utils.projects.getRecents.invalidate(); resolveChoice(); }, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index e9ad8cc4bd3..eab89d1a9c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -213,7 +213,29 @@ export function ProjectSettings({ const { data: globalWorktreeMode } = electronTrpc.settings.getWorktreeMode.useQuery(); + const [showDisableWorktreeDialog, setShowDisableWorktreeDialog] = + useState(false); + + const deleteWorkspace = electronTrpc.workspaces.delete.useMutation({ + onSettled: () => utils.workspaces.getAllGrouped.invalidate(), + }); + const closeWorkspaceMutation = electronTrpc.workspaces.close.useMutation({ + onSettled: () => utils.workspaces.getAllGrouped.invalidate(), + }); + + // Get worktree workspaces for this project + const { data: allGroups = [] } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + const projectGroup = allGroups.find((g) => g.project.id === projectId); + const worktreeWorkspaces = (projectGroup?.workspaces ?? []).filter( + (w) => w.type === "worktree", + ); + const handleWorktreeModeChange = (value: string) => { + if (value === "disabled" && worktreeWorkspaces.length > 0) { + setShowDisableWorktreeDialog(true); + return; + } updateProject.mutate({ id: projectId, patch: { @@ -222,6 +244,29 @@ export function ProjectSettings({ }); }; + const handleDisableWorktrees = async (recycleWorktrees: boolean) => { + // Close/delete all worktree workspaces + for (const ws of worktreeWorkspaces) { + try { + if (recycleWorktrees) { + await deleteWorkspace.mutateAsync({ id: ws.id, trash: true }); + } else { + await closeWorkspaceMutation.mutateAsync({ id: ws.id }); + } + } catch (error) { + console.error( + `[ProjectSettings] Failed to close worktree workspace ${ws.id}:`, + error, + ); + } + } + // Now disable worktrees on the project + updateProject.mutate({ + id: projectId, + patch: { worktreeMode: "disabled" }, + }); + }; + const effectiveWorktreeMode = project?.worktreeMode ?? globalWorktreeMode ?? "always"; const isWorktreeDisabled = effectiveWorktreeMode === "disabled"; @@ -703,6 +748,67 @@ export function ProjectSettings({
+ + {/* Disable worktrees confirmation dialog */} + + + + + Disable Worktrees for "{project.name}"? + + +
+ + This project has {worktreeWorkspaces.length} worktree + workspace{worktreeWorkspaces.length !== 1 ? "s" : ""}. + + + Disable removes worktree workspaces from the + sidebar. Files stay on disk. +
+ Disable & Recycle also moves worktree folders + to Trash. +
+
+
+
+ + + + + +
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index b5cfa76aa2c..32b7c80843f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -135,7 +135,7 @@ export function WorkspaceListItem({ const handleCloseConfirm = (options: { moveToTrash: boolean }) => { if (options.moveToTrash) { - deleteWorkspace.mutate({ id }); + deleteWorkspace.mutate({ id, trash: true }); } else { closeWorkspace.mutate({ id }); } From 60c9f87e8d618a4b5af940892a081df7a7de6528 Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 16:04:58 +0700 Subject: [PATCH 04/15] feat(desktop): add hover shortcut overlay for branch-only projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch-only projects now show the same hover UX as worktree workspaces: diff stats fade out and keyboard shortcut (⌘1-9) fades in on hover. Uses the same CSS grid overlay pattern as WorkspaceListItem. --- .../ProjectSection/ProjectHeader.tsx | 39 ++++++++++++++----- .../ProjectSection/ProjectSection.tsx | 1 + 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index ab52990195a..efee4891d83 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -60,6 +60,8 @@ interface ProjectHeaderProps { branchOnlyBranch?: string; /** Worktree path for diff stats on branch-only projects */ branchOnlyWorktreePath?: string; + /** Keyboard shortcut index for branch-only projects */ + shortcutIndex?: number; /** Whether this branch-only project's workspace is currently active */ isActive?: boolean; /** Called when clicking a branch-only project to navigate directly */ @@ -83,6 +85,7 @@ export function ProjectHeader({ isBranchOnly = false, branchOnlyBranch, branchOnlyWorktreePath, + shortcutIndex, isActive = false, onNavigateToWorkspace, }: ProjectHeaderProps) { @@ -322,7 +325,7 @@ export function ProjectHeader({
)} - {isBranchOnly && branchOnlyDiffStats && ( -
-
- - +{branchOnlyDiffStats.additions} - - - -{branchOnlyDiffStats.deletions} - + {isBranchOnly && ( +
+ {branchOnlyDiffStats && ( +
+
+ + +{branchOnlyDiffStats.additions} + + + -{branchOnlyDiffStats.deletions} + +
+
+ )} +
+ {shortcutIndex !== undefined && shortcutIndex < 9 && ( + + ⌘{shortcutIndex + 1} + + )}
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index b7676013e48..c49c1617440 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -339,6 +339,7 @@ export function ProjectSection({ ? workspaces[0].worktreePath : undefined } + shortcutIndex={isBranchOnly ? shortcutBaseIndex : undefined} isActive={isBranchOnlyActive} onNavigateToWorkspace={handleNavigateToWorkspace} /> From 5c8d78362966a37dbde3e2caa152e3ccd4a9c8b6 Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 18:17:38 +0700 Subject: [PATCH 05/15] feat(desktop): add agent status ring to branch-only project thumbnails When an agent is running in a branch-only project's workspace, the project thumbnail in the sidebar gets a colored ring: - Working (amber): pulsing ring while agent is processing - Permission (red): pulsing ring when agent needs user input - Review (green): static ring when agent finished but user hasn't seen it Uses the same tabs store status aggregation as WorkspaceListItem. --- .../ProjectSection/ProjectHeader.tsx | 48 +++++++++++++++---- .../ProjectSection/ProjectSection.tsx | 3 ++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index efee4891d83..c49066ea594 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -28,10 +28,13 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { useUpdateProject } from "renderer/react-query/projects/useUpdateProject"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useProjectRename } from "renderer/screens/main/hooks/useProjectRename"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; import { PROJECT_COLOR_DEFAULT, PROJECT_COLORS, } from "shared/constants/project-colors"; +import { getHighestPriorityStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; import { RenameInput } from "../RenameInput"; import { CloseProjectDialog } from "./CloseProjectDialog"; @@ -62,6 +65,8 @@ interface ProjectHeaderProps { branchOnlyWorktreePath?: string; /** Keyboard shortcut index for branch-only projects */ shortcutIndex?: number; + /** Workspace ID for branch-only status tracking */ + branchOnlyWorkspaceId?: string; /** Whether this branch-only project's workspace is currently active */ isActive?: boolean; /** Called when clicking a branch-only project to navigate directly */ @@ -86,6 +91,7 @@ export function ProjectHeader({ branchOnlyBranch, branchOnlyWorktreePath, shortcutIndex, + branchOnlyWorkspaceId, isActive = false, onNavigateToWorkspace, }: ProjectHeaderProps) { @@ -95,6 +101,20 @@ export function ProjectHeader({ const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); const rename = useProjectRename(projectId, projectName); + // Agent status for branch-only projects (pulsing ring around thumbnail) + const branchOnlyStatus = useTabsStore((state) => { + if (!branchOnlyWorkspaceId) return null; + function* paneStatuses() { + for (const tab of state.tabs) { + if (tab.workspaceId !== branchOnlyWorkspaceId) continue; + for (const paneId of extractPaneIdsFromLayout(tab.layout)) { + yield state.panes[paneId]?.status; + } + } + } + return getHighestPriorityStatus(paneStatuses()); + }); + // Diff stats for branch-only inline display const { data: branchOnlyChanges } = electronTrpc.changes.getStatus.useQuery( { worktreePath: branchOnlyWorktreePath ?? "" }, @@ -361,14 +381,26 @@ export function ProjectHeader({ )} >
- +
+ +
{projectName} {!isBranchOnly && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index c49c1617440..29e2263b4b0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -340,6 +340,9 @@ export function ProjectSection({ : undefined } shortcutIndex={isBranchOnly ? shortcutBaseIndex : undefined} + branchOnlyWorkspaceId={ + isBranchOnly && workspaces.length > 0 ? workspaces[0].id : undefined + } isActive={isBranchOnlyActive} onNavigateToWorkspace={handleNavigateToWorkspace} /> From f878bcda5ee62ae711a601bc2c9a0710fd8cdbb9 Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 18:20:47 +0700 Subject: [PATCH 06/15] feat(desktop): auto-discover project favicon on open When a project is opened or created, automatically search for favicon/logo files in the repo (favicon.ico, logo.png, public/favicon, etc.) and set it as the project icon. Only runs if no icon is already set. Manually uploaded icons take priority. Fire-and-forget so it doesn't block project creation. --- .../src/lib/trpc/routers/projects/projects.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 2a7ff963a85..42a6f42b88a 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -117,6 +117,27 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { .set({ lastOpenedAt: Date.now(), defaultBranch }) .where(eq(projects.id, existing.id)) .run(); + + // Discover favicon if no icon is set yet (fire-and-forget) + if (!existing.iconUrl) { + discoverAndSaveProjectIcon({ + projectId: existing.id, + repoPath: mainRepoPath, + }) + .then((iconUrl) => { + if (iconUrl) { + localDb + .update(projects) + .set({ iconUrl }) + .where(eq(projects.id, existing.id)) + .run(); + } + }) + .catch((err) => { + console.error("[upsertProject] Favicon discovery failed:", err); + }); + } + return { ...existing, lastOpenedAt: Date.now(), defaultBranch }; } @@ -131,6 +152,24 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { .returning() .get(); + // Discover favicon for new project (fire-and-forget) + discoverAndSaveProjectIcon({ + projectId: project.id, + repoPath: mainRepoPath, + }) + .then((iconUrl) => { + if (iconUrl) { + localDb + .update(projects) + .set({ iconUrl }) + .where(eq(projects.id, project.id)) + .run(); + } + }) + .catch((err) => { + console.error("[upsertProject] Favicon discovery failed:", err); + }); + return project; } From 797c2268d0dccb7ce4615d5ab30da3f389e51caf Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 18:21:49 +0700 Subject: [PATCH 07/15] fix(desktop): match status ring radius to project thumbnail (rounded) --- .../WorkspaceSidebar/ProjectSection/ProjectHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index c49066ea594..4492aa77744 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -383,7 +383,7 @@ export function ProjectHeader({
Date: Thu, 19 Mar 2026 18:30:14 +0700 Subject: [PATCH 08/15] fix(desktop): expand favicon discovery to find nested app icons Add patterns for Next.js app directory (app/favicon.ico, app/icon.png), monorepo nested public directories (**/public/favicon.*), and increase glob depth to 4 levels. Fixes discovery for projects where the favicon is in a subdirectory like NEXTapp/app/ or peliguard-website/app/. --- .../projects/utils/favicon-discovery.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts index f985b7c2eb1..b005699236d 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts @@ -6,8 +6,9 @@ import { saveProjectIconFromFile, } from "main/lib/project-icons"; -/** Common favicon file names to search for in project roots */ +/** Common favicon file names to search for in project roots and subdirectories */ const FAVICON_PATTERNS = [ + // Root level "favicon.ico", "favicon.png", "favicon.svg", @@ -17,6 +18,7 @@ const FAVICON_PATTERNS = [ "icon.svg", ".github/logo.png", ".github/logo.svg", + // Common static/public directories "public/favicon.ico", "public/favicon.png", "public/favicon.svg", @@ -28,6 +30,19 @@ const FAVICON_PATTERNS = [ "assets/favicon.ico", "assets/favicon.png", "assets/icon.png", + // Next.js / app directory patterns (up to 2 levels deep) + "app/favicon.ico", + "app/icon.png", + "app/icon.svg", + "**/app/favicon.ico", + "**/app/icon.png", + "**/app/icon.svg", + // Deeper public directories in monorepos/nested projects + "**/public/favicon.ico", + "**/public/favicon.png", + "**/public/favicon.svg", + "**/public/logo.png", + "**/public/logo.svg", ]; /** Max file size for discovered favicons: 256KB */ @@ -48,6 +63,7 @@ export async function discoverAndSaveProjectIcon({ const matches = await fg(FAVICON_PATTERNS, { cwd: repoPath, absolute: true, + deep: 4, ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"], onlyFiles: true, }); From 8dfa3ce40444c561680fb674c78d01b73b552632 Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 18:32:47 +0700 Subject: [PATCH 09/15] style(desktop): workspace count in badge tile instead of parentheses --- .../WorkspaceSidebar/ProjectSection/ProjectHeader.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 4492aa77744..ffe2dce4daa 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -403,8 +403,8 @@ export function ProjectHeader({
{projectName} {!isBranchOnly && ( - - ({workspaceCount}) + + {workspaceCount} )} {isBranchOnly && ( From 5b9a80fe14373d41efd1b455b865d6e63cfebd11 Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 18:58:36 +0700 Subject: [PATCH 10/15] fix(desktop): clear review status when clicking branch-only project --- .../WorkspaceSidebar/ProjectSection/ProjectSection.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 29e2263b4b0..040fe0b535b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -9,6 +9,7 @@ import { useReorderProjects } from "renderer/react-query/projects"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useWorkspaceSidebarStore } from "renderer/stores"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { useTabsStore } from "renderer/stores/tabs/store"; import { useSectionDropZone } from "../hooks"; import type { SidebarSection, SidebarWorkspace } from "../types"; import { WorkspaceListItem } from "../WorkspaceListItem"; @@ -92,8 +93,13 @@ export function ProjectSection({ params: { workspaceId: workspaces[0].id }, }); + const clearWorkspaceAttentionStatus = useTabsStore( + (s) => s.clearWorkspaceAttentionStatus, + ); + const handleNavigateToWorkspace = () => { if (isBranchOnly && workspaces.length > 0) { + clearWorkspaceAttentionStatus(workspaces[0].id); navigateToWorkspace(workspaces[0].id, navigate); } }; From 3ecf401a7207d5ef28962c7b87614b583f71cf84 Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 19:03:05 +0700 Subject: [PATCH 11/15] fix(desktop): show worktree choice dialog on drag-and-drop project open --- .../renderer/react-query/projects/useOpenProject.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx b/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx index 52827280ed9..7ade642ecc5 100644 --- a/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx +++ b/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx @@ -220,7 +220,10 @@ export function useOpenProject() { showDialog({ paths: [result.selectedPath], immediateSuccesses: [], - resolve: (projects) => resolve(projects[0] ?? null), + resolve: async (projects) => { + await maybePromptWorktreeChoice(projects); + resolve(projects[0] ?? null); + }, }); return; } @@ -231,7 +234,9 @@ export function useOpenProject() { } if ("project" in result) { - resolve(result.project); + maybePromptWorktreeChoice([result.project]).then(() => { + resolve(result.project); + }); return; } @@ -244,7 +249,7 @@ export function useOpenProject() { ); }); }, - [openFromPathMutation, showDialog], + [maybePromptWorktreeChoice, openFromPathMutation, showDialog], ); return { From 93a53050463079060c5846dc3efce3f8045f0f6d Mon Sep 17 00:00:00 2001 From: z3thon Date: Thu, 19 Mar 2026 19:06:56 +0700 Subject: [PATCH 12/15] fix(desktop): navigate to workspace after drag-and-drop project open --- .../SidebarDropZone/SidebarDropZone.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx index 519c4f5b819..550d51573f2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SidebarDropZone/SidebarDropZone.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { type ReactNode, useCallback, useEffect, useState } from "react"; import { LuFolderPlus, LuLoader, LuX } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useOpenProject } from "renderer/react-query/projects"; interface SidebarDropZoneProps { @@ -16,6 +17,7 @@ export function SidebarDropZone({ children, className }: SidebarDropZoneProps) { const [error, setError] = useState(null); const { openFromPath, isPending } = useOpenProject(); + const utils = electronTrpc.useUtils(); useEffect(() => { if (!error) return; @@ -97,16 +99,24 @@ export function SidebarDropZone({ children, className }: SidebarDropZoneProps) { try { const project = await openFromPath(filePath); if (project) { - navigate({ - to: "/project/$projectId", - params: { projectId: project.id }, - }); + // Refresh sidebar and navigate to the project's workspace + await utils.workspaces.getAllGrouped.invalidate(); + const groups = await utils.workspaces.getAllGrouped.fetch(); + const workspace = groups + .flatMap((g) => g.workspaces) + .find((w) => w.projectId === project.id); + if (workspace) { + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: workspace.id }, + }); + } } } catch (err) { setError(err instanceof Error ? err.message : "Failed to open project"); } }, - [openFromPath, isPending, navigate], + [openFromPath, isPending, navigate, utils], ); return ( From 48da3605f638b107f56dd2c3d9590aa295c799d0 Mon Sep 17 00:00:00 2001 From: z3thon Date: Fri, 20 Mar 2026 05:42:57 +0700 Subject: [PATCH 13/15] fix(desktop): address PR review feedback - Checkout target branch before creating branch workspace when worktrees are disabled (prevents main repo staying on wrong branch) - Handle async errors in worktree choice dialog to prevent fire-and-forget rejections and ensure dialog closes properly - Wrap useOpenProject's onChoice in try/finally to guarantee resolveChoice() is always called, preventing indefinite pending state --- .../routers/workspaces/procedures/create.ts | 5 +++++ .../ConnectedWorktreeChoiceDialog.tsx | 11 ++++++++--- .../react-query/projects/useOpenProject.tsx | 19 +++++++++++-------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index d1faf7e2fdb..858a5de0acb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -326,6 +326,11 @@ export const createCreateProcedures = () => { throw new Error("Could not determine current branch"); } + // Checkout the target branch so the main repo matches the workspace + if (input.branchName?.trim()) { + await safeCheckoutBranch(project.mainRepoPath, branch); + } + const existing = getBranchWorkspace(input.projectId); if (existing) { diff --git a/apps/desktop/src/renderer/components/WorktreeChoiceDialog/ConnectedWorktreeChoiceDialog.tsx b/apps/desktop/src/renderer/components/WorktreeChoiceDialog/ConnectedWorktreeChoiceDialog.tsx index 5b703b6e1a8..2e27588af81 100644 --- a/apps/desktop/src/renderer/components/WorktreeChoiceDialog/ConnectedWorktreeChoiceDialog.tsx +++ b/apps/desktop/src/renderer/components/WorktreeChoiceDialog/ConnectedWorktreeChoiceDialog.tsx @@ -12,9 +12,14 @@ export function ConnectedWorktreeChoiceDialog() { onOpenChange={(open) => { if (!open) close(); }} - onChoice={(enableWorktrees) => { - onChoice?.(enableWorktrees); - close(); + onChoice={async (enableWorktrees) => { + try { + await onChoice?.(enableWorktrees); + } catch (error) { + console.error("[WorktreeChoiceDialog] onChoice failed:", error); + } finally { + close(); + } }} /> ); diff --git a/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx b/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx index 7ade642ecc5..ef2dc2919f4 100644 --- a/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx +++ b/apps/desktop/src/renderer/react-query/projects/useOpenProject.tsx @@ -85,15 +85,18 @@ export function useOpenProject() { useWorktreeChoiceDialogStore.getState().open({ projectName: project.name, onChoice: async (enableWorktrees) => { - if (!enableWorktrees) { - await updateProject.mutateAsync({ - id: project.id, - patch: { worktreeMode: "disabled" }, - }); + try { + if (!enableWorktrees) { + await updateProject.mutateAsync({ + id: project.id, + patch: { worktreeMode: "disabled" }, + }); + } + await utils.workspaces.getAllGrouped.invalidate(); + await utils.projects.getRecents.invalidate(); + } finally { + resolveChoice(); } - await utils.workspaces.getAllGrouped.invalidate(); - await utils.projects.getRecents.invalidate(); - resolveChoice(); }, }); }); From c4a03244e9813bbcef174a47bba0cecf8459e584 Mon Sep 17 00:00:00 2001 From: z3thon Date: Fri, 20 Mar 2026 07:22:37 +0700 Subject: [PATCH 14/15] fix(desktop): X button on workspace uses close dialog with recycle option --- .../WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 32b7c80843f..5ce885ac7a6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -402,7 +402,7 @@ export function WorkspaceListItem({ type="button" onClick={(e) => { e.stopPropagation(); - handleDeleteClick(); + setShowCloseDialog(true); }} className="flex items-center justify-center text-muted-foreground hover:text-foreground" aria-label="Close workspace" From d14d19dc4667adfe138c2dcb596ca1b8eabd0783 Mon Sep 17 00:00:00 2001 From: z3thon Date: Fri, 20 Mar 2026 23:58:05 +0700 Subject: [PATCH 15/15] fix(desktop): add missing settings import in workspace create procedure --- .../src/lib/trpc/routers/workspaces/procedures/create.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 6164aa93aff..40b79aa4d27 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -1,4 +1,4 @@ -import { projects, workspaces, worktrees } from "@superset/local-db"; +import { projects, settings, workspaces, worktrees } from "@superset/local-db"; import { and, eq, isNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db";