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
64 changes: 63 additions & 1 deletion src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,46 @@ import type { BranchGroup } from '@/lib/sidebar-utils';
/** LocalStorage key for group collapsed state */
const SIDEBAR_GROUP_COLLAPSED_STORAGE_KEY = 'mcbd-sidebar-group-collapsed';

/** LocalStorage key for branch list scroll position */
const SIDEBAR_SCROLL_TOP_STORAGE_KEY = 'mcbd-sidebar-scroll-top';

/** In-memory cache used across client-side remounts */
let lastSidebarScrollTop = 0;

function readSidebarScrollTop(): number {
if (typeof window === 'undefined') return lastSidebarScrollTop;

try {
const stored = localStorage.getItem(SIDEBAR_SCROLL_TOP_STORAGE_KEY);
if (!stored) {
lastSidebarScrollTop = 0;
return 0;
}

const parsed = Number(stored);
if (Number.isFinite(parsed) && parsed >= 0) {
lastSidebarScrollTop = parsed;
}
} catch {
// Ignore localStorage errors
}

return lastSidebarScrollTop;
}

function persistSidebarScrollTop(scrollTop: number): void {
const normalized = Number.isFinite(scrollTop) && scrollTop > 0 ? scrollTop : 0;
lastSidebarScrollTop = normalized;

if (typeof window === 'undefined') return;

try {
localStorage.setItem(SIDEBAR_SCROLL_TOP_STORAGE_KEY, String(normalized));
} catch {
// Ignore localStorage errors
}
}

// ============================================================================
// Component
// ============================================================================
Expand All @@ -68,6 +108,7 @@ export const Sidebar = memo(function Sidebar() {
const { worktrees, selectedWorktreeId, selectWorktree, refreshWorktrees } = useWorktreeSelection();
const { closeMobileDrawer, sortKey, sortDirection, viewMode, setViewMode } = useSidebarContext();
const [searchQuery, setSearchQuery] = useState('');
const branchListRef = useRef<HTMLDivElement>(null);

// Group collapsed state with localStorage sync
const [groupCollapsed, setGroupCollapsed] = useState<Record<string, boolean>>(() => {
Expand Down Expand Up @@ -156,16 +197,35 @@ export const Sidebar = memo(function Sidebar() {
}));
}, []);

const saveBranchListScroll = useCallback(() => {
persistSidebarScrollTop(branchListRef.current?.scrollTop ?? 0);
}, []);

// Restore saved scroll position after the list content has rendered.
useEffect(() => {
const branchList = branchListRef.current;
if (!branchList) return;

const frameId = window.requestAnimationFrame(() => {
branchList.scrollTop = readSidebarScrollTop();
});

return () => {
window.cancelAnimationFrame(frameId);
};
}, [flatBranches.length, groupedBranches?.length, viewMode]);

// Handle branch selection.
// Note: no fallback timer — Next.js App Router defers history.pushState to a React
// effect, so window.location.pathname does not update synchronously with router.push().
// A fallback timer that checks window.location.pathname would fire before the URL
// updates and trigger a spurious full-page reload on every navigation.
const handleBranchClick = useCallback((branchId: string) => {
saveBranchListScroll();
selectWorktree(branchId);
router.push(`/worktrees/${branchId}`);
closeMobileDrawer();
}, [selectWorktree, router, closeMobileDrawer]);
}, [saveBranchListScroll, selectWorktree, router, closeMobileDrawer]);

// DnD sensors: require 8px move before activating (distinguishes click from drag)
const sensors = useSensors(
Expand Down Expand Up @@ -246,7 +306,9 @@ export const Sidebar = memo(function Sidebar() {

{/* Branch list */}
<div
ref={branchListRef}
data-testid="branch-list"
onScroll={saveBranchListScroll}
className="flex-1 overflow-y-auto"
>
{isEmpty ? (
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/components/layout/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,48 @@ describe('Sidebar', () => {
fireEvent.click(branchItem!);
expect(mockPush).toHaveBeenCalledWith('/worktrees/feature-test-2');
});

it('should save branch list scroll position on click', async () => {
render(
<Wrapper>
<Sidebar />
</Wrapper>
);

const branchList = await screen.findByTestId('branch-list');
branchList.scrollTop = 180;

const branchItem = screen.getAllByText('feature/test-2')[0].closest('[data-testid="branch-list-item"]');
expect(branchItem).not.toBeNull();

fireEvent.click(branchItem!);

expect(localStorage.getItem('mcbd-sidebar-scroll-top')).toBe('180');
});

it('should restore branch list scroll position after remount', async () => {
const firstRender = render(
<Wrapper>
<Sidebar />
</Wrapper>
);

const firstBranchList = await screen.findByTestId('branch-list');
firstBranchList.scrollTop = 240;
fireEvent.scroll(firstBranchList);

firstRender.unmount();

render(
<Wrapper>
<Sidebar />
</Wrapper>
);

await waitFor(() => {
expect(screen.getByTestId('branch-list').scrollTop).toBe(240);
});
});
});

describe('Search filtering', () => {
Expand Down
Loading