Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import type { Tab, Workspace, Worktree } from "shared/types";
import {
CreateWorktreeButton,
CreateWorktreeModal,
SidebarHeader,
WorkspaceCarousel,
WorkspacePortIndicator,
WorkspaceSwitcher,
WorktreeList,
} from "./components";
import { ModeCarousel, type SidebarMode } from "./components/ModeCarousel";
import { ModeSwitcher } from "./components/ModeSwitcher";

interface SidebarProps {
workspaces: Workspace[];
Expand Down Expand Up @@ -52,17 +50,16 @@ export function Sidebar({
const [description, setDescription] = useState("");
const [setupStatus, setSetupStatus] = useState<string | undefined>(undefined);
const [setupOutput, setSetupOutput] = useState<string | undefined>(undefined);
const [currentMode, setCurrentMode] = useState<SidebarMode>("tabs");

// Initialize with current workspace index
const currentIndex = workspaces.findIndex(
(w) => w.id === currentWorkspace?.id,
);
const initialIndex = currentIndex >= 0 ? currentIndex : 0;
const defaultScrollProgress = useMotionValue(initialIndex);
// Initialize scroll progress
const defaultScrollProgress = useMotionValue(0);
const [scrollProgress, setScrollProgress] = useState<MotionValue<number>>(
defaultScrollProgress,
);

const modes: SidebarMode[] = ["tabs", "diff"];

// Auto-expand worktree if it contains the selected tab
useEffect(() => {
if (currentWorkspace && selectedTabId) {
Expand Down Expand Up @@ -291,40 +288,54 @@ export function Sidebar({

return (
<div className="flex flex-col h-full w-full select-none text-neutral-300 text-sm">
<WorkspaceCarousel
workspaces={workspaces}
currentWorkspace={currentWorkspace}
onWorkspaceSelect={onWorkspaceSelect}
<ModeSwitcher
modes={modes}
currentMode={currentMode}
onModeSelect={setCurrentMode}
scrollProgress={scrollProgress}
/>
<ModeCarousel
modes={modes}
currentMode={currentMode}
onModeSelect={setCurrentMode}
onScrollProgress={setScrollProgress}
isDragging={isDragging}
>
{(workspace, isActive) => (
<>
<WorktreeList
currentWorkspace={workspace}
expandedWorktrees={expandedWorktrees}
onToggleWorktree={toggleWorktree}
onTabSelect={onTabSelect}
onReload={onWorktreeCreated}
onUpdateWorktree={onUpdateWorktree}
selectedTabId={selectedTabId}
onCloneWorktree={handleCloneWorktree}
onShowDiff={onShowDiff}
selectedWorktreeId={
selectedWorktreeId ?? currentWorkspace?.activeWorktreeId
}
showWorkspaceHeader={true}
/>
{(mode, isActive) => {
if (mode === "diff") {
// Diff mode - empty for now
return <div className="flex-1" />;
}

{workspace && (
<CreateWorktreeButton
onClick={handleCreateWorktree}
isCreating={isCreatingWorktree}
// Tabs mode - show worktree list
return (
<>
<WorktreeList
currentWorkspace={currentWorkspace}
expandedWorktrees={expandedWorktrees}
onToggleWorktree={toggleWorktree}
onTabSelect={onTabSelect}
onReload={onWorktreeCreated}
onUpdateWorktree={onUpdateWorktree}
selectedTabId={selectedTabId}
onCloneWorktree={handleCloneWorktree}
onShowDiff={onShowDiff}
selectedWorktreeId={
selectedWorktreeId ?? currentWorkspace?.activeWorktreeId
}
showWorkspaceHeader={true}
/>
)}
</>
)}
</WorkspaceCarousel>

{currentWorkspace && (
<CreateWorktreeButton
onClick={handleCreateWorktree}
isCreating={isCreatingWorktree}
/>
)}
</>
);
}}
</ModeCarousel>

<CreateWorktreeModal
isOpen={showWorktreeModal}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { type MotionValue, useMotionValue } from "framer-motion";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";

export type SidebarMode = "tabs" | "diff";

interface ModeCarouselProps {
modes: SidebarMode[];
currentMode: SidebarMode;
onModeSelect: (mode: SidebarMode) => void;
children: (mode: SidebarMode, isActive: boolean) => ReactNode;
onScrollProgress: (progress: MotionValue<number>) => void;
isDragging?: boolean;
}

export function ModeCarousel({
modes,
currentMode,
onModeSelect,
children,
onScrollProgress,
isDragging = false,
}: ModeCarouselProps) {
const scrollTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isInitialMount = useRef(true);

const currentIndex = modes.findIndex((m) => m === currentMode);
const initialProgress = currentIndex >= 0 ? currentIndex : 0;
const modeProgress = useMotionValue(initialProgress);

const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(
null,
);

// Use callback ref to get notified when the ref is attached
const scrollContainerRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
setScrollContainer(node);
}
}, []);

// Track scroll position and update motion value
useEffect(() => {
if (!scrollContainer) return;

let rafId: number | undefined;

const updateProgress = () => {
const scrollLeft = scrollContainer.scrollLeft;
const containerWidth = scrollContainer.offsetWidth;
const progress = scrollLeft / containerWidth;
modeProgress.set(progress);
};

const handleScroll = () => {
// Use requestAnimationFrame for smooth updates
if (rafId !== undefined) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(updateProgress);
};

scrollContainer.addEventListener("scroll", handleScroll, { passive: true });

// Initial value
updateProgress();

return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
if (rafId !== undefined) {
cancelAnimationFrame(rafId);
}
};
}, [scrollContainer, modeProgress]);

// Expose scroll progress to parent
useEffect(() => {
onScrollProgress(modeProgress);
}, [onScrollProgress, modeProgress]);

// Scroll to current mode when it changes externally
useEffect(() => {
if (!scrollContainer || currentIndex < 0) return;

const targetScrollX = currentIndex * scrollContainer.offsetWidth;

// Only scroll if we're not already at the target position
if (Math.abs(scrollContainer.scrollLeft - targetScrollX) > 10) {
scrollContainer.scrollTo({
left: targetScrollX,
behavior: isInitialMount.current ? "auto" : "smooth",
});
}

// Mark that initial mount is complete
isInitialMount.current = false;
}, [currentIndex, scrollContainer]);

// Detect when user finishes scrolling and update current mode
useEffect(() => {
if (!scrollContainer || isDragging) return;

const handleScroll = () => {
// Clear existing timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}

// Wait for scroll to settle (150ms after last scroll event)
scrollTimeoutRef.current = setTimeout(() => {
const scrollLeft = scrollContainer.scrollLeft;
const containerWidth = scrollContainer.offsetWidth;

// Calculate which mode we're closest to
const newIndex = Math.round(scrollLeft / containerWidth);

// Update mode if it changed
if (
newIndex >= 0 &&
newIndex < modes.length &&
modes[newIndex] &&
modes[newIndex] !== currentMode
) {
onModeSelect(modes[newIndex]);
}
}, 150);
};

scrollContainer.addEventListener("scroll", handleScroll);

return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [modes, currentMode, onModeSelect, scrollContainer, isDragging]);

// If only one mode or no modes, disable carousel
if (modes.length <= 1) {
return (
<div className="flex-1 overflow-y-auto px-3">
{children(currentMode, true)}
</div>
);
}

return (
<div
ref={scrollContainerRef}
className="flex-1 overflow-x-scroll overflow-y-hidden hide-scrollbar"
style={{
scrollSnapType: isDragging ? "none" : "x mandatory",
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
msOverflowStyle: "none",
pointerEvents: isDragging ? "none" : "auto",
}}
>
<div
className="flex h-full"
style={{ width: `${modes.length * 100}%` }}
>
{modes.map((mode) => (
<div
key={mode}
className="overflow-y-auto px-3"
style={{
scrollSnapAlign: "start",
scrollSnapStop: "always",
width: `${100 / modes.length}%`,
}}
>
{children(mode, mode === currentMode)}
</div>
))}
</div>
</div>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ModeCarousel, type SidebarMode } from "./ModeCarousel";

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Button } from "@superset/ui/button";
import { type MotionValue, motion, useTransform } from "framer-motion";
import type { SidebarMode } from "../ModeCarousel";

interface ModeSwitcherProps {
modes: SidebarMode[];
currentMode: SidebarMode;
onModeSelect: (mode: SidebarMode) => void;
scrollProgress: MotionValue<number>;
}

const modeLabels: Record<SidebarMode, string> = {
tabs: "Tabs",
diff: "Diffs",
};

export function ModeSwitcher({
modes,
currentMode,
onModeSelect,
scrollProgress,
}: ModeSwitcherProps) {
// Calculate sliding background position from scroll progress
// scrollProgress is 0-1 (0 = tabs, 1 = diff), and we have 2 modes, so each mode is 50% width
// Transform to percentage: 0 -> 0%, 1 -> 50%
const backgroundX = useTransform(scrollProgress, (value) => `${value * 50}%`);

return (
<div className="flex w-full border-b border-neutral-800/50">
<div className="relative flex items-center w-full">
{/* Sliding background indicator */}
<motion.div
className="absolute w-1/2 h-full bg-neutral-800/50"
style={{ left: backgroundX }}
initial={false}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
/>

{modes.map((mode) => (
<Button
key={mode}
variant="ghost"
onClick={() => onModeSelect(mode)}
className={`relative z-10 flex-1 h-8 rounded-none text-xs font-medium transition-colors ${
currentMode === mode
? "text-neutral-200"
: "text-neutral-400 hover:text-neutral-300"
}`}
>
{modeLabels[mode]}
</Button>
))}
</div>
</div>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ModeSwitcher } from "./ModeSwitcher";

Loading