mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
feat(app): add infinite home session scrolling
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
homeSessionActiveHeaderIndex,
|
||||
homeSessionListRows,
|
||||
homeSessionListRowSize,
|
||||
shouldLoadMoreHomeSessions,
|
||||
} from "./home-session-list"
|
||||
|
||||
describe("homeSessionListRows", () => {
|
||||
test("flattens groups with stable headers and spacing metadata", () => {
|
||||
const rows = homeSessionListRows(
|
||||
[
|
||||
{ id: "today", title: "Today", sessions: ["a", "b"] },
|
||||
{ id: "older", title: "Older", sessions: ["c"] },
|
||||
],
|
||||
(session) => `session:${session}`,
|
||||
)
|
||||
|
||||
expect(rows.map((row) => row.key)).toEqual(["header:today", "session:a", "session:b", "header:older", "session:c"])
|
||||
expect(rows.map(homeSessionListRowSize)).toEqual([44, 41, 64, 44, 40])
|
||||
expect(rows[2]).toMatchObject({ type: "session", last: true, finalGroup: false })
|
||||
expect(rows[4]).toMatchObject({ type: "session", last: true, finalGroup: true })
|
||||
expect(homeSessionActiveHeaderIndex(rows, 2)).toBe(0)
|
||||
expect(homeSessionActiveHeaderIndex(rows, 4)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldLoadMoreHomeSessions", () => {
|
||||
test("loads within the bottom threshold", () => {
|
||||
expect(shouldLoadMoreHomeSessions({ scrollTop: 601, scrollHeight: 1_400, clientHeight: 400, threshold: 400 })).toBe(
|
||||
true,
|
||||
)
|
||||
expect(shouldLoadMoreHomeSessions({ scrollTop: 600, scrollHeight: 1_400, clientHeight: 400, threshold: 400 })).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("fills a viewport taller than its content", () => {
|
||||
expect(shouldLoadMoreHomeSessions({ scrollTop: 0, scrollHeight: 300, clientHeight: 600, threshold: 400 })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
export type HomeSessionListGroup<T> = {
|
||||
id: string
|
||||
title: string
|
||||
sessions: T[]
|
||||
}
|
||||
|
||||
export type HomeSessionListRow<T> =
|
||||
| {
|
||||
type: "header"
|
||||
key: string
|
||||
group: HomeSessionListGroup<T>
|
||||
}
|
||||
| {
|
||||
type: "session"
|
||||
key: string
|
||||
group: HomeSessionListGroup<T>
|
||||
record: T
|
||||
last: boolean
|
||||
finalGroup: boolean
|
||||
}
|
||||
|
||||
export function homeSessionListRows<T>(groups: HomeSessionListGroup<T>[], key: (record: T) => string) {
|
||||
return groups.flatMap((group, groupIndex) => [
|
||||
{ type: "header" as const, key: `header:${group.id}`, group },
|
||||
...group.sessions.map((record, index) => ({
|
||||
type: "session" as const,
|
||||
key: key(record),
|
||||
group,
|
||||
record,
|
||||
last: index === group.sessions.length - 1,
|
||||
finalGroup: groupIndex === groups.length - 1,
|
||||
})),
|
||||
])
|
||||
}
|
||||
|
||||
export function homeSessionListRowSize<T>(row: HomeSessionListRow<T>) {
|
||||
if (row.type === "header") return 44
|
||||
if (!row.last) return 41
|
||||
return row.finalGroup ? 40 : 64
|
||||
}
|
||||
|
||||
export function homeSessionActiveHeaderIndex<T>(rows: HomeSessionListRow<T>[], index: number) {
|
||||
return rows.slice(0, index + 1).findLastIndex((row) => row.type === "header")
|
||||
}
|
||||
|
||||
export function shouldLoadMoreHomeSessions(input: {
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
threshold: number
|
||||
}) {
|
||||
return input.scrollHeight - input.scrollTop - input.clientHeight < input.threshold
|
||||
}
|
||||
+273
-172
@@ -1,6 +1,5 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import {
|
||||
type ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { createVirtualizer } from "@tanstack/solid-virtual"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
@@ -69,11 +69,18 @@ import { archiveHomeSession } from "./home-session-archive"
|
||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { fileManagerApp } from "@/utils/file-manager"
|
||||
import {
|
||||
homeSessionActiveHeaderIndex,
|
||||
homeSessionListRows,
|
||||
homeSessionListRowSize,
|
||||
shouldLoadMoreHomeSessions,
|
||||
} from "./home-session-list"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
|
||||
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
|
||||
const HOME_SESSION_LOAD_THRESHOLD = 400
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_ROW_LAYOUT =
|
||||
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
|
||||
@@ -139,107 +146,6 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let positionFrame: number | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||
const [state, setState] = createStore({
|
||||
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const items = groups()
|
||||
const ids = new Set(items.map((group) => group.id))
|
||||
headerRefs.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerRefs.delete(id)
|
||||
})
|
||||
headerOffsets.forEach((_, id) => {
|
||||
if (!ids.has(id)) headerOffsets.delete(id)
|
||||
})
|
||||
if (items.length === 0) {
|
||||
content = undefined
|
||||
bindResizeObserver()
|
||||
}
|
||||
queuePositionUpdate()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
function setViewport(el: HTMLDivElement) {
|
||||
viewport = el
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setContentRef(el: HTMLDivElement) {
|
||||
content = el
|
||||
bindResizeObserver()
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
|
||||
headerRefs.set(id, el)
|
||||
queuePositionUpdate()
|
||||
}
|
||||
|
||||
function queuePositionUpdate() {
|
||||
if (typeof requestAnimationFrame === "undefined") {
|
||||
updatePositionCache()
|
||||
return
|
||||
}
|
||||
if (positionFrame !== undefined) return
|
||||
positionFrame = requestAnimationFrame(() => {
|
||||
positionFrame = undefined
|
||||
updatePositionCache()
|
||||
})
|
||||
}
|
||||
|
||||
function updatePositionCache() {
|
||||
if (!viewport) return
|
||||
groups().forEach((group) => {
|
||||
const el = headerRefs.get(group.id)
|
||||
if (!el) return
|
||||
headerOffsets.set(group.id, el.offsetTop)
|
||||
})
|
||||
update(viewport.scrollTop)
|
||||
}
|
||||
|
||||
function update(scrollTop: number) {
|
||||
const items = groups()
|
||||
items.forEach((group, index) => {
|
||||
const nextOffset = items
|
||||
.slice(index + 1)
|
||||
.map((item) => headerOffsets.get(item.id))
|
||||
.find((offset) => offset !== undefined)
|
||||
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
|
||||
const opacity =
|
||||
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
|
||||
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
|
||||
})
|
||||
}
|
||||
|
||||
function titleOpacity(id: HomeSessionGroup["id"]) {
|
||||
return state.titleOpacity[id] ?? 1
|
||||
}
|
||||
|
||||
function bindResizeObserver() {
|
||||
resizeObserver?.disconnect()
|
||||
if (typeof ResizeObserver === "undefined") return
|
||||
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
|
||||
if (viewport) resizeObserver.observe(viewport)
|
||||
if (content) resizeObserver.observe(content)
|
||||
}
|
||||
|
||||
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||
}
|
||||
|
||||
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
|
||||
// background without navigating, matching browser conventions.
|
||||
function isBackgroundOpen(event: MouseEvent) {
|
||||
@@ -303,6 +209,9 @@ export function NewHome() {
|
||||
if (!project) return projects().flatMap(directories)
|
||||
return directories(project)
|
||||
})
|
||||
const sessionScope = createMemo(
|
||||
() => `${selection().server}\0${projectDirectories().map(pathKey).sort().join("\0")}`,
|
||||
)
|
||||
const search = createMemo(() => state.search.trim())
|
||||
const searchPlaceholder = createMemo(() => {
|
||||
const project = selectedProject()
|
||||
@@ -340,21 +249,18 @@ export function NewHome() {
|
||||
projectByID,
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
const searchResults = createMemo(() => {
|
||||
const query = search().toLowerCase()
|
||||
if (!query) return []
|
||||
return allRecords().filter((record) => matchesHomeSessionSearch(record, query))
|
||||
})
|
||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = focusedServerCtx()
|
||||
if (!ctx) return
|
||||
records()
|
||||
allRecords()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}`
|
||||
@@ -590,69 +496,21 @@ export function NewHome() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView
|
||||
class="mt-3 -mr-3 min-h-0 flex-1 relative"
|
||||
viewportRef={sessionHeaderOpacity.setViewport}
|
||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||
>
|
||||
<Show when={groups().length > 0 && newSessionProject()}>
|
||||
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={openNewSession}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
record={record}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
<Show when={sessionScope()} keyed>
|
||||
<HomeSessionList
|
||||
records={allRecords()}
|
||||
directories={projectDirectories()}
|
||||
sync={focusedSync()}
|
||||
loading={sessionLoad.isLoading}
|
||||
canCreateSession={!!newSessionProject()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
openNewSession={openNewSession}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
language={language}
|
||||
/>
|
||||
</Show>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
@@ -665,6 +523,251 @@ export function NewHome() {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionList(props: {
|
||||
records: HomeSessionRecord[]
|
||||
directories: string[]
|
||||
sync: ServerSync
|
||||
loading: boolean
|
||||
canCreateSession: boolean
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
openNewSession: () => void
|
||||
openSession: (session: Session, options?: OpenSessionOptions) => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const [state, setState] = createStore({
|
||||
limit: HOME_SESSION_LIMIT,
|
||||
loadingMore: false,
|
||||
scrollTop: 0,
|
||||
viewport: undefined as HTMLDivElement | undefined,
|
||||
})
|
||||
const records = createMemo(() => props.records.slice(0, state.limit))
|
||||
const groups = createMemo(() => groupSessions(records(), props.language))
|
||||
const rows = createMemo(() => homeSessionListRows(groups(), homeSessionSearchKey))
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.key, row] as const)))
|
||||
const hasMore = createMemo(() => props.records.length > state.limit || props.directories.some(directoryHasMore))
|
||||
let fillFrame: number | undefined
|
||||
let disposed = false
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => state.viewport ?? null,
|
||||
estimateSize: (index) => {
|
||||
const row = rows()[index]
|
||||
return row ? homeSessionListRowSize(row) : 40
|
||||
},
|
||||
overscan: 10,
|
||||
paddingStart: 12,
|
||||
paddingEnd: 64,
|
||||
get getItemKey() {
|
||||
const current = rows()
|
||||
return (index: number) => current[index]?.key ?? index
|
||||
},
|
||||
onChange: scheduleFill,
|
||||
})
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
|
||||
const activeHeaderIndex = createMemo(() => {
|
||||
const offset = state.scrollTop + HOME_SESSION_HEADER_STICKY_TOP
|
||||
const index = virtualizer.measurementsCache.findLastIndex((item) => item.start <= offset)
|
||||
return homeSessionActiveHeaderIndex(rows(), Math.max(0, index))
|
||||
})
|
||||
const activeHeader = createMemo(() => {
|
||||
const row = rows()[activeHeaderIndex()]
|
||||
return row?.type === "header" ? row : undefined
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.loading, state.loadingMore, state.limit, props.records.length, hasMore()] as const,
|
||||
scheduleFill,
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
||||
})
|
||||
|
||||
function directoryHasMore(directory: string) {
|
||||
const store = props.sync.child(directory, { bootstrap: false })[0]
|
||||
return store.sessionTotal > sortedRootSessions(store, Date.now()).length
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (props.loading || state.loadingMore || !hasMore()) return
|
||||
const limit = state.limit + HOME_SESSION_LIMIT
|
||||
setState("loadingMore", true)
|
||||
try {
|
||||
await Promise.all(
|
||||
props.directories
|
||||
.filter(directoryHasMore)
|
||||
.map((directory) => props.sync.project.loadSessions(directory, { limit })),
|
||||
)
|
||||
if (!disposed) setState("limit", limit)
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setState("loadingMore", false)
|
||||
scheduleFill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFill() {
|
||||
if (fillFrame !== undefined || !state.viewport) return
|
||||
fillFrame = requestAnimationFrame(() => {
|
||||
fillFrame = undefined
|
||||
const viewport = state.viewport
|
||||
if (!viewport || props.loading || state.loadingMore || !hasMore()) return
|
||||
if (
|
||||
!shouldLoadMoreHomeSessions({
|
||||
scrollTop: viewport.scrollTop,
|
||||
scrollHeight: viewport.scrollHeight,
|
||||
clientHeight: viewport.clientHeight,
|
||||
threshold: HOME_SESSION_LOAD_THRESHOLD,
|
||||
})
|
||||
)
|
||||
return
|
||||
void loadMore()
|
||||
})
|
||||
}
|
||||
|
||||
function bindViewport(viewport: HTMLDivElement) {
|
||||
setState("viewport", viewport)
|
||||
scheduleFill()
|
||||
}
|
||||
|
||||
function handleScroll(viewport: HTMLDivElement) {
|
||||
setState("scrollTop", viewport.scrollTop)
|
||||
if (
|
||||
shouldLoadMoreHomeSessions({
|
||||
scrollTop: viewport.scrollTop,
|
||||
scrollHeight: viewport.scrollHeight,
|
||||
clientHeight: viewport.clientHeight,
|
||||
threshold: HOME_SESSION_LOAD_THRESHOLD,
|
||||
})
|
||||
)
|
||||
void loadMore()
|
||||
}
|
||||
|
||||
function headerTitleOpacity(index: number) {
|
||||
if (index !== activeHeaderIndex()) return 1
|
||||
state.scrollTop
|
||||
const next = rows().findIndex((row, rowIndex) => rowIndex > index && row.type === "header")
|
||||
if (next < 0) return 1
|
||||
const measurement = virtualizer.measurementsCache[next]
|
||||
if (!measurement) return 1
|
||||
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(1, (measurement.start - state.scrollTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="mt-3 -mr-3 min-h-0 flex-1 relative">
|
||||
<Show when={activeHeader()}>
|
||||
{(header) => (
|
||||
<div
|
||||
data-component="home-session-sticky-header"
|
||||
class="pointer-events-none absolute top-3 left-0 right-3 z-[5]"
|
||||
>
|
||||
<HomeSessionGroupHeader
|
||||
title={header().group.title}
|
||||
titleOpacity={headerTitleOpacity(activeHeaderIndex())}
|
||||
elevated
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ScrollView class="h-full relative" viewportRef={bindViewport} onScroll={(event) => handleScroll(event.currentTarget)}>
|
||||
<Show when={groups().length > 0 && props.canCreateSession}>
|
||||
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
icon="edit"
|
||||
class="pointer-events-auto h-7 px-2 [font-weight:530]"
|
||||
onClick={props.openNewSession}
|
||||
>
|
||||
{props.language.t("command.session.new")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={props.canCreateSession ? props.openNewSession : undefined} />}
|
||||
>
|
||||
<div
|
||||
data-component="home-session-list"
|
||||
data-total-rows={records().length}
|
||||
class="relative min-w-0 pr-3"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
<For each={virtualRowKeys()}>
|
||||
{(key) => (
|
||||
<Show when={virtualItemByKey().get(key)}>
|
||||
{(item) => (
|
||||
<Show when={rowByKey().get(String(key))}>
|
||||
{(row) => {
|
||||
const value = row()
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
transform: `translateY(${item().start}px)`,
|
||||
"z-index": value.type === "header" ? 10 : undefined,
|
||||
}}
|
||||
>
|
||||
{value.type === "session" ? (
|
||||
<HomeSessionRow
|
||||
record={value.record}
|
||||
showProjectName={props.showProjectName}
|
||||
server={props.server}
|
||||
openSession={props.openSession}
|
||||
archiveSession={props.archiveSession}
|
||||
/>
|
||||
) : item().index !== activeHeaderIndex() ? (
|
||||
<HomeSessionGroupHeader title={value.group.title} titleOpacity={1} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={state.loadingMore}>
|
||||
<div class="pointer-events-none absolute bottom-4 left-0 flex w-full justify-center" role="status">
|
||||
<Spinner />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectColumn(props: {
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
@@ -1411,13 +1514,11 @@ function HomeSessionSearchResultRow(props: {
|
||||
function HomeSessionGroupHeader(props: {
|
||||
title: string
|
||||
titleOpacity: number
|
||||
ref: ComponentProps<"div">["ref"]
|
||||
elevated?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
|
||||
class={`pointer-events-none flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header relative" : ""}`}
|
||||
>
|
||||
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||
{props.title}
|
||||
|
||||
Reference in New Issue
Block a user