From dcf015bd9212a9d5b4a7f8610651f87d2fc7494f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Feb 2026 10:12:08 +0700 Subject: [PATCH] Update files --- .../app/e2e/git-diff-sticky-headers.spec.ts | 100 +++++++++++ packages/app/src/components/git-diff-pane.tsx | 167 ++++++++++++------ 2 files changed, 213 insertions(+), 54 deletions(-) create mode 100644 packages/app/e2e/git-diff-sticky-headers.spec.ts diff --git a/packages/app/e2e/git-diff-sticky-headers.spec.ts b/packages/app/e2e/git-diff-sticky-headers.spec.ts new file mode 100644 index 000000000..0ac6e2b1b --- /dev/null +++ b/packages/app/e2e/git-diff-sticky-headers.spec.ts @@ -0,0 +1,100 @@ +import path from 'node:path'; +import { appendFile } from 'node:fs/promises'; +import { test, expect, type Page } from './fixtures'; +import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'; +import { createTempGitRepo } from './helpers/workspace'; + +test.describe.configure({ timeout: 90000 }); + +function getChangesScope(page: Page) { + return page.locator('[data-testid="explorer-content-area"]:visible').first(); +} + +async function openChangesPanel(page: Page) { + const changesHeader = getChangesScope(page).getByTestId('changes-header'); + if (!(await changesHeader.isVisible())) { + const explorerHeader = page.getByTestId('explorer-header'); + if (await explorerHeader.isVisible()) { + await page.getByText('Changes', { exact: true }).click(); + } else { + const overflowMenu = page.getByTestId('agent-overflow-menu').first(); + await expect(overflowMenu).toBeVisible({ timeout: 10000 }); + await overflowMenu.click(); + await page.getByText('View Changes', { exact: true }).click(); + } + } + await expect(changesHeader).toBeVisible(); +} + +async function refreshUncommittedMode(page: Page) { + const scope = getChangesScope(page); + const toggle = scope.getByTestId('changes-diff-status').first(); + await expect(toggle).toBeVisible({ timeout: 30000 }); + + const currentLabel = (await toggle.innerText()).trim(); + await toggle.click(); + await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel); + + const nextLabel = (await toggle.innerText()).trim(); + await toggle.click(); + await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel); +} + +async function createAgentAndWait(page: Page, message: string) { + const input = page.getByRole('textbox', { name: 'Message agent...' }); + await expect(input).toBeEditable(); + await input.fill(message); + await input.press('Enter'); + await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 }); + await expect(page.getByText(message, { exact: true })).toBeVisible(); +} + +test('keeps file header sticky while scrolling within a long diff', async ({ page }) => { + const repo = await createTempGitRepo('paseo-e2e-sticky-'); + + try { + await gotoHome(page); + await setWorkingDirectory(page, repo.path); + await ensureHostSelected(page); + await createAgentAndWait(page, 'Respond with exactly: READY'); + await expect(page.getByText('READY', { exact: true })).toBeVisible({ timeout: 60000 }); + + await openChangesPanel(page); + + const readmePath = path.join(repo.path, 'README.md'); + const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join(''); + await appendFile(readmePath, `\n${lines}`); + + await refreshUncommittedMode(page); + + const scope = getChangesScope(page); + await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 }); + + const fileToggle = scope.getByTestId('diff-file-0-toggle').first(); + await fileToggle.click(); + + const markerLine = scope.getByText('Sticky header line 250').first(); + await expect(markerLine).toBeVisible({ timeout: 30000 }); + + const scroll = scope.getByTestId('git-diff-scroll').first(); + await expect(scroll).toBeVisible(); + + await expect.poll(async () => { + return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0)); + }).toBe(true); + + await scroll.hover(); + for (let i = 0; i < 12; i++) { + await page.mouse.wheel(0, 700); + } + + await expect.poll(async () => { + return await scroll.evaluate((el) => el.scrollTop ?? 0); + }).toBeGreaterThan(0); + + await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 }); + await expect(fileToggle).toBeVisible(); + } finally { + await repo.cleanup(); + } +}); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index a3a1e380c..68946bf9f 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -6,11 +6,11 @@ import { Text, ActivityIndicator, Pressable, - FlatList, + SectionList, Platform, type NativeSyntheticEvent, type NativeScrollEvent, - type ListRenderItem, + type SectionListRenderItem, } from "react-native"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -259,18 +259,13 @@ function DiffLineView({ line }: { line: DiffLine }) { ); } -const DiffFileSection = memo(function DiffFileSection({ +const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, onToggle, testID, }: DiffFileSectionProps) { const { theme } = useUnistyles(); - const [scrollViewWidth, setScrollViewWidth] = useState(0); - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); - const horizontalScroll = useHorizontalScrollOptional(); - const scrollId = useId(); - const scrollViewRef = useRef(null); const expandStartRef = useRef(null); const { hunkCount, lineCount, tokenCount } = useMemo(() => { @@ -295,15 +290,6 @@ const DiffFileSection = memo(function DiffFileSection({ lineCount >= DIFF_FILE_LOG_LINE_THRESHOLD || tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD; - // Get the close gesture ref from animation context (may not be available outside sidebar) - let closeGestureRef: React.MutableRefObject | undefined; - try { - const animation = useExplorerSidebarAnimation(); - closeGestureRef = animation.closeGestureRef; - } catch { - // Not inside ExplorerSidebarAnimationProvider, which is fine - } - const toggleExpanded = useCallback(() => { if (isPerfLoggingEnabled() && shouldLogFileMetrics) { expandStartRef.current = getNowMs(); @@ -346,30 +332,14 @@ const DiffFileSection = memo(function DiffFileSection({ } }, [isExpanded, file.path, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]); - // Register/unregister scroll offset tracking - useEffect(() => { - if (!horizontalScroll || !isExpanded) return; - // Start at 0 (not scrolled) - horizontalScroll.registerScrollOffset(scrollId, 0); - return () => { - horizontalScroll.unregisterScrollOffset(scrollId); - }; - }, [horizontalScroll, isExpanded, scrollId]); - - const handleScroll = useCallback( - (event: NativeSyntheticEvent) => { - const offsetX = event.nativeEvent.contentOffset.x; - // Track if we're at the left edge (with small threshold for float precision) - setIsAtLeftEdge(offsetX <= 1); - if (horizontalScroll) { - horizontalScroll.registerScrollOffset(scrollId, offsetX); - } - }, - [horizontalScroll, scrollId] - ); - return ( - + [ @@ -412,13 +382,57 @@ const DiffFileSection = memo(function DiffFileSection({ -{file.deletions} - {isExpanded && (file.status === "too_large" || file.status === "binary") ? ( + + ); +}); + +function DiffFileBody({ file, testID }: { file: ParsedDiffFile; testID?: string }) { + const [scrollViewWidth, setScrollViewWidth] = useState(0); + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); + const horizontalScroll = useHorizontalScrollOptional(); + const scrollId = useId(); + const scrollViewRef = useRef(null); + + // Get the close gesture ref from animation context (may not be available outside sidebar) + let closeGestureRef: React.MutableRefObject | undefined; + try { + const animation = useExplorerSidebarAnimation(); + closeGestureRef = animation.closeGestureRef; + } catch { + // Not inside ExplorerSidebarAnimationProvider, which is fine + } + + // Register/unregister scroll offset tracking + useEffect(() => { + if (!horizontalScroll) return; + // Start at 0 (not scrolled) + horizontalScroll.registerScrollOffset(scrollId, 0); + return () => { + horizontalScroll.unregisterScrollOffset(scrollId); + }; + }, [horizontalScroll, scrollId]); + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + const offsetX = event.nativeEvent.contentOffset.x; + // Track if we're at the left edge (with small threshold for float precision) + setIsAtLeftEdge(offsetX <= 1); + if (horizontalScroll) { + horizontalScroll.registerScrollOffset(scrollId, offsetX); + } + }, + [horizontalScroll, scrollId] + ); + + return ( + + {file.status === "too_large" || file.status === "binary" ? ( {file.status === "binary" ? "Binary file" : "Diff too large to display"} - ) : isExpanded ? ( + ) : ( - ) : null} + )} ); -}); +} interface GitDiffPaneProps { serverId: string; @@ -455,6 +469,14 @@ interface GitDiffPaneProps { cwd: string; } +type GitDiffSection = { + key: string; + index: number; + file: ParsedDiffFile; + isExpanded: boolean; + data: ParsedDiffFile[]; +}; + export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const { theme } = useUnistyles(); const router = useRouter(); @@ -583,6 +605,19 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { })); }, []); + const diffSections = useMemo((): GitDiffSection[] => { + return files.map((file, index) => { + const isExpanded = expandedByPath[file.path] ?? false; + return { + key: file.path, + index, + file, + isExpanded, + data: isExpanded ? [file] : [], + }; + }); + }, [files, expandedByPath]); + const allExpanded = useMemo(() => { if (files.length === 0) return false; return files.every((file) => expandedByPath[file.path]); @@ -786,16 +821,23 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const pushAction = useActionStatus(pushMutation); const archiveAction = useActionStatus(archiveMutation); - const renderFileSection: ListRenderItem = useCallback( - ({ item, index }) => ( - = useCallback( + ({ item, section }) => ( + + ), + [] + ); + + const renderSectionHeader = useCallback( + ({ section }: { section: GitDiffSection }) => ( + ), - [expandedByPath, handleToggleExpanded] + [handleToggleExpanded] ); const keyExtractor = useCallback((item: ParsedDiffFile) => item.path, []); @@ -875,10 +917,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { ); } else { bodyContent = ( - ({ borderBottomWidth: 1, borderBottomColor: theme.colors.borderAccent, }, + fileSectionHeaderContainer: { + overflow: "hidden", + backgroundColor: theme.colors.surface2, + }, + fileSectionBodyContainer: { + overflow: "hidden", + backgroundColor: theme.colors.surface2, + }, + fileSectionBorder: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.borderAccent, + }, fileHeader: { flexDirection: "row", alignItems: "center", @@ -1456,6 +1512,9 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[2], gap: theme.spacing[1], + backgroundColor: theme.colors.surface2, + zIndex: 2, + elevation: 2, }, fileHeaderPressed: { opacity: 0.7,