fix(app): stabilize canvas diff rendering

Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.

Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:38 +02:00
parent 4d23905587
commit c7b103a2cd
50 changed files with 2180 additions and 580 deletions
@@ -1,26 +1,29 @@
import type { ReactElement } from "react";
import { withUnistyles } from "react-native-unistyles";
import { SquareMinus, SquarePlus } from "lucide-react-native";
import { SquareDot, SquareMinus, SquarePlus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
const ThemedSquarePlus = withUnistyles(SquarePlus);
const ThemedSquareMinus = withUnistyles(SquareMinus);
const ThemedSquareDot = withUnistyles(SquareDot);
const successMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
const warningMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const ICON_SIZE = 14;
/**
* Whether a file was added or removed outright, shown beside its diff stat.
* The file's change kind, shown beside its diff stat.
*
* Square-plus / square-minus because that is what every forge uses for the same fact, so it needs
* no label — the shape is the word. It sits at the same muted weight as the "+12 −3" next to it:
* these are footnotes on a file header, not the diff body where colour carries the line-by-line
* signal.
* The square variants form one aligned visual family for added, deleted, and modified files.
*/
export function FileChangeIcon({ change }: { change: "added" | "deleted" }): ReactElement {
export function FileChangeIcon({
change,
}: {
change: "added" | "deleted" | "modified";
}): ReactElement {
const { t } = useTranslation();
if (change === "added") {
@@ -33,11 +36,21 @@ export function FileChangeIcon({ change }: { change: "added" | "deleted" }): Rea
);
}
if (change === "deleted") {
return (
<ThemedSquareMinus
size={ICON_SIZE}
uniProps={dangerMapping}
accessibilityLabel={t("workspace.git.diff.deletedFile")}
/>
);
}
return (
<ThemedSquareMinus
<ThemedSquareDot
size={ICON_SIZE}
uniProps={dangerMapping}
accessibilityLabel={t("workspace.git.diff.deletedFile")}
uniProps={warningMapping}
accessibilityLabel={t("workspace.git.diff.modifiedFile")}
/>
);
}
@@ -31,10 +31,8 @@ import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide
import { MaterialFileIcon } from "@/components/material-file-icon";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
workspaceTreeRowStyles,
WORKSPACE_TREE_ICON_LABEL_GAP,
WORKSPACE_TREE_ICON_SIZE,
WORKSPACE_TREE_LOADING_ICON_SIZE,
@@ -201,8 +199,7 @@ function EntryNameInputRow({
);
return (
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
<TreeIndentGuides depth={depth} />
<View style={[workspaceTreeRowStyles.row, { paddingLeft: treeRowPaddingLeft(depth) }]}>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{kind === "directory" ? (
@@ -259,6 +256,9 @@ function TreeRowItem({
testID,
}: TreeRowItemProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const showNameHover = useCallback(() => setIsHovered(true), []);
const hideNameHover = useCallback(() => setIsHovered(false), []);
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
@@ -282,9 +282,9 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
workspaceTreeRowStyles.row,
{ paddingLeft: treeRowPaddingLeft(depth) },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
(Boolean(hovered) || pressed || isSelected) && workspaceTreeRowStyles.active,
],
[depth, isSelected],
);
@@ -364,11 +364,12 @@ function TreeRowItem({
onLongPress={handleSelect}
onContextMenu={handleSelect}
style={pressableStyle}
onHoverIn={showNameHover}
onHoverOut={hideNameHover}
accessibilityState={accessibilityState}
aria-selected={isSelected}
testID={testID}
>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{isDirectory ? (
@@ -377,7 +378,15 @@ function TreeRowItem({
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
<Text
style={[
styles.entryName,
workspaceTreeRowStyles.name,
isHovered && workspaceTreeRowStyles.nameHovered,
]}
numberOfLines={1}
testID={testID ? `${testID}-name` : undefined}
>
{entry.name}
</Text>
</View>
@@ -1738,16 +1747,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
entryRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
entryInfo: {
flex: 1,
flexDirection: "row",
+18 -39
View File
@@ -1,16 +1,14 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import { SPACING, type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// Shared presentation primitives for the app's directory trees. Both the Files
// explorer (server-loaded listings) and the Changes view (client-built from diff
// paths) render different data, but their ROWS should look identical — same
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// indentation, density, name emphasis, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const TREE_INDENT_PER_LEVEL = 12;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
export const WORKSPACE_TREE_ICON_SIZE = 16;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
@@ -22,8 +20,7 @@ export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
*/
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
/** Left padding for a row in either workspace tree. */
export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
}
@@ -34,32 +31,6 @@ const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
const ThemedChevronRight = withUnistyles(ChevronRight);
/**
* Vertical guide lines connecting nested rows to their ancestors — one line per
* ancestor depth level, positioned absolutely within the (relative) row. Renders
* nothing at depth 0.
*/
export function TreeIndentGuides({ depth }: { depth: number }) {
const guides = useMemo(
() =>
Array.from({ length: depth }, (_, index) => ({
key: index,
style: [
styles.indentGuide,
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
],
})),
[depth],
);
return (
<>
{guides.map((guide) => (
<View key={guide.key} style={guide.style} pointerEvents="none" />
))}
</>
);
}
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
export function TreeChevron({ expanded }: { expanded: boolean }) {
return (
@@ -72,14 +43,22 @@ export function TreeChevron({ expanded }: { expanded: boolean }) {
);
}
const styles = StyleSheet.create((theme: Theme) => ({
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
export const workspaceTreeRowStyles = StyleSheet.create((theme: Theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
},
active: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
name: { color: theme.colors.foreground, opacity: 0.76 },
nameHovered: { opacity: 1 },
}));
const styles = StyleSheet.create((_theme: Theme) => ({
chevron: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,