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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user