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 d47ebeaf354..e2efc41808d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -732,6 +732,8 @@ export const createCreateProcedures = () => { gitStatus: { branch: existingWorktree.branch, needsRebase: false, + ahead: 0, + behind: 0, lastRefreshed: Date.now(), }, }) @@ -816,6 +818,8 @@ export const createCreateProcedures = () => { gitStatus: { branch: input.branch, needsRebase: false, + ahead: 0, + behind: 0, lastRefreshed: Date.now(), }, }) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 1f978cb5200..ea55390ffe0 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -10,8 +10,8 @@ import { updateProjectDefaultBranch, } from "../utils/db-helpers"; import { - checkNeedsRebase, fetchDefaultBranch, + getAheadBehindCount, getDefaultBranch, listExternalWorktrees, refreshDefaultBranch, @@ -42,7 +42,6 @@ export const createGitStatusProcedures = () => { throw new Error(`Project ${workspace.projectId} not found`); } - // Sync with remote in case the default branch changed (e.g. master -> main) const remoteDefaultBranch = await refreshDefaultBranch( project.mainRepoPath, ); @@ -59,22 +58,21 @@ export const createGitStatusProcedures = () => { updateProjectDefaultBranch(project.id, defaultBranch); } - // Fetch default branch to get latest await fetchDefaultBranch(project.mainRepoPath, defaultBranch); - // Check if worktree branch is behind origin/{defaultBranch} - const needsRebase = await checkNeedsRebase( - worktree.path, + const { ahead, behind } = await getAheadBehindCount({ + repoPath: worktree.path, defaultBranch, - ); + }); const gitStatus = { branch: worktree.branch, - needsRebase, + needsRebase: behind > 0, + ahead, + behind, lastRefreshed: Date.now(), }; - // Update worktree in db localDb .update(worktrees) .set({ gitStatus }) @@ -84,6 +82,25 @@ export const createGitStatusProcedures = () => { return { gitStatus, defaultBranch }; }), + getAheadBehind: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ input }) => { + const workspace = getWorkspace(input.workspaceId); + if (!workspace) { + return { ahead: 0, behind: 0 }; + } + + const project = getProject(workspace.projectId); + if (!project) { + return { ahead: 0, behind: 0 }; + } + + return getAheadBehindCount({ + repoPath: project.mainRepoPath, + defaultBranch: workspace.branch, + }); + }), + getGitHubStatus: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(async ({ input }) => { @@ -99,10 +116,8 @@ export const createGitStatusProcedures = () => { return null; } - // Always fetch fresh data on hover const freshStatus = await fetchGitHubPRStatus(worktree.path); - // Update cache if we got data if (freshStatus) { localDb .update(worktrees) @@ -129,7 +144,6 @@ export const createGitStatusProcedures = () => { return null; } - // Extract worktree name from path (last segment) const worktreeName = worktree.path.split("/").pop() ?? worktree.branch; const branchName = worktree.branch; 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 6d6e16ccf10..4266da1e2b2 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -1090,6 +1090,31 @@ export async function checkNeedsRebase( return Number.parseInt(behindCount.trim(), 10) > 0; } +export async function getAheadBehindCount({ + repoPath, + defaultBranch, +}: { + repoPath: string; + defaultBranch: string; +}): Promise<{ ahead: number; behind: number }> { + const git = simpleGit(repoPath); + try { + const output = await git.raw([ + "rev-list", + "--left-right", + "--count", + `origin/${defaultBranch}...HEAD`, + ]); + const [behindStr, aheadStr] = output.trim().split(/\s+/); + return { + ahead: Number.parseInt(aheadStr || "0", 10), + behind: Number.parseInt(behindStr || "0", 10), + }; + } catch { + return { ahead: 0, behind: 0 }; + } +} + export async function hasUncommittedChanges( worktreePath: string, ): Promise { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts index e92733a836b..dde53a635d4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/workspace-init.ts @@ -118,6 +118,8 @@ export async function initializeWorkspaceWorktree({ gitStatus: { branch, needsRebase: false, + ahead: 0, + behind: 0, lastRefreshed: Date.now(), }, }) @@ -438,6 +440,8 @@ export async function initializeWorkspaceWorktree({ gitStatus: { branch, needsRebase: false, + ahead: 0, + behind: 0, lastRefreshed: Date.now(), }, }) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx new file mode 100644 index 00000000000..e9a2693cc50 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx @@ -0,0 +1,131 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import type { RefObject } from "react"; +import { LuCopy, LuX } from "react-icons/lu"; +import type { ActivePaneStatus } from "shared/tabs-types"; +import { STROKE_WIDTH } from "../constants"; +import { DeleteWorkspaceDialog, WorkspaceHoverCardContent } from "./components"; +import { HOVER_CARD_CLOSE_DELAY, HOVER_CARD_OPEN_DELAY } from "./constants"; +import { WorkspaceIcon } from "./WorkspaceIcon"; + +interface CollapsedWorkspaceItemProps { + id: string; + name: string; + branch: string; + type: "worktree" | "branch"; + isActive: boolean; + isUnread: boolean; + workspaceStatus: ActivePaneStatus | null; + itemRef: RefObject; + showDeleteDialog: boolean; + setShowDeleteDialog: (open: boolean) => void; + onMouseEnter: () => void; + onClick: () => void; + onDeleteClick: () => void; + onCopyPath: () => void; +} + +export function CollapsedWorkspaceItem({ + id, + name, + branch, + type, + isActive, + isUnread, + workspaceStatus, + itemRef, + showDeleteDialog, + setShowDeleteDialog, + onMouseEnter, + onClick, + onDeleteClick, + onCopyPath, +}: CollapsedWorkspaceItemProps) { + const isBranchWorkspace = type === "branch"; + + const collapsedButton = ( + + ); + + if (isBranchWorkspace) { + return ( + + {collapsedButton} + + local + + {branch} + + + + ); + } + + return ( + <> + + + + {collapsedButton} + + + + + Copy Path + + + onDeleteClick()}> + + Close Worktree + + + + + + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceAheadBehind.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceAheadBehind.tsx new file mode 100644 index 00000000000..df5a2c2d702 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceAheadBehind.tsx @@ -0,0 +1,20 @@ +interface WorkspaceAheadBehindProps { + ahead: number; + behind: number; +} + +export function WorkspaceAheadBehind({ + ahead, + behind, +}: WorkspaceAheadBehindProps) { + if (ahead === 0 && behind === 0) { + return null; + } + + return ( +
+ {behind > 0 && ↓{behind}} + {ahead > 0 && ↑{ahead}} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx index 8cda83a4bde..c408e454617 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx @@ -24,18 +24,15 @@ export function WorkspaceDiffStats({ : "bg-muted/50 group-hover:bg-transparent", )} > - {/* Diff stats - hidden on card hover when onClose provided */}
+{additions} −{deletions}
- {/* X icon - shown on card hover */} {onClose && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceIcon.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceIcon.tsx new file mode 100644 index 00000000000..73c4f3ac7b4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceIcon.tsx @@ -0,0 +1,66 @@ +import { cn } from "@superset/ui/utils"; +import { LuFolderGit2, LuLaptop } from "react-icons/lu"; +import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import type { ActivePaneStatus } from "shared/tabs-types"; +import { STROKE_WIDTH } from "../constants"; + +interface WorkspaceIconProps { + isBranchWorkspace: boolean; + isActive: boolean; + isUnread: boolean; + workspaceStatus: ActivePaneStatus | null; + variant: "collapsed" | "expanded"; +} + +const OVERLAY_POSITION = { + collapsed: "top-1 right-1", + expanded: "-top-0.5 -right-0.5", +} as const; + +export function WorkspaceIcon({ + isBranchWorkspace, + isActive, + isUnread, + workspaceStatus, + variant, +}: WorkspaceIconProps) { + const overlayPosition = OVERLAY_POSITION[variant]; + const iconColor = isActive ? "text-foreground" : "text-muted-foreground"; + + return ( + <> + {workspaceStatus === "working" ? ( + + ) : isBranchWorkspace ? ( + + ) : ( + + )} + {workspaceStatus && workspaceStatus !== "working" && ( + + + + )} + {isUnread && !workspaceStatus && ( + + + + )} + + ); +} 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 ee0e04438de..f7d9a86bdb2 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 @@ -22,11 +22,8 @@ import { LuCopy, LuEye, LuEyeOff, - LuFolderGit2, LuFolderOpen, - LuLaptop, LuPencil, - LuX, } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { @@ -34,8 +31,6 @@ import { useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; -import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import { useBranchSyncInvalidation } from "renderer/screens/main/hooks/useBranchSyncInvalidation"; import { useGitChangesStatus } from "renderer/screens/main/hooks/useGitChangesStatus"; import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; @@ -43,17 +38,28 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; import { getHighestPriorityStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; +import { CollapsedWorkspaceItem } from "./CollapsedWorkspaceItem"; import { DeleteWorkspaceDialog, WorkspaceHoverCardContent } from "./components"; import { + AHEAD_BEHIND_STALE_TIME, GITHUB_STATUS_STALE_TIME, HOVER_CARD_CLOSE_DELAY, HOVER_CARD_OPEN_DELAY, MAX_KEYBOARD_SHORTCUT_INDEX, } from "./constants"; +import { WorkspaceAheadBehind } from "./WorkspaceAheadBehind"; import { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +import { WorkspaceIcon } from "./WorkspaceIcon"; import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; -const WORKSPACE_TYPE = "WORKSPACE"; +const WORKSPACE_DND_TYPE = "WORKSPACE"; + +interface DragItem { + id: string; + projectId: string; + index: number; + originalIndex: number; +} interface WorkspaceListItemProps { id: string; @@ -65,7 +71,6 @@ interface WorkspaceListItemProps { isUnread?: boolean; index: number; shortcutIndex?: number; - /** Whether the sidebar is in collapsed mode (icon-only view) */ isCollapsed?: boolean; } @@ -111,9 +116,7 @@ export function WorkspaceListItem({ onError: (error) => toast.error(`Failed to open: ${error.message}`), }); const setUnread = electronTrpc.workspaces.setUnread.useMutation({ - onSuccess: () => { - utils.workspaces.getAllGrouped.invalidate(); - }, + onSuccess: () => utils.workspaces.getAllGrouped.invalidate(), onError: (error) => toast.error(`Failed to update unread status: ${error.message}`), }); @@ -121,7 +124,6 @@ export function WorkspaceListItem({ const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); - // Lazy-load on hover to avoid N+1 queries for every sidebar item const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( { workspaceId: id }, @@ -133,17 +135,25 @@ export function WorkspaceListItem({ const { status: localChanges } = useGitChangesStatus({ worktreePath, - enabled: hasHovered && type === "worktree", + enabled: hasHovered && !!worktreePath, staleTime: GITHUB_STATUS_STALE_TIME, }); + const { data: aheadBehind } = electronTrpc.workspaces.getAheadBehind.useQuery( + { workspaceId: id }, + { + enabled: isBranchWorkspace, + staleTime: AHEAD_BEHIND_STALE_TIME, + refetchInterval: AHEAD_BEHIND_STALE_TIME, + }, + ); + useBranchSyncInvalidation({ gitBranch: localChanges?.branch, workspaceBranch: branch, workspaceId: id, }); - // Prefer againstBase (committed diff vs base branch) over uncommitted changes only const localDiffStats = useMemo(() => { if (!localChanges) return null; const allFiles = @@ -160,21 +170,18 @@ export function WorkspaceListItem({ return { additions, deletions }; }, [localChanges]); - const workspacePaneIds = useMemo(() => { + const workspaceStatus = useMemo(() => { const workspaceTabs = tabs.filter((t) => t.workspaceId === id); - return new Set( + const paneIds = new Set( workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), ); - }, [tabs, id]); - - const workspaceStatus = useMemo(() => { function* paneStatuses() { - for (const paneId of workspacePaneIds) { + for (const paneId of paneIds) { yield panes[paneId]?.status; } } return getHighestPriorityStatus(paneStatuses()); - }, [panes, workspacePaneIds]); + }, [tabs, panes, id]); const handleClick = () => { if (!rename.isRenaming) { @@ -184,103 +191,71 @@ export function WorkspaceListItem({ }; const handleMouseEnter = () => { - if (!hasHovered) { - setHasHovered(true); - } + if (!hasHovered) setHasHovered(true); }; const handleOpenInFinder = () => { - if (worktreePath) { - openInFinder.mutate(worktreePath); - } - }; - - const handleToggleUnread = () => { - setUnread.mutate({ id, isUnread: !isUnread }); + if (worktreePath) openInFinder.mutate(worktreePath); }; const handleCopyPath = async () => { - if (worktreePath) { - try { - await navigator.clipboard.writeText(worktreePath); - toast.success("Path copied to clipboard"); - } catch { - toast.error("Failed to copy path"); - } + if (!worktreePath) return; + try { + await navigator.clipboard.writeText(worktreePath); + toast.success("Path copied to clipboard"); + } catch { + toast.error("Failed to copy path"); } }; + const handleReorder = (item: DragItem) => { + if (item.originalIndex === item.index) return; + reorderWorkspaces.mutate( + { + projectId: item.projectId, + fromIndex: item.originalIndex, + toIndex: item.index, + }, + { + onError: (error) => + toast.error(`Failed to reorder workspace: ${error.message}`), + onSettled: () => utils.workspaces.getAllGrouped.invalidate(), + }, + ); + }; + const [{ isDragging }, drag] = useDrag( () => ({ - type: WORKSPACE_TYPE, + type: WORKSPACE_DND_TYPE, item: { id, projectId, index, originalIndex: index }, end: (item, monitor) => { - if (!item || monitor.didDrop()) return; - if (item.originalIndex !== item.index) { - reorderWorkspaces.mutate( - { - projectId: item.projectId, - fromIndex: item.originalIndex, - toIndex: item.index, - }, - { - onError: (error) => - toast.error(`Failed to reorder workspace: ${error.message}`), - onSettled: () => utils.workspaces.getAllGrouped.invalidate(), - }, - ); - } + if (item && !monitor.didDrop()) handleReorder(item); }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), + collect: (monitor) => ({ isDragging: monitor.isDragging() }), }), [id, projectId, index, reorderWorkspaces], ); const [, drop] = useDrop({ - accept: WORKSPACE_TYPE, - hover: (item: { - id: string; - projectId: string; - index: number; - originalIndex: number; - }) => { - if (item.projectId === projectId && item.index !== index) { - utils.workspaces.getAllGrouped.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return oldData.map((group) => { - if (group.project.id !== projectId) return group; - const workspaces = [...group.workspaces]; - const [moved] = workspaces.splice(item.index, 1); - workspaces.splice(index, 0, moved); - return { ...group, workspaces }; - }); + accept: WORKSPACE_DND_TYPE, + hover: (item: DragItem) => { + if (item.projectId !== projectId || item.index === index) return; + utils.workspaces.getAllGrouped.setData(undefined, (oldData) => { + if (!oldData) return oldData; + return oldData.map((group) => { + if (group.project.id !== projectId) return group; + const workspaces = [...group.workspaces]; + const [moved] = workspaces.splice(item.index, 1); + workspaces.splice(index, 0, moved); + return { ...group, workspaces }; }); - item.index = index; - } + }); + item.index = index; }, - drop: (item: { - id: string; - projectId: string; - index: number; - originalIndex: number; - }) => { - if (item.projectId !== projectId) return; - if (item.originalIndex !== item.index) { - reorderWorkspaces.mutate( - { - projectId, - fromIndex: item.originalIndex, - toIndex: item.index, - }, - { - onError: (error) => - toast.error(`Failed to reorder workspace: ${error.message}`), - onSettled: () => utils.workspaces.getAllGrouped.invalidate(), - }, - ); - return { reordered: true }; + drop: (item: DragItem) => { + if (item.projectId === projectId) { + handleReorder(item); + if (item.originalIndex !== item.index) return { reordered: true }; } }, }); @@ -291,114 +266,32 @@ export function WorkspaceListItem({ (pr && (pr.additions > 0 || pr.deletions > 0) ? { additions: pr.additions, deletions: pr.deletions } : null); - const showDiffStats = !!diffStats; const showBranchSubtitle = isBranchWorkspace || (!!name && name !== branch); if (isCollapsed) { - const collapsedButton = ( - - ); - - // Branch workspaces get a simple tooltip - if (isBranchWorkspace) { - return ( - - {collapsedButton} - - local - - {branch} - - - - ); - } - - // Worktree workspaces get the full hover card with context menu return ( - <> - - - - {collapsedButton} - - - - - Copy Path - - - handleDeleteClick()}> - - Close Worktree - - - - - - - - - + ); } const content = ( - // biome-ignore lint/a11y/useSemanticElements: Can't use