Update files
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
FlatList,
|
SectionList,
|
||||||
Platform,
|
Platform,
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
type ListRenderItem,
|
type SectionListRenderItem,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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,
|
file,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
testID,
|
testID,
|
||||||
}: DiffFileSectionProps) {
|
}: DiffFileSectionProps) {
|
||||||
const { theme } = useUnistyles();
|
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 expandStartRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const { hunkCount, lineCount, tokenCount } = useMemo(() => {
|
const { hunkCount, lineCount, tokenCount } = useMemo(() => {
|
||||||
@@ -295,15 +290,6 @@ const DiffFileSection = memo(function DiffFileSection({
|
|||||||
lineCount >= DIFF_FILE_LOG_LINE_THRESHOLD ||
|
lineCount >= DIFF_FILE_LOG_LINE_THRESHOLD ||
|
||||||
tokenCount >= DIFF_FILE_LOG_TOKEN_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(() => {
|
const toggleExpanded = useCallback(() => {
|
||||||
if (isPerfLoggingEnabled() && shouldLogFileMetrics) {
|
if (isPerfLoggingEnabled() && shouldLogFileMetrics) {
|
||||||
expandStartRef.current = getNowMs();
|
expandStartRef.current = getNowMs();
|
||||||
@@ -346,30 +332,14 @@ const DiffFileSection = memo(function DiffFileSection({
|
|||||||
}
|
}
|
||||||
}, [isExpanded, file.path, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
|
}, [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 (
|
return (
|
||||||
<View style={styles.fileSection} testID={testID}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.fileSectionHeaderContainer,
|
||||||
|
!isExpanded && styles.fileSectionBorder,
|
||||||
|
]}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={testID ? `${testID}-toggle` : undefined}
|
testID={testID ? `${testID}-toggle` : undefined}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
@@ -412,13 +382,57 @@ const DiffFileSection = memo(function DiffFileSection({
|
|||||||
<Text style={styles.deletions}>-{file.deletions}</Text>
|
<Text style={styles.deletions}>-{file.deletions}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</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}>
|
<View style={styles.statusMessageContainer}>
|
||||||
<Text style={styles.statusMessageText}>
|
<Text style={styles.statusMessageText}>
|
||||||
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : isExpanded ? (
|
) : (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
horizontal
|
horizontal
|
||||||
@@ -444,10 +458,10 @@ const DiffFileSection = memo(function DiffFileSection({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
interface GitDiffPaneProps {
|
interface GitDiffPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -455,6 +469,14 @@ interface GitDiffPaneProps {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GitDiffSection = {
|
||||||
|
key: string;
|
||||||
|
index: number;
|
||||||
|
file: ParsedDiffFile;
|
||||||
|
isExpanded: boolean;
|
||||||
|
data: ParsedDiffFile[];
|
||||||
|
};
|
||||||
|
|
||||||
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
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(() => {
|
const allExpanded = useMemo(() => {
|
||||||
if (files.length === 0) return false;
|
if (files.length === 0) return false;
|
||||||
return files.every((file) => expandedByPath[file.path]);
|
return files.every((file) => expandedByPath[file.path]);
|
||||||
@@ -786,16 +821,23 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
const pushAction = useActionStatus(pushMutation);
|
const pushAction = useActionStatus(pushMutation);
|
||||||
const archiveAction = useActionStatus(archiveMutation);
|
const archiveAction = useActionStatus(archiveMutation);
|
||||||
|
|
||||||
const renderFileSection: ListRenderItem<ParsedDiffFile> = useCallback(
|
const renderFileBody: SectionListRenderItem<ParsedDiffFile, GitDiffSection> = useCallback(
|
||||||
({ item, index }) => (
|
({ item, section }) => (
|
||||||
<DiffFileSection
|
<DiffFileBody file={item} testID={`diff-file-${section.index}-body`} />
|
||||||
file={item}
|
),
|
||||||
isExpanded={expandedByPath[item.path] ?? false}
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSectionHeader = useCallback(
|
||||||
|
({ section }: { section: GitDiffSection }) => (
|
||||||
|
<DiffFileHeader
|
||||||
|
file={section.file}
|
||||||
|
isExpanded={section.isExpanded}
|
||||||
onToggle={handleToggleExpanded}
|
onToggle={handleToggleExpanded}
|
||||||
testID={`diff-file-${index}`}
|
testID={`diff-file-${section.index}`}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[expandedByPath, handleToggleExpanded]
|
[handleToggleExpanded]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: ParsedDiffFile) => item.path, []);
|
const keyExtractor = useCallback((item: ParsedDiffFile) => item.path, []);
|
||||||
@@ -875,10 +917,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
bodyContent = (
|
bodyContent = (
|
||||||
<FlatList
|
<SectionList
|
||||||
data={files}
|
sections={diffSections}
|
||||||
renderItem={renderFileSection}
|
renderItem={renderFileBody}
|
||||||
|
renderSectionHeader={renderSectionHeader}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
|
stickySectionHeadersEnabled
|
||||||
extraData={expandedByPath}
|
extraData={expandedByPath}
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.contentContainer}
|
contentContainerStyle={styles.contentContainer}
|
||||||
@@ -1449,6 +1493,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.borderAccent,
|
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: {
|
fileHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -1456,6 +1512,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
zIndex: 2,
|
||||||
|
elevation: 2,
|
||||||
},
|
},
|
||||||
fileHeaderPressed: {
|
fileHeaderPressed: {
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
|
|||||||
Reference in New Issue
Block a user