diff --git a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffView.tsx b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffView.tsx index dcdf1c9b4b2..d42f26ae9f6 100644 --- a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffView.tsx @@ -28,6 +28,9 @@ interface DiffViewProps { onRefresh?: () => void; isRefreshing?: boolean; onClose?: () => void; + hideFileTree?: boolean; + externalSelectedFile?: string | null; + onFileSelect?: (fileId: string) => void; } export function DiffView({ @@ -35,15 +38,22 @@ export function DiffView({ onRefresh, isRefreshing = false, onClose, + hideFileTree = false, + externalSelectedFile = null, + onFileSelect: externalOnFileSelect, }: DiffViewProps) { const [viewMode, setViewMode] = useState("files"); - const [selectedFile, setSelectedFile] = useState( + const [internalSelectedFile, setInternalSelectedFile] = useState( data.files[0]?.id || null, ); - const [showFileTree, setShowFileTree] = useState(true); + const [showFileTree, setShowFileTree] = useState(!hideFileTree); const scrollContainerRef = useRef(null); const isScrollingProgrammatically = useRef(false); + // Use external selected file if provided, otherwise use internal state + const selectedFile = externalSelectedFile !== null ? externalSelectedFile : internalSelectedFile; + const setSelectedFile = externalOnFileSelect || setInternalSelectedFile; + const getFileIcon = (status: FileDiff["status"]) => { switch (status) { case "added": @@ -338,44 +348,46 @@ export function DiffView({ ) : ( // Files changed view - scrollable list of all files (GitHub style) <> - {/* File tree sidebar - kept mounted, hidden with display:none for instant toggle */} -
-
-
-

Files

-
- - - - - -

Hide file tree

-
-
+ {/* File tree sidebar - hidden when hideFileTree prop is true */} + {!hideFileTree && ( +
+
+
+

Files

+
+ + + + + +

Hide file tree

+
+
+
+
+
+
-
-
-
-
+ )} {/* All files diff content - scrollable */}
- {!showFileTree && ( + {!hideFileTree && !showFileTree && (
@@ -843,11 +804,10 @@ export const NewLayoutMain: React.FC = () => { } }} workspaceId={currentWorkspace?.id || null} - workspaces={workspaces || undefined} - currentWorkspace={currentWorkspace} - onWorkspaceSelect={handleWorkspaceSelect} - onAddWorkspace={handleAddWorkspace} - onRemoveWorkspace={handleRemoveWorkspace} + workspaceName={currentWorkspace?.name} + mainBranch={currentWorkspace?.branch} + onDiffFileSelect={setSelectedDiffFile} + onModeChange={setSidebarMode} /> )} @@ -859,13 +819,32 @@ export const NewLayoutMain: React.FC = () => { {loading || error || !currentWorkspace || - !selectedTab || !selectedWorktree ? ( + ) : sidebarMode === "changes" ? ( + // Changes mode - always show diff view +
+ +
+ ) : !selectedTab ? ( + ) : parentGroupTab ? ( // Selected tab is a sub-tab of a group → display the parent group's mosaic { worktree={selectedWorktree} workspaceName={currentWorkspace.name} mainBranch={currentWorkspace.branch} + selectedDiffFile={selectedDiffFile} + onDiffFileSelect={setSelectedDiffFile} />
) : ( diff --git a/apps/desktop/src/renderer/screens/main/components/NewLayout/WorktreeTabsSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/NewLayout/WorktreeTabsSidebar.tsx index 52deec4926b..e1af00497f4 100644 --- a/apps/desktop/src/renderer/screens/main/components/NewLayout/WorktreeTabsSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/NewLayout/WorktreeTabsSidebar.tsx @@ -1,11 +1,38 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { type MotionValue, useMotionValue } from "framer-motion"; -import { Monitor, Plus, Terminal as TerminalIcon, X } from "lucide-react"; +import { motion, useMotionValue, useTransform } from "framer-motion"; +import { + File, + FileEdit, + FilePlus, + FileText, + FileX, + GitBranch, + Monitor, + Plus, + RefreshCw, + Terminal as TerminalIcon, + X, +} from "lucide-react"; import type React from "react"; -import type { Tab, Workspace, Worktree } from "shared/types"; -import { WorkspaceCarousel } from "../Sidebar/components/WorkspaceCarousel"; -import { WorkspaceSwitcher } from "../Sidebar/components/WorkspaceSwitcher"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Tab, Worktree } from "shared/types"; +import { FileTree } from "../DiffView/FileTree"; +import type { DiffViewData, FileDiff } from "../DiffView/types"; + +// Define sidebar modes +type SidebarMode = "tabs" | "changes"; + +interface Mode { + id: SidebarMode; + name: string; + icon: React.ComponentType<{ size?: number }>; +} + +const SIDEBAR_MODES: Mode[] = [ + { id: "tabs", name: "Tabs", icon: FileText }, + { id: "changes", name: "Changes", icon: GitBranch }, +]; interface WorktreeTabsSidebarProps { worktree: Worktree | null; @@ -15,12 +42,10 @@ interface WorktreeTabsSidebarProps { onCreateTerminal: () => void; onCreatePreview: () => void; workspaceId: string | null; - // New props for workspace carousel - workspaces?: Workspace[]; - currentWorkspace?: Workspace | null; - onWorkspaceSelect?: (workspaceId: string) => void; - onAddWorkspace?: () => void; - onRemoveWorkspace?: (workspaceId: string, workspaceName: string) => void; + workspaceName?: string; + mainBranch?: string; + onDiffFileSelect?: (fileId: string | null) => void; + onModeChange?: (mode: SidebarMode) => void; } export const WorktreeTabsSidebar: React.FC = ({ @@ -31,14 +56,183 @@ export const WorktreeTabsSidebar: React.FC = ({ onCreateTerminal, onCreatePreview, workspaceId, - workspaces, - currentWorkspace, - onWorkspaceSelect, - onAddWorkspace, - onRemoveWorkspace, + workspaceName, + mainBranch, + onDiffFileSelect, + onModeChange, }) => { + const [currentMode, setCurrentMode] = useState("tabs"); const scrollProgress = useMotionValue(0); + const scrollContainerRef = useRef(null); + const scrollTimeoutRef = useRef(undefined); + const isInitialMount = useRef(true); + const backgroundX = useMotionValue(0); + + // Diff/changes state + const [diffData, setDiffData] = useState(null); + const [loadingDiff, setLoadingDiff] = useState(false); + const [diffError, setDiffError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + + const tabs = worktree?.tabs || []; + const currentModeIndex = SIDEBAR_MODES.findIndex((m) => m.id === currentMode); + + // Track scroll position and update motion value + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + let rafId: number | undefined; + + const updateProgress = () => { + const scrollLeft = scrollContainer.scrollLeft; + const containerWidth = scrollContainer.offsetWidth; + const progress = scrollLeft / containerWidth; + scrollProgress.set(progress); + }; + + const handleScroll = () => { + if (rafId !== undefined) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(updateProgress); + }; + + scrollContainer.addEventListener("scroll", handleScroll, { passive: true }); + updateProgress(); + + return () => { + scrollContainer.removeEventListener("scroll", handleScroll); + if (rafId !== undefined) { + cancelAnimationFrame(rafId); + } + }; + }, [scrollProgress]); + + // Scroll to current mode when it changes + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + const targetScrollX = currentModeIndex * scrollContainer.offsetWidth; + + if (Math.abs(scrollContainer.scrollLeft - targetScrollX) > 10) { + scrollContainer.scrollTo({ + left: targetScrollX, + behavior: isInitialMount.current ? "auto" : "smooth", + }); + } + + isInitialMount.current = false; + }, [currentModeIndex]); + + // Detect when user finishes scrolling and update current mode + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + const handleScroll = () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + scrollTimeoutRef.current = setTimeout(() => { + const scrollLeft = scrollContainer.scrollLeft; + const containerWidth = scrollContainer.offsetWidth; + const newIndex = Math.round(scrollLeft / containerWidth); + + if ( + newIndex >= 0 && + newIndex < SIDEBAR_MODES.length && + SIDEBAR_MODES[newIndex].id !== currentMode + ) { + const newMode = SIDEBAR_MODES[newIndex].id; + setCurrentMode(newMode); + onModeChange?.(newMode); + } + }, 150); + }; + + scrollContainer.addEventListener("scroll", handleScroll); + + return () => { + scrollContainer.removeEventListener("scroll", handleScroll); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; + }, [currentMode, onModeChange]); + + // Calculate sliding background position (each button is 40px wide including gap) + useEffect(() => { + const unsubscribe = scrollProgress.on("change", (latest) => { + backgroundX.set(latest * 40); + }); + return unsubscribe; + }, [scrollProgress, backgroundX]); + + // Create opacity transforms for each mode (must be outside render loop) + const mode0Opacity = useTransform( + scrollProgress, + [-0.5, 0, 0.5], + [0.4, 1, 0.4], + ); + const mode1Opacity = useTransform( + scrollProgress, + [0.5, 1, 1.5], + [0.4, 1, 0.4], + ); + + const modeOpacities = [mode0Opacity, mode1Opacity]; + + // Load diff data when switching to Changes mode + const loadDiff = useCallback(async () => { + if (!workspaceId || !worktree?.id) return; + + setLoadingDiff(true); + setDiffError(null); + + try { + const result = await window.ipcRenderer.invoke("worktree-get-git-diff", { + workspaceId, + worktreeId: worktree.id, + }); + + if (result?.success && result.diff) { + const diffViewData: DiffViewData = { + title: `Changes in ${worktree.branch}`, + description: workspaceName, + timestamp: new Date().toLocaleString(), + files: result.diff.files, + }; + setDiffData(diffViewData); + if (diffViewData.files.length > 0 && !selectedFile) { + setSelectedFile(diffViewData.files[0].id); + } + } else { + setDiffError(result?.error || "Failed to load diff"); + } + } catch (err) { + setDiffError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoadingDiff(false); + } + }, [ + workspaceId, + worktree?.id, + worktree?.branch, + workspaceName, + selectedFile, + ]); + + // Load diff when switching to Changes mode + useEffect(() => { + if (currentMode === "changes" && !diffData && !loadingDiff) { + loadDiff(); + } + }, [currentMode, diffData, loadingDiff, loadDiff]); + // Early return after all hooks if (!worktree || !workspaceId) { return (
@@ -47,17 +241,6 @@ export const WorktreeTabsSidebar: React.FC = ({ ); } - const tabs = worktree.tabs || []; - - // Check if workspace carousel should be shown - const showWorkspaceCarousel = - workspaces && - workspaces.length > 1 && - currentWorkspace && - onWorkspaceSelect && - onAddWorkspace && - onRemoveWorkspace; - // Helper to get icon for tab type const getTabIcon = (tab: Tab) => { switch (tab.type) { @@ -66,14 +249,31 @@ export const WorktreeTabsSidebar: React.FC = ({ case "preview": return ; case "diff": - return ; + return ; default: return ; } }; + // Helper to get icon for file diff status + const getFileIcon = (status: FileDiff["status"]) => { + switch (status) { + case "added": + return ; + case "deleted": + return ; + case "modified": + return ; + default: + return ; + } + }; + // Flatten tabs recursively (handle group tabs) - const flattenTabs = (tabs: Tab[], level = 0): Array<{ tab: Tab; level: number }> => { + const flattenTabs = ( + tabs: Tab[], + level = 0, + ): Array<{ tab: Tab; level: number }> => { const result: Array<{ tab: Tab; level: number }> = []; for (const tab of tabs) { result.push({ tab, level }); @@ -86,132 +286,258 @@ export const WorktreeTabsSidebar: React.FC = ({ const flatTabs = flattenTabs(tabs); - const renderSidebarContent = (workspace: Workspace | null, isActive: boolean) => { - if (!workspace) return null; + // Filter out diff tabs - they should only be accessed through Changes mode + const nonDiffTabs = flatTabs.filter(({ tab }) => tab.type !== "diff"); - // Find the worktree for this workspace - const workspaceWorktree = workspace.worktrees?.[0] || null; - const workspaceTabs = workspaceWorktree?.tabs || []; - const workspaceFlatTabs = flattenTabs(workspaceTabs); + // Render tabs mode content + const renderTabsMode = () => ( + <> + {/* Header with actions */} +
+

Tabs

+
+ + + + + +

New Terminal

+
+
+ + + + + +

New Preview

+
+
+
+
- return ( - <> - {/* Header with actions */} -
-

Tabs

-
- - - - - -

New Terminal

-
-
- - - - - -

New Preview

-
-
+ {/* Tab list */} +
+ {nonDiffTabs.length === 0 ? ( +
+ No tabs yet. Create a terminal or preview to get started.
+ ) : ( +
+ {nonDiffTabs.map(({ tab, level }) => ( + + )} + + ))} +
+ )} +
+ + ); + + // Render changes mode content + const renderChangesMode = () => ( + <> + {/* Header with actions */} +
+

Changes

+
+ + + + + +

Refresh changes

+
+
+
- {/* Tab list */} -
- {workspaceFlatTabs.length === 0 ? ( -
- No tabs yet. Create a terminal or preview to get started. + {/* Changes content */} +
+ {loadingDiff ? ( +
+
+
+

Loading changes...

- ) : ( -
- {workspaceFlatTabs.map(({ tab, level }) => ( - - )} - - ))} +
+ ) : diffError ? ( +
+

{diffError}

+ +
+ ) : !diffData || diffData.files.length === 0 ? ( +
+

No changes

+

+ No uncommitted changes in this worktree +

+
+ ) : ( +
+
+

+ Files Changed • {diffData.files.length} +

- )} -
- - ); - }; +
+ { + setSelectedFile(fileId); + onDiffFileSelect?.(fileId); + }} + getFileIcon={getFileIcon} + /> +
+
+ )} +
+ + ); return (
- {/* Workspace Switcher - only show if multiple workspaces */} - {showWorkspaceCarousel && workspaces && currentWorkspace && onWorkspaceSelect && onAddWorkspace && onRemoveWorkspace && ( - - )} - - {/* Workspace Carousel - horizontal scroll between workspaces */} - {showWorkspaceCarousel && workspaces && currentWorkspace && onWorkspaceSelect ? ( - ) => { - // Update scroll progress for the switcher - scrollProgress.set(progress.get()); - }} + {/* Mode Switcher */} +
+
+ {SIDEBAR_MODES.map((mode) => { + const Icon = mode.icon; + const isActive = currentMode === mode.id; + + return ( + + + + + +

{mode.name}

+
+
+ ); + })} +
+
+ + {/* Mode Content Carousel */} +
+
- {renderSidebarContent} - - ) : ( - // Single workspace - no carousel needed - renderSidebarContent(currentWorkspace || null, true) - )} + {/* Tabs Mode */} +
+ {renderTabsMode()} +
+ + {/* Changes Mode */} +
+ {renderChangesMode()} +
+
+
); }; diff --git a/apps/desktop/src/renderer/screens/main/components/TabContent/components/DiffTab/DiffTab.tsx b/apps/desktop/src/renderer/screens/main/components/TabContent/components/DiffTab/DiffTab.tsx index b30a6ef9f62..a38ced19469 100644 --- a/apps/desktop/src/renderer/screens/main/components/TabContent/components/DiffTab/DiffTab.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TabContent/components/DiffTab/DiffTab.tsx @@ -13,6 +13,8 @@ interface DiffTabProps { workspaceName?: string; mainBranch?: string; onClose?: () => void; + selectedDiffFile?: string | null; + onDiffFileSelect?: (fileId: string) => void; } export function DiffTab({ @@ -23,6 +25,8 @@ export function DiffTab({ workspaceName, mainBranch, onClose, + selectedDiffFile, + onDiffFileSelect, }: DiffTabProps) { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -159,6 +163,9 @@ export function DiffTab({ onRefresh={handleRefresh} isRefreshing={refreshing} onClose={onClose} + hideFileTree={!!selectedDiffFile} + externalSelectedFile={selectedDiffFile || null} + onFileSelect={onDiffFileSelect} />
); diff --git a/apps/website/src/trpc/server.tsx b/apps/website/src/trpc/server.tsx index 1ed2bc8484f..f6b899fd3bf 100644 --- a/apps/website/src/trpc/server.tsx +++ b/apps/website/src/trpc/server.tsx @@ -1,12 +1,12 @@ -import 'server-only'; +import "server-only"; -import type { TRPCQueryOptions } from '@trpc/tanstack-react-query'; -import { cache } from 'react'; -import { headers } from 'next/headers'; -import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; -import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'; -import { appRouter, createTRPCContext, type AppRouter } from '@superset/api'; -import { createQueryClient } from './query-client'; +import { type AppRouter, appRouter, createTRPCContext } from "@superset/api"; +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; +import type { TRPCQueryOptions } from "@trpc/tanstack-react-query"; +import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; +import { headers } from "next/headers"; +import { cache } from "react"; +import { createQueryClient } from "./query-client"; /** * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when @@ -14,7 +14,7 @@ import { createQueryClient } from './query-client'; */ const createContext = cache(async () => { const heads = new Headers(await headers()); - heads.set('x-trpc-source', 'rsc'); + heads.set("x-trpc-source", "rsc"); return createTRPCContext({ headers: heads, @@ -43,7 +43,7 @@ export function prefetch>>( queryOptions: T, ) { const queryClient = getQueryClient(); - if (queryOptions.queryKey[1]?.type === 'infinite') { + if (queryOptions.queryKey[1]?.type === "infinite") { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any void queryClient.prefetchInfiniteQuery(queryOptions as any); } else {