fix(tasks): restore GitHub page and scroll position on reopen (#13096)
This commit is contained in:
parent
34f2a62cda
commit
bd9addb449
|
|
@ -244,6 +244,11 @@ import {
|
|||
type TaskPageRepoSourceState
|
||||
} from '@/components/task-page-cache-selectors'
|
||||
import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility'
|
||||
import {
|
||||
buildTaskPageGitHubResumeContextKey,
|
||||
taskPageGitHubResumeCache,
|
||||
TASK_PAGE_GITHUB_RESUME_FRESH_MS
|
||||
} from '@/components/task-page-github-resume-cache'
|
||||
import {
|
||||
applyEmptyPageClamp,
|
||||
applyWindowPageLimit,
|
||||
|
|
@ -3886,6 +3891,11 @@ export default function TaskPage(): React.JSX.Element {
|
|||
CROSS_REPO_DISPLAY_LIMIT
|
||||
)
|
||||
const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length)
|
||||
const githubResumeContextKey = buildTaskPageGitHubResumeContextKey({
|
||||
selectedReposKey,
|
||||
query: appliedTaskSearch.trim(),
|
||||
pageSize: githubPageSize
|
||||
})
|
||||
// Why: null entries are pages not fetched yet; numbered provider pages let a high-page click load directly without reading intermediate pages.
|
||||
const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => {
|
||||
const trimmed = initialTaskQuery.trim()
|
||||
|
|
@ -3913,6 +3923,11 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const currentPageRef = useRef(currentPage)
|
||||
pagesRef.current = pages
|
||||
currentPageRef.current = currentPage
|
||||
const githubResumeConsumedRef = useRef(false)
|
||||
const githubResumeContextRef = useRef('')
|
||||
const githubListScrollRef = useRef<HTMLDivElement>(null)
|
||||
const githubListScrollTopRef = useRef(0)
|
||||
const pendingGithubScrollRestoreRef = useRef<number | null>(null)
|
||||
const [paginationLoading, setPaginationLoading] = useState(false)
|
||||
const [loadingTargetPage, setLoadingTargetPage] = useState<number | null>(null)
|
||||
const [countedTotalPages, setCountedTotalPages] = useState<number | null>(null)
|
||||
|
|
@ -3927,6 +3942,51 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage)
|
||||
const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos)
|
||||
|
||||
useEffect(() => {
|
||||
const page = pages[currentPage]
|
||||
if (!taskResumeApplied || taskSource !== 'github' || githubMode !== 'items' || !page) {
|
||||
return
|
||||
}
|
||||
taskPageGitHubResumeCache.write(githubResumeContextKey, currentPage, page)
|
||||
}, [currentPage, githubMode, githubResumeContextKey, pages, taskResumeApplied, taskSource])
|
||||
|
||||
const taskListPositionRef = useRef<{
|
||||
contextKey: string
|
||||
page: number
|
||||
scrollTop: number
|
||||
} | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
taskSource !== 'github' ||
|
||||
githubMode !== 'items' ||
|
||||
pageData.openGitHubWorkItem ||
|
||||
pendingGithubScrollRestoreRef.current !== null
|
||||
) {
|
||||
return
|
||||
}
|
||||
taskListPositionRef.current = {
|
||||
contextKey: githubResumeContextKey,
|
||||
page: currentPage,
|
||||
scrollTop: githubListScrollTopRef.current
|
||||
}
|
||||
}, [currentPage, githubMode, githubResumeContextKey, pageData.openGitHubWorkItem, taskSource])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const position = taskListPositionRef.current
|
||||
const state = useAppStore.getState()
|
||||
if (position && !state.taskPageData.openGitHubWorkItem) {
|
||||
state.setTaskListPosition({
|
||||
contextKey: position.contextKey,
|
||||
page: position.page,
|
||||
scrollTop: position.scrollTop
|
||||
})
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Why: keyed on selectedReposKey, not the selectedRepos array — a background
|
||||
// repos:changed refresh mid-flight would otherwise bump the generation and
|
||||
// silently discard the user's page navigation (#11485). Mirrors every dep of
|
||||
|
|
@ -3976,6 +4036,68 @@ export default function TaskPage(): React.JSX.Element {
|
|||
const dialogWorkItem = dialogWorkItemKey
|
||||
? (cachedDialogWorkItem ?? githubTaskDrawerWorkItem)
|
||||
: null
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scrollTop = pendingGithubScrollRestoreRef.current
|
||||
const scrollElement = githubListScrollRef.current
|
||||
if (scrollTop === null || !scrollElement || !pages[currentPage]) {
|
||||
return
|
||||
}
|
||||
let frame: number | null = null
|
||||
let timeout: number | null = null
|
||||
let observer: ResizeObserver | null = null
|
||||
const clearScheduledRestore = (): void => {
|
||||
if (frame !== null) {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = null
|
||||
}
|
||||
if (timeout !== null) {
|
||||
window.clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
observer?.disconnect()
|
||||
}
|
||||
const restore = (): void => {
|
||||
const committedScrollElement = githubListScrollRef.current
|
||||
if (!committedScrollElement || pendingGithubScrollRestoreRef.current !== scrollTop) {
|
||||
return
|
||||
}
|
||||
committedScrollElement.scrollTop = scrollTop
|
||||
githubListScrollTopRef.current = scrollTop
|
||||
taskListPositionRef.current = {
|
||||
contextKey: githubResumeContextKey,
|
||||
page: currentPage,
|
||||
scrollTop
|
||||
}
|
||||
if (Math.abs(committedScrollElement.scrollTop - scrollTop) < 1) {
|
||||
pendingGithubScrollRestoreRef.current = null
|
||||
clearScheduledRestore()
|
||||
}
|
||||
}
|
||||
observer = new ResizeObserver(restore)
|
||||
for (const child of scrollElement.children) {
|
||||
observer.observe(child)
|
||||
}
|
||||
restore()
|
||||
if (pendingGithubScrollRestoreRef.current === scrollTop) {
|
||||
frame = window.requestAnimationFrame(restore)
|
||||
timeout = window.setTimeout(() => {
|
||||
if (pendingGithubScrollRestoreRef.current === scrollTop) {
|
||||
const committedScrollTop = githubListScrollRef.current?.scrollTop ?? 0
|
||||
githubListScrollTopRef.current = committedScrollTop
|
||||
taskListPositionRef.current = {
|
||||
contextKey: githubResumeContextKey,
|
||||
page: currentPage,
|
||||
scrollTop: committedScrollTop
|
||||
}
|
||||
pendingGithubScrollRestoreRef.current = null
|
||||
}
|
||||
clearScheduledRestore()
|
||||
}, 5_000)
|
||||
}
|
||||
return clearScheduledRestore
|
||||
}, [currentPage, dialogWorkItem, githubResumeContextKey, pages])
|
||||
|
||||
const dialogRepoPath = dialogWorkItem ? (repoMap.get(dialogWorkItem.repoId)?.path ?? null) : null
|
||||
const dialogSourceContext = useMemo(() => {
|
||||
if (!dialogWorkItem) {
|
||||
|
|
@ -4039,6 +4161,15 @@ export default function TaskPage(): React.JSX.Element {
|
|||
|
||||
const openGitHubDetailPage = useCallback(
|
||||
(item: GitHubWorkItem, initialTab: ItemDialogTab = 'conversation') => {
|
||||
const scrollTop = githubListScrollRef.current?.scrollTop ?? githubListScrollTopRef.current
|
||||
githubListScrollTopRef.current = scrollTop
|
||||
pendingGithubScrollRestoreRef.current = scrollTop
|
||||
taskListPositionRef.current = {
|
||||
contextKey: githubResumeContextKey,
|
||||
page: currentPageRef.current,
|
||||
scrollTop
|
||||
}
|
||||
useAppStore.getState().setTaskListPosition(taskListPositionRef.current)
|
||||
openTaskPage(
|
||||
{
|
||||
taskSource: 'github',
|
||||
|
|
@ -4050,7 +4181,7 @@ export default function TaskPage(): React.JSX.Element {
|
|||
{ recordTasksInteraction: false }
|
||||
)
|
||||
},
|
||||
[openTaskPage, repoMap]
|
||||
[githubResumeContextKey, openTaskPage, repoMap]
|
||||
)
|
||||
|
||||
const openGitLabDetailPage = useCallback(
|
||||
|
|
@ -6616,6 +6747,30 @@ export default function TaskPage(): React.JSX.Element {
|
|||
// Why: strip repo:owner/name qualifiers before fan-out — cross-repo they'd pin every fetch to one repo. See stripRepoQualifiers.
|
||||
const q = stripRepoQualifiers(appliedTaskSearch.trim())
|
||||
let cancelled = false
|
||||
const contextChanged = githubResumeContextRef.current !== githubResumeContextKey
|
||||
githubResumeContextRef.current = githubResumeContextKey
|
||||
const savedPosition = !githubResumeConsumedRef.current
|
||||
? useAppStore.getState().taskListPosition
|
||||
: undefined
|
||||
githubResumeConsumedRef.current = true
|
||||
const savedPositionMatches = savedPosition?.contextKey === githubResumeContextKey
|
||||
const targetPage = savedPositionMatches
|
||||
? savedPosition.page
|
||||
: contextChanged
|
||||
? 0
|
||||
: currentPageRef.current
|
||||
const liveTargetItems = pagesRef.current[targetPage]
|
||||
const cachedTargetPage = liveTargetItems
|
||||
? { items: liveTargetItems, cachedAt: Date.now() }
|
||||
: taskPageGitHubResumeCache.read(githubResumeContextKey, targetPage)
|
||||
const cachedTargetIsFresh =
|
||||
cachedTargetPage !== null &&
|
||||
Date.now() - cachedTargetPage.cachedAt < TASK_PAGE_GITHUB_RESUME_FRESH_MS
|
||||
if (savedPositionMatches) {
|
||||
pendingGithubScrollRestoreRef.current = savedPosition.scrollTop
|
||||
} else if (contextChanged) {
|
||||
pendingGithubScrollRestoreRef.current = 0
|
||||
}
|
||||
|
||||
// Why: paint cached rows synchronously before the fan-out so a selection change doesn't leave the prior rows on screen for a frame.
|
||||
const preMerged: GitHubWorkItem[] = []
|
||||
|
|
@ -6636,26 +6791,33 @@ export default function TaskPage(): React.JSX.Element {
|
|||
preMerged.push(...cached)
|
||||
}
|
||||
}
|
||||
// Why: always replace so an empty cache clears the previous query's rows.
|
||||
// Why: page-one metadata and the restored numbered page have independent lifecycles.
|
||||
const page0Raw =
|
||||
preMerged.length > 0 ? sortWorkItemsByNumber(preMerged).slice(0, githubPageSize) : []
|
||||
// Why: pre-paint must still overlay in-flight mutations (K4/K18).
|
||||
setPages((previous) => [
|
||||
materializeTaskPageItemList({
|
||||
networkItems: page0Raw,
|
||||
previousItems: previous.flatMap((page) => page ?? []),
|
||||
queryKey: githubWorkItemMutationQueryKey
|
||||
})
|
||||
])
|
||||
currentPageRef.current = 0
|
||||
setCurrentPage(0)
|
||||
const landingPages: (GitHubWorkItem[] | null)[] = Array.from(
|
||||
{ length: targetPage + 1 },
|
||||
() => null
|
||||
)
|
||||
landingPages[0] = materializeTaskPageItemList({
|
||||
networkItems: page0Raw,
|
||||
previousItems: pagesRef.current.flatMap((page) => page ?? []),
|
||||
queryKey: githubWorkItemMutationQueryKey
|
||||
})
|
||||
if (targetPage > 0 && cachedTargetPage) {
|
||||
landingPages[targetPage] = overlayPendingOnTaskPagePages([cachedTargetPage.items])[0] ?? []
|
||||
}
|
||||
pagesRef.current = landingPages
|
||||
currentPageRef.current = targetPage
|
||||
setPages(landingPages)
|
||||
setCurrentPage(targetPage)
|
||||
setCountedTotalPages(null)
|
||||
countedTotalPagesRef.current = null
|
||||
setProvenPageLimit(null)
|
||||
setTasksError(null)
|
||||
setFailedCount(0) // reset so a prior failure banner doesn't linger
|
||||
setGithubUnavailable(false)
|
||||
setTasksLoading(anyUncached)
|
||||
setTasksLoading(targetPage > 0 ? cachedTargetPage === null : anyUncached)
|
||||
|
||||
// Preserve the existing nonce-gated force behavior.
|
||||
const forceRefresh = taskRefreshNonce !== lastFetchedNonceRef.current
|
||||
|
|
@ -6679,7 +6841,10 @@ export default function TaskPage(): React.JSX.Element {
|
|||
}))
|
||||
const landingRefreshKey = `${repoArgs.map((r) => `${r.repoId}:${r.path}`).join('|')}::${q}`
|
||||
const shouldProbeOnLanding =
|
||||
!forcedFetch && anyRepoCached && !landingGitHubRefreshKeysRef.current.has(landingRefreshKey)
|
||||
!forcedFetch &&
|
||||
!cachedTargetIsFresh &&
|
||||
anyRepoCached &&
|
||||
!landingGitHubRefreshKeysRef.current.has(landingRefreshKey)
|
||||
if (shouldProbeOnLanding) {
|
||||
landingGitHubRefreshKeysRef.current = new Set([
|
||||
...landingGitHubRefreshKeysRef.current,
|
||||
|
|
@ -6689,6 +6854,63 @@ export default function TaskPage(): React.JSX.Element {
|
|||
// Why: manual refresh keeps cached rows (tasksLoading stays false), so track forced fetch separately for the toolbar spinner.
|
||||
setTasksRefreshing(forcedFetch)
|
||||
|
||||
if (targetPage > 0 && (!cachedTargetIsFresh || forcedFetch)) {
|
||||
const requestGeneration = paginationGenerationRef.current
|
||||
if (!cachedTargetPage) {
|
||||
setPaginationLoading(true)
|
||||
setLoadingTargetPage(targetPage)
|
||||
}
|
||||
void fetchWorkItemsNextPage(
|
||||
repoArgs,
|
||||
githubPerRepoPageLimit,
|
||||
githubPageSize,
|
||||
q,
|
||||
taskPageToGitHubApiPage(targetPage)
|
||||
)
|
||||
.then(({ items, failedCount, errorTypes }) => {
|
||||
if (cancelled || paginationGenerationRef.current !== requestGeneration) {
|
||||
return
|
||||
}
|
||||
if (items.length === 0) {
|
||||
const { reason } = resolveEmptyPageOutcome({
|
||||
target: targetPage,
|
||||
failedCount,
|
||||
errorTypes,
|
||||
countedTotalPages: null
|
||||
})
|
||||
if (reason === 'load-failed' && cachedTargetPage) {
|
||||
return
|
||||
}
|
||||
pendingGithubScrollRestoreRef.current = 0
|
||||
currentPageRef.current = 0
|
||||
setCurrentPage(0)
|
||||
const next = [pagesRef.current[0] ?? []]
|
||||
pagesRef.current = next
|
||||
setPages(next)
|
||||
return
|
||||
}
|
||||
const restoredItems = overlayPendingOnTaskPagePages([items])[0] ?? []
|
||||
taskPageGitHubResumeCache.write(githubResumeContextKey, targetPage, restoredItems)
|
||||
const next = [...pagesRef.current]
|
||||
while (next.length <= targetPage) {
|
||||
next.push(null)
|
||||
}
|
||||
next[targetPage] = restoredItems
|
||||
pagesRef.current = next
|
||||
setPages(next)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to restore GitHub task page:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && paginationGenerationRef.current === requestGeneration) {
|
||||
setPaginationLoading(false)
|
||||
setLoadingTargetPage(null)
|
||||
setTasksLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: snapshot retrying keys at dispatch so an earlier settling effect doesn't wipe a newer retry's pending source.
|
||||
const dispatchedRetrySourceKeys = retryingSourceKeys
|
||||
void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, {
|
||||
|
|
@ -6739,7 +6961,16 @@ export default function TaskPage(): React.JSX.Element {
|
|||
patchWorkItem: useAppStore.getState().patchWorkItem,
|
||||
sourceContextByRepoId
|
||||
})
|
||||
if (shouldProbeOnLanding) {
|
||||
if (targetPage > 0) {
|
||||
const next = [...pagesRef.current]
|
||||
next[0] = materializeTaskPageItemList({
|
||||
networkItems: items,
|
||||
previousItems: next.flatMap((page) => page ?? []),
|
||||
queryKey: githubWorkItemMutationQueryKey
|
||||
})
|
||||
pagesRef.current = next
|
||||
setPages(next)
|
||||
} else if (shouldProbeOnLanding) {
|
||||
const replaceFirstPage = shouldReplaceTaskPageItemsAfterRefresh(page0Raw, items)
|
||||
const resetPagination = shouldResetTaskPagePaginationAfterLandingRefresh(
|
||||
page0Raw,
|
||||
|
|
@ -6767,7 +6998,9 @@ export default function TaskPage(): React.JSX.Element {
|
|||
}
|
||||
setFailedCount(failed)
|
||||
setGithubUnavailable(unavailable)
|
||||
setTasksLoading(false)
|
||||
if (targetPage === 0 || cachedTargetPage) {
|
||||
setTasksLoading(false)
|
||||
}
|
||||
setTasksRefreshing(false)
|
||||
setTasksFiltering(false)
|
||||
}
|
||||
|
|
@ -6791,7 +7024,9 @@ export default function TaskPage(): React.JSX.Element {
|
|||
setTasksError(err instanceof Error ? err.message : 'Failed to load GitHub work.')
|
||||
setFailedCount(0) // the per-repo banner would be misleading next to tasksError
|
||||
setGithubUnavailable(false)
|
||||
setTasksLoading(false)
|
||||
if (targetPage === 0 || cachedTargetPage) {
|
||||
setTasksLoading(false)
|
||||
}
|
||||
setTasksRefreshing(false)
|
||||
setTasksFiltering(false)
|
||||
})
|
||||
|
|
@ -9974,8 +10209,27 @@ export default function TaskPage(): React.JSX.Element {
|
|||
// chrome (no gap, no top border/radius) so toolbar + table read as one.
|
||||
<div className="flex min-h-0 min-w-0 max-h-full flex-col overflow-hidden rounded-md rounded-t-none border border-t-0 border-border/50 bg-background shadow-sm">
|
||||
<div
|
||||
ref={githubListScrollRef}
|
||||
data-task-list-scroll="github"
|
||||
className="min-h-0 flex-initial overflow-auto scrollbar-sleek scrollbar-sleek-lg"
|
||||
style={{ scrollbarGutter: 'stable' }}
|
||||
onScroll={(event) => {
|
||||
const state = useAppStore.getState()
|
||||
if (
|
||||
state.activeView !== 'tasks' ||
|
||||
state.taskPageData.openGitHubWorkItem ||
|
||||
pendingGithubScrollRestoreRef.current !== null
|
||||
) {
|
||||
return
|
||||
}
|
||||
const scrollTop = event.currentTarget.scrollTop
|
||||
githubListScrollTopRef.current = scrollTop
|
||||
taskListPositionRef.current = {
|
||||
contextKey: githubResumeContextKey,
|
||||
page: currentPageRef.current,
|
||||
scrollTop
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
// Why: z-40 must beat the rows' sticky left cells (z-20); this stacking context's z sets the whole header's level.
|
||||
|
|
@ -10561,6 +10815,11 @@ export default function TaskPage(): React.JSX.Element {
|
|||
totalPages={totalPages}
|
||||
loadingTarget={loadingTargetPage}
|
||||
onPageChange={(page) => {
|
||||
pendingGithubScrollRestoreRef.current = null
|
||||
githubListScrollTopRef.current = 0
|
||||
if (githubListScrollRef.current) {
|
||||
githubListScrollRef.current.scrollTop = 0
|
||||
}
|
||||
if (pages[page] !== null && pages[page] !== undefined) {
|
||||
currentPageRef.current = page
|
||||
setCurrentPage(page)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTaskPageGitHubResumeContextKey,
|
||||
createTaskPageResumePageCache,
|
||||
TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT
|
||||
} from './task-page-github-resume-cache'
|
||||
|
||||
describe('task page GitHub resume cache', () => {
|
||||
it('isolates entries by full list context', () => {
|
||||
const cache = createTaskPageResumePageCache<number>()
|
||||
const first = buildTaskPageGitHubResumeContextKey({
|
||||
selectedReposKey: 'local:repo-a',
|
||||
query: 'is:pr',
|
||||
pageSize: 30
|
||||
})
|
||||
const second = buildTaskPageGitHubResumeContextKey({
|
||||
selectedReposKey: 'ssh:host-a:repo-a',
|
||||
query: 'is:pr',
|
||||
pageSize: 30
|
||||
})
|
||||
|
||||
cache.write(first, 2, [1])
|
||||
cache.write(second, 2, [2])
|
||||
|
||||
expect(cache.read(first, 2)?.items).toEqual([1])
|
||||
expect(cache.read(second, 2)?.items).toEqual([2])
|
||||
})
|
||||
|
||||
it('evicts the least recently used page at the global cap', () => {
|
||||
const cache = createTaskPageResumePageCache<number>()
|
||||
for (let page = 0; page < TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT; page += 1) {
|
||||
cache.write('scope', page, [page], page)
|
||||
}
|
||||
expect(cache.read('scope', 0, 10)?.items).toEqual([0])
|
||||
|
||||
cache.write('scope', TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT, [5], 11)
|
||||
|
||||
expect(cache.size()).toBe(TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT)
|
||||
expect(cache.read('scope', 1, 12)).toBeNull()
|
||||
expect(cache.read('scope', 0, 12)?.items).toEqual([0])
|
||||
})
|
||||
|
||||
it('retains only five payloads after all 28 pages are visited', () => {
|
||||
const cache = createTaskPageResumePageCache<number>()
|
||||
|
||||
for (let page = 0; page < 28; page += 1) {
|
||||
cache.write('scope', page, [page], page)
|
||||
}
|
||||
|
||||
expect(cache.size()).toBe(5)
|
||||
expect(cache.read('scope', 22, 28)).toBeNull()
|
||||
expect(cache.read('scope', 23, 28)?.items).toEqual([23])
|
||||
expect(cache.read('scope', 27, 28)?.items).toEqual([27])
|
||||
})
|
||||
|
||||
it('expires entries after the inactivity window', () => {
|
||||
const cache = createTaskPageResumePageCache<number>({ ttlMs: 100 })
|
||||
cache.write('scope', 4, [4], 0)
|
||||
expect(cache.read('scope', 4, 99)?.items).toEqual([4])
|
||||
expect(cache.read('scope', 4, 198)?.items).toEqual([4])
|
||||
expect(cache.read('scope', 4, 298)).toBeNull()
|
||||
})
|
||||
|
||||
it('copies page arrays at the cache boundary', () => {
|
||||
const cache = createTaskPageResumePageCache<number>()
|
||||
const source = [1, 2]
|
||||
cache.write('scope', 0, source)
|
||||
source.push(3)
|
||||
|
||||
const firstRead = cache.read('scope', 0)
|
||||
firstRead?.items.push(4)
|
||||
|
||||
expect(cache.read('scope', 0)?.items).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { GitHubWorkItem } from '../../../shared/types'
|
||||
|
||||
export const TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT = 5
|
||||
export const TASK_PAGE_GITHUB_RESUME_CACHE_TTL_MS = 10 * 60_000
|
||||
export const TASK_PAGE_GITHUB_RESUME_FRESH_MS = 30_000
|
||||
|
||||
type CachedPage<T> = {
|
||||
items: readonly T[]
|
||||
cachedAt: number
|
||||
lastAccessedAt: number
|
||||
}
|
||||
|
||||
export type TaskPageResumeCachedPage<T> = {
|
||||
items: T[]
|
||||
cachedAt: number
|
||||
}
|
||||
|
||||
type TaskPageResumePageCacheOptions = {
|
||||
maxEntries?: number
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
export function buildTaskPageGitHubResumeContextKey(args: {
|
||||
selectedReposKey: string
|
||||
query: string
|
||||
pageSize: number
|
||||
}): string {
|
||||
return JSON.stringify(['github', 'items', args.selectedReposKey, args.query, args.pageSize])
|
||||
}
|
||||
|
||||
export function createTaskPageResumePageCache<T>(options: TaskPageResumePageCacheOptions = {}): {
|
||||
read: (contextKey: string, page: number, now?: number) => TaskPageResumeCachedPage<T> | null
|
||||
write: (contextKey: string, page: number, items: readonly T[], now?: number) => void
|
||||
clear: () => void
|
||||
size: () => number
|
||||
} {
|
||||
const maxEntries = options.maxEntries ?? TASK_PAGE_GITHUB_RESUME_CACHE_LIMIT
|
||||
const ttlMs = options.ttlMs ?? TASK_PAGE_GITHUB_RESUME_CACHE_TTL_MS
|
||||
const entries = new Map<string, CachedPage<T>>()
|
||||
const keyFor = (contextKey: string, page: number): string => `${contextKey}\u0000${page}`
|
||||
|
||||
const pruneExpired = (now: number): void => {
|
||||
for (const [key, entry] of entries) {
|
||||
if (now - entry.lastAccessedAt >= ttlMs) {
|
||||
entries.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read(contextKey, page, now = Date.now()) {
|
||||
pruneExpired(now)
|
||||
const key = keyFor(contextKey, page)
|
||||
const entry = entries.get(key)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
entries.delete(key)
|
||||
entries.set(key, { ...entry, lastAccessedAt: now })
|
||||
return { items: [...entry.items], cachedAt: entry.cachedAt }
|
||||
},
|
||||
write(contextKey, page, items, now = Date.now()) {
|
||||
pruneExpired(now)
|
||||
const key = keyFor(contextKey, page)
|
||||
entries.delete(key)
|
||||
entries.set(key, { items: [...items], cachedAt: now, lastAccessedAt: now })
|
||||
while (entries.size > maxEntries) {
|
||||
const oldestKey = entries.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
entries.delete(oldestKey)
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
entries.clear()
|
||||
},
|
||||
size() {
|
||||
return entries.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const taskPageGitHubResumeCache = createTaskPageResumePageCache<GitHubWorkItem>()
|
||||
|
|
@ -706,6 +706,8 @@ export type UISlice = {
|
|||
}
|
||||
taskResumeState: TaskResumeState | undefined
|
||||
setTaskResumeState: (updates: Partial<TaskResumeState>) => void
|
||||
taskListPosition: { contextKey: string; page: number; scrollTop: number } | null
|
||||
setTaskListPosition: (position: UISlice['taskListPosition']) => void
|
||||
githubTaskDrawerWorkItem: GitHubWorkItem | null
|
||||
setGithubTaskDrawerWorkItem: (item: GitHubWorkItem | null) => void
|
||||
newWorkspaceDraft: {
|
||||
|
|
@ -1265,6 +1267,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
setActiveView: (view) => set({ activeView: view }),
|
||||
taskPageData: {},
|
||||
taskResumeState: undefined,
|
||||
taskListPosition: null,
|
||||
githubTaskDrawerWorkItem: null,
|
||||
newWorkspaceDraft: null,
|
||||
openTaskPage: (data = {}, options = {}) => {
|
||||
|
|
@ -1419,6 +1422,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
window.api.ui.set({ taskResumeState: next }).catch(console.error)
|
||||
return { taskResumeState: next }
|
||||
}),
|
||||
setTaskListPosition: (taskListPosition) => set({ taskListPosition }),
|
||||
setGithubTaskDrawerWorkItem: (item) => set({ githubTaskDrawerWorkItem: item }),
|
||||
closeTaskPage: () =>
|
||||
set((state) => {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,60 @@ async function getRenderedTaskSources(
|
|||
}, TASK_SOURCE_BY_LABEL)
|
||||
}
|
||||
|
||||
async function openMockedPaginatedGitHubTasks(
|
||||
page: Parameters<typeof getStoreState>[0]
|
||||
): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const repos = store.getState().repos.map((repo, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...repo,
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/example/repo',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/example/repo.git'
|
||||
}
|
||||
}
|
||||
: repo
|
||||
)
|
||||
const makePage = (pageNumber: number) =>
|
||||
Array.from({ length: 30 }, (_, index) => ({
|
||||
id: `issue-${pageNumber}-${index + 1}`,
|
||||
type: 'issue' as const,
|
||||
number: pageNumber * 100 + index + 1,
|
||||
title: `Issue page ${pageNumber} item ${index + 1}`,
|
||||
state: 'open' as const,
|
||||
url: `https://github.com/example/repo/issues/${pageNumber * 100 + index + 1}`,
|
||||
labels: [],
|
||||
updatedAt: new Date(1_700_000_000_000 - index * 1_000).toISOString(),
|
||||
author: 'octocat',
|
||||
repoId: repos[0]?.id ?? 'repo-1'
|
||||
}))
|
||||
|
||||
store.setState({
|
||||
repos,
|
||||
getCachedWorkItems: () => makePage(1),
|
||||
prefetchWorkItems: () => {},
|
||||
fetchWorkItemsAcrossRepos: async () => ({
|
||||
items: makePage(1),
|
||||
failedCount: 0,
|
||||
githubUnavailable: false
|
||||
}),
|
||||
fetchWorkItemsNextPage: async (_repos, _perRepoLimit, _displayLimit, _query, pageNumber) => ({
|
||||
items: makePage(pageNumber),
|
||||
failedCount: 0,
|
||||
errorTypes: []
|
||||
}),
|
||||
countWorkItemsAcrossRepos: async () => ({ totalCount: 840, totalPages: 28 })
|
||||
})
|
||||
store.getState().openTaskPage({ taskSource: 'github' })
|
||||
})
|
||||
}
|
||||
|
||||
async function openInstrumentedGitHubTasksPage(
|
||||
page: Parameters<typeof getStoreState>[0]
|
||||
): Promise<void> {
|
||||
|
|
@ -234,6 +288,108 @@ test.describe('Tasks page', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('reopening restores the GitHub page and scroll position', async ({ orcaPage }) => {
|
||||
await openMockedPaginatedGitHubTasks(orcaPage)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Page 28', exact: true }).click()
|
||||
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
|
||||
|
||||
const list = orcaPage.locator('[data-task-list-scroll="github"]')
|
||||
await list.evaluate((element) => {
|
||||
element.scrollTop = 360
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await expect.poll(() => list.evaluate((element) => element.scrollTop)).toBeGreaterThan(300)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
|
||||
await expect(list).toHaveCount(0)
|
||||
const clampedRowsStyle = await orcaPage.addStyleTag({
|
||||
content:
|
||||
'[data-task-list-scroll="github"] > .divide-y { max-height: 0 !important; overflow: hidden !important; }'
|
||||
})
|
||||
await openTasksPage(orcaPage)
|
||||
|
||||
await expect(orcaPage.getByRole('button', { name: 'Page 28', exact: true })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
)
|
||||
const restoredList = orcaPage.locator('[data-task-list-scroll="github"]')
|
||||
await expect.poll(() => restoredList.evaluate((element) => element.scrollTop)).toBe(0)
|
||||
await clampedRowsStyle.evaluate((element) => element.remove())
|
||||
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => restoredList.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
await orcaPage.getByText('Issue page 28 item 12', { exact: true }).click()
|
||||
await expect(restoredList).toHaveCount(0)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const position = await getStoreState<{ scrollTop: number }>(orcaPage, 'taskListPosition')
|
||||
return position.scrollTop
|
||||
})
|
||||
.toBeGreaterThan(300)
|
||||
await orcaPage.getByRole('button', { name: 'GitHub list', exact: true }).click()
|
||||
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => restoredList.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
|
||||
const pendingRestoreStyle = await orcaPage.addStyleTag({
|
||||
content:
|
||||
'[data-task-list-scroll="github"] > .divide-y { max-height: 0 !important; overflow: hidden !important; }'
|
||||
})
|
||||
await openTasksPage(orcaPage)
|
||||
await expect(orcaPage.getByRole('button', { name: 'Page 28', exact: true })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
)
|
||||
await orcaPage.getByRole('button', { name: 'Page 1', exact: true }).click()
|
||||
await pendingRestoreStyle.evaluate((element) => element.remove())
|
||||
await expect(orcaPage.getByRole('button', { name: 'Page 1', exact: true })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage
|
||||
.locator('[data-task-list-scroll="github"]')
|
||||
.evaluate((element) => element.scrollTop)
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Page 28', exact: true }).click()
|
||||
await expect(orcaPage.getByText('Issue page 28 item 1', { exact: true })).toBeVisible()
|
||||
await restoredList.evaluate((element) => {
|
||||
element.scrollTop = 360
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await expect
|
||||
.poll(() => restoredList.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(300)
|
||||
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
|
||||
|
||||
const permanentlyClampedRowsStyle = await orcaPage.addStyleTag({
|
||||
content:
|
||||
'[data-task-list-scroll="github"] > .divide-y { max-height: 0 !important; overflow: hidden !important; }'
|
||||
})
|
||||
await openTasksPage(orcaPage)
|
||||
await expect(orcaPage.getByRole('button', { name: 'Page 28', exact: true })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
)
|
||||
await orcaPage.waitForTimeout(5_500)
|
||||
await orcaPage.getByRole('button', { name: 'Close tasks' }).click()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const position = await getStoreState<{ scrollTop: number }>(orcaPage, 'taskListPosition')
|
||||
return position.scrollTop
|
||||
})
|
||||
.toBe(0)
|
||||
await permanentlyClampedRowsStyle.evaluate((element) => element.remove())
|
||||
})
|
||||
|
||||
test('GitHub search waits for idle, keeps rows visible, and Enter does not double-fetch', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue