Update files

This commit is contained in:
Mohamed Boudra
2026-02-04 10:12:08 +07:00
parent 2fd818c156
commit dcf015bd92
2 changed files with 213 additions and 54 deletions

View File

@@ -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();
}
});

View File

@@ -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<ScrollViewType>(null);
const expandStartRef = useRef<number | null>(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<any> | 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<NativeScrollEvent>) => {
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 (
<View style={styles.fileSection} testID={testID}>
<View
style={[
styles.fileSectionHeaderContainer,
!isExpanded && styles.fileSectionBorder,
]}
testID={testID}
>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={({ pressed }) => [
@@ -412,13 +382,57 @@ const DiffFileSection = memo(function DiffFileSection({
<Text style={styles.deletions}>-{file.deletions}</Text>
</View>
</Pressable>
{isExpanded && (file.status === "too_large" || file.status === "binary") ? (
</View>
);
});
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<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar)
let closeGestureRef: React.MutableRefObject<any> | 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<NativeScrollEvent>) => {
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 (
<View style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]} testID={testID}>
{file.status === "too_large" || file.status === "binary" ? (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
) : isExpanded ? (
) : (
<ScrollView
ref={scrollViewRef}
horizontal
@@ -444,10 +458,10 @@ const DiffFileSection = memo(function DiffFileSection({
)}
</View>
</ScrollView>
) : null}
)}
</View>
);
});
}
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<ParsedDiffFile> = useCallback(
({ item, index }) => (
<DiffFileSection
file={item}
isExpanded={expandedByPath[item.path] ?? false}
const renderFileBody: SectionListRenderItem<ParsedDiffFile, GitDiffSection> = useCallback(
({ item, section }) => (
<DiffFileBody file={item} testID={`diff-file-${section.index}-body`} />
),
[]
);
const renderSectionHeader = useCallback(
({ section }: { section: GitDiffSection }) => (
<DiffFileHeader
file={section.file}
isExpanded={section.isExpanded}
onToggle={handleToggleExpanded}
testID={`diff-file-${index}`}
testID={`diff-file-${section.index}`}
/>
),
[expandedByPath, handleToggleExpanded]
[handleToggleExpanded]
);
const keyExtractor = useCallback((item: ParsedDiffFile) => item.path, []);
@@ -875,10 +917,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
);
} else {
bodyContent = (
<FlatList
data={files}
renderItem={renderFileSection}
<SectionList
sections={diffSections}
renderItem={renderFileBody}
renderSectionHeader={renderSectionHeader}
keyExtractor={keyExtractor}
stickySectionHeadersEnabled
extraData={expandedByPath}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
@@ -1449,6 +1493,18 @@ const styles = StyleSheet.create((theme) => ({
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,