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
131 changes: 121 additions & 10 deletions apps/staged/src/lib/features/projects/ProjectsSidebar.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onDestroy, onMount, tick } from 'svelte';
import { quintIn } from 'svelte/easing';
import { fade } from 'svelte/transition';
import House from '@lucide/svelte/icons/house';
Expand Down Expand Up @@ -32,6 +32,7 @@
import {
hydrateProjectsSidebarState,
projectsSidebarState,
setProjectsSidebarScrollTop,
setProjectsSidebarWidth,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
Expand Down Expand Up @@ -72,7 +73,12 @@
onRemoveProject,
}: Props = $props();

let lastNavigationKey = `${navigation.activeView}:${navigation.selectedProjectId ?? ''}`;
let sidebarBodyEl = $state<HTMLDivElement | null>(null);
let activeProjectRowEl = $state<HTMLElement | null>(null);
let sidebarScrollRestored = $state(false);
let restoreInProgress = false;
let restoreToken = 0;
let trackedSidebarBodyEl: HTMLDivElement | null = null;

// ── Pinned repos ──
let pinnedRepos = $state<RepoHomeItem[]>([]);
Expand Down Expand Up @@ -156,14 +162,110 @@
}

function scrollIfActive(node: HTMLElement, active: boolean) {
if (active) node.scrollIntoView({ block: 'nearest' });
let currentActive = active;
if (active) {
activeProjectRowEl = node;
}
Comment on lines +166 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scroll newly mounted active rows into view

When the sidebar has already restored and a selected row is mounted for the first time, this branch only records the node and never scrolls it; the update hook will not run because the row is active on its initial render. For example, handleProjectCreated appends a new project and immediately selects it while the sidebar stays mounted, so in a long sidebar the newly selected project can remain offscreen, regressing the previous mount-time scrollIntoView behavior. Consider scrolling here when sidebarScrollRestored is already true.

Useful? React with 👍 / 👎.

return {
update(nextActive: boolean) {
if (currentActive === nextActive) return;
currentActive = nextActive;
if (nextActive) {
activeProjectRowEl = node;
if (sidebarScrollRestored) {
node.scrollIntoView({ block: 'nearest' });
}
} else if (activeProjectRowEl === node) {
activeProjectRowEl = null;
}
},
destroy() {
if (activeProjectRowEl === node) {
activeProjectRowEl = null;
}
},
};
}

function handleSidebarScroll() {
if (!sidebarBodyEl) return;
setProjectsSidebarScrollTop(sidebarBodyEl.scrollTop);
}

function saveSidebarScrollTopFromNode(node: HTMLDivElement) {
const scrollTop = node.scrollTop;
const canTrustScrollTop = scrollTop > 0 || node.clientHeight > 0 || node.scrollHeight > 0;

if (!canTrustScrollTop) {
return;
}

setProjectsSidebarScrollTop(scrollTop);
}

function trackSidebarBody(node: HTMLDivElement) {
sidebarBodyEl = node;
trackedSidebarBodyEl = node;
sidebarScrollRestored = false;

return {
update(active: boolean) {
if (active) node.scrollIntoView({ block: 'nearest' });
destroy() {
saveSidebarScrollTopFromNode(node);
if (sidebarBodyEl === node) {
sidebarBodyEl = null;
sidebarScrollRestored = false;
}
if (trackedSidebarBodyEl === node) {
trackedSidebarBodyEl = null;
}
},
};
}

function isFullyVisibleInSidebar(node: HTMLElement): boolean {
if (!sidebarBodyEl) return true;

const sidebarRect = sidebarBodyEl.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
return nodeRect.top >= sidebarRect.top && nodeRect.bottom <= sidebarRect.bottom;
}

async function restoreSidebarScrollPosition() {
if (!sidebarBodyEl || restoreInProgress) return;

restoreInProgress = true;
sidebarScrollRestored = false;
const token = ++restoreToken;
const requestedScrollTop = projectsSidebarState.scrollTop;

try {
await tick();
if (token !== restoreToken || !sidebarBodyEl) {
return;
}

sidebarBodyEl.scrollTop = requestedScrollTop;
await tick();
if (token !== restoreToken || !sidebarBodyEl) {
return;
}

const activeRowVisible = activeProjectRowEl
? isFullyVisibleInSidebar(activeProjectRowEl)
: null;

if (activeProjectRowEl && !activeRowVisible) {
activeProjectRowEl.scrollIntoView({ block: 'nearest' });
}
setProjectsSidebarScrollTop(sidebarBodyEl.scrollTop);
} finally {
if (token === restoreToken) {
restoreInProgress = false;
sidebarScrollRestored = true;
}
}
}

function repoCountForProject(project: Project): number {
return repoCountsByProject.get(project.id) ?? (project.githubRepo ? 1 : 0);
}
Expand Down Expand Up @@ -224,6 +326,9 @@
});

onDestroy(() => {
if (trackedSidebarBodyEl) {
saveSidebarScrollTopFromNode(trackedSidebarBodyEl);
}
stopResize();
});

Expand Down Expand Up @@ -296,10 +401,16 @@
}

$effect(() => {
const nextNavigationKey = `${navigation.activeView}:${navigation.selectedProjectId ?? ''}`;
if (nextNavigationKey !== lastNavigationKey) {
lastNavigationKey = nextNavigationKey;
}
const readyToRestore =
sidebarVisible &&
sidebarBodyEl &&
!sidebarScrollRestored &&
!restoreInProgress &&
!loading &&
!error;

if (!readyToRestore) return;
void restoreSidebarScrollPosition();
});
</script>

Expand All @@ -317,7 +428,7 @@
</div>
</div>

<div class="sidebar-body">
<div class="sidebar-body" use:trackSidebarBody onscroll={handleSidebarScroll}>
{#if loading}
<div class="state">Loading projects…</div>
{:else if error}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const projectsSidebarState = $state({
collapsed: false,
hydrated: false,
hasProjects: true,
scrollTop: 0,
});

export async function hydrateProjectsSidebarState(): Promise<void> {
Expand Down Expand Up @@ -60,6 +61,11 @@ export function setProjects(projects: Project[]): void {
projectsSidebarState.hasProjects = projects.length > 0;
}

export function setProjectsSidebarScrollTop(scrollTop: number): void {
if (!Number.isFinite(scrollTop)) return;
projectsSidebarState.scrollTop = Math.max(0, scrollTop);
}

export function setProjectsSidebarCollapsed(collapsed: boolean): void {
if (projectsSidebarState.collapsed === collapsed) return;
projectsSidebarState.collapsed = collapsed;
Expand Down