feat: add download toast, e2e permission tests, and update favicons

- Add download toast component with progress indicator and auto-dismiss
- Add download store for managing file download state
- Add e2e tests for permission prompts (allow/deny flows)
- Add global setup for e2e tests with isolated daemon instance
- Update favicon assets with new design
- Refactor agent-stream-view to use shared tool-call-details component
- Simplify file-explorer-pane component
- Clean up unused screenshots and PRODUCTION.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Mohamed Boudra
2026-01-16 20:23:07 +07:00
parent f733f2852d
commit 420f25f1e9
39 changed files with 877 additions and 931 deletions
@@ -16,7 +16,6 @@ import Markdown from "react-native-markdown-display";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { Fonts } from "@/constants/theme";
import Animated, {
FadeIn,
FadeOut,
@@ -41,18 +40,14 @@ import {
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { DiffViewer } from "./diff-viewer";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
import {
extractCommandDetails,
extractEditEntries,
extractReadEntries,
} from "@/utils/tool-call-parsers";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -762,9 +757,6 @@ function PermissionRequestCard({
const { request } = permission;
const title = request.title ?? request.name ?? "Permission Required";
const description = request.description ?? "";
const inputPreview = request.input
? JSON.stringify(request.input, null, 2)
: null;
const planMarkdown = useMemo(() => {
if (!request) {
@@ -784,19 +776,9 @@ function PermissionRequestCard({
return undefined;
}, [request]);
const editEntries = useMemo(
() => extractEditEntries(request.input, request.metadata),
[request]
);
const readEntries = useMemo(
() => extractReadEntries(request.input, request.metadata),
[request]
);
const commandDetails = useMemo(
() => extractCommandDetails(request.input, request.metadata),
[request]
const toolCallDisplay = useMemo(
() => parseToolCallDisplay(request.input, null),
[request.input]
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
@@ -1016,187 +998,7 @@ function PermissionRequestCard({
</View>
) : null}
{commandDetails ? (
<View style={permissionStyles.section}>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.foregroundMuted },
]}
>
Command
</Text>
{commandDetails.command ? (
<View style={permissionStyles.metadataRow}>
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.foregroundMuted },
]}
>
Command
</Text>
<Text
style={[
permissionStyles.metadataValue,
{ color: theme.colors.foreground },
]}
>
{commandDetails.command}
</Text>
</View>
) : null}
{commandDetails.cwd ? (
<View style={permissionStyles.metadataRow}>
<Text
style={[
permissionStyles.metadataLabel,
{ color: theme.colors.foregroundMuted },
]}
>
Directory
</Text>
<Text
style={[
permissionStyles.metadataValue,
{ color: theme.colors.foreground },
]}
>
{commandDetails.cwd}
</Text>
</View>
) : null}
</View>
) : null}
{editEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.foregroundMuted },
]}
>
Proposed Changes
</Text>
{editEntries.map((entry, index) => (
<View
key={`${entry.filePath ?? "change"}-${index}`}
style={permissionStyles.diffSection}
>
{entry.filePath ? (
<View
style={[
permissionStyles.fileBadge,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
]}
>
<Text
style={[
permissionStyles.fileBadgeText,
{ color: theme.colors.foreground },
]}
>
{entry.filePath}
</Text>
</View>
) : null}
<View
style={[
permissionStyles.diffWrapper,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
},
]}
>
<DiffViewer diffLines={entry.diffLines} maxHeight={200} />
</View>
</View>
))}
</View>
) : null}
{readEntries.length > 0 ? (
<View style={permissionStyles.section}>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.foregroundMuted },
]}
>
File Content
</Text>
{readEntries.map((entry, index) => (
<View
key={`${entry.filePath ?? "content"}-${index}`}
style={[
permissionStyles.contentCard,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
>
{entry.filePath ? (
<Text
style={[
permissionStyles.metadataLabel,
{
color: theme.colors.foregroundMuted,
marginBottom: theme.spacing[1],
},
]}
>
{entry.filePath}
</Text>
) : null}
<Text
style={[
permissionStyles.rawContentText,
{ color: theme.colors.foreground },
]}
>
{entry.content}
</Text>
</View>
))}
</View>
) : null}
{inputPreview ? (
<View style={permissionStyles.section}>
<Text
style={[
permissionStyles.sectionTitle,
{ color: theme.colors.foregroundMuted },
]}
>
Raw Request
</Text>
<View
style={[
permissionStyles.contentCard,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
>
<Text
style={[
permissionStyles.rawContentText,
{ color: theme.colors.foreground },
]}
>
{inputPreview}
</Text>
</View>
</View>
) : null}
<ToolCallDetailsContent display={toolCallDisplay} maxHeight={200} />
<Text
style={[
@@ -1401,42 +1203,6 @@ const permissionStyles = StyleSheet.create((theme) => ({
flexShrink: 1,
minWidth: 0,
},
metadataRow: {
marginBottom: theme.spacing[2],
},
metadataLabel: {
fontSize: theme.fontSize.xs,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
},
metadataValue: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
},
diffSection: {
gap: theme.spacing[2],
},
fileBadge: {
alignSelf: "flex-start",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
},
fileBadgeText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
},
diffWrapper: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
overflow: "hidden",
},
rawContentText: {
fontFamily: Fonts.mono,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
question: {
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[2],
@@ -0,0 +1,141 @@
import { useEffect, useRef } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, X, XCircle } from "lucide-react-native";
import { useDownloadStore, formatSpeed, formatEta } from "@/stores/download-store";
const AUTO_DISMISS_DELAY = 3000;
export function DownloadToast() {
const { theme } = useUnistyles();
const downloads = useDownloadStore((state) => state.downloads);
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
const dismissDownload = useDownloadStore((state) => state.dismissDownload);
const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeDownload = activeDownloadId ? downloads.get(activeDownloadId) : null;
useEffect(() => {
if (dismissTimeoutRef.current) {
clearTimeout(dismissTimeoutRef.current);
dismissTimeoutRef.current = null;
}
if (activeDownload && activeDownload.status !== "downloading") {
dismissTimeoutRef.current = setTimeout(() => {
dismissDownload(activeDownload.id);
}, AUTO_DISMISS_DELAY);
}
return () => {
if (dismissTimeoutRef.current) {
clearTimeout(dismissTimeoutRef.current);
}
};
}, [activeDownload, dismissDownload]);
if (!activeDownload) {
return null;
}
return (
<View style={styles.container} pointerEvents="box-none">
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : activeDownload.status === "complete" ? (
<Check size={18} color={theme.colors.primary} />
) : (
<XCircle size={18} color={theme.colors.destructive} />
)}
<View style={styles.textContainer}>
<Text style={styles.fileName} numberOfLines={1}>
{activeDownload.fileName}
</Text>
<Text style={styles.status}>
{activeDownload.status === "downloading"
? activeDownload.progress
? `${Math.round(activeDownload.progress.percent * 100)}% · ${formatSpeed(activeDownload.progress.speed)} · ${formatEta(activeDownload.progress.eta)}`
: "Starting..."
: activeDownload.status === "complete"
? "Download complete"
: activeDownload.message ?? "Download failed"}
</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${Math.round(activeDownload.progress.percent * 100)}%` },
]}
/>
</View>
)}
</View>
{activeDownload.status !== "downloading" && (
<Pressable
onPress={() => dismissDownload(activeDownload.id)}
hitSlop={8}
style={styles.dismiss}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",
bottom: theme.spacing[4],
left: theme.spacing[4],
right: theme.spacing[4],
zIndex: 1000,
},
toast: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
},
textContainer: {
flex: 1,
gap: theme.spacing[1],
},
fileName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
status: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
progressBar: {
height: 3,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.full,
marginTop: theme.spacing[1],
overflow: "hidden",
},
progressFill: {
height: "100%",
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.full,
},
dismiss: {
padding: theme.spacing[1],
},
}));
@@ -10,7 +10,6 @@ import {
NativeScrollEvent,
NativeSyntheticEvent,
Modal,
Platform,
Pressable,
ScrollView,
Text,
@@ -20,9 +19,6 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import { File as FSFile, Paths } from "expo-file-system";
import * as LegacyFileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import {
BottomSheetModal,
BottomSheetScrollView,
@@ -30,7 +26,6 @@ import {
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
Check,
ChevronDown,
Download,
File,
@@ -39,12 +34,11 @@ import {
Image as ImageIcon,
MoreVertical,
X,
XCircle,
} from "lucide-react-native";
import type { ExplorerEntry } from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import {
usePanelStore,
type SortOption,
@@ -170,21 +164,7 @@ export function FileExplorerPane({
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
const [menuHeight, setMenuHeight] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [downloadToast, setDownloadToast] = useState<{
status: "downloading" | "complete" | "error";
fileName: string;
message?: string;
progress?: {
percent: number;
bytesWritten: number;
totalBytes: number;
speed: number;
eta: number;
};
} | null>(null);
const downloadStartTimeRef = useRef<number>(0);
const lastProgressRef = useRef<{ bytes: number; time: number } | null>(null);
const downloadToastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startDownload = useDownloadStore((state) => state.startDownload);
const agentIdRef = useRef(agentId);
const viewModeRef = useRef(viewMode);
const requestFilePreviewRef = useRef(requestFilePreview);
@@ -434,121 +414,22 @@ export function FileExplorerPane({
setMenuHeight((current) => (current === height ? current : height));
}, []);
const showDownloadToast = useCallback(
(toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => {
if (downloadToastTimeoutRef.current) {
clearTimeout(downloadToastTimeoutRef.current);
downloadToastTimeoutRef.current = null;
}
setDownloadToast(toast);
if (toast.status !== "downloading") {
downloadToastTimeoutRef.current = setTimeout(() => {
setDownloadToast(null);
}, 3000);
}
},
[]
);
const handleDownloadEntry = useCallback(
async (entry: ExplorerEntry) => {
(entry: ExplorerEntry) => {
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
return;
}
const displayName = entry.name;
try {
const tokenResponse = await requestFileDownloadToken(agentId, entry.path);
if (tokenResponse.error || !tokenResponse.token) {
throw new Error(tokenResponse.error ?? "Failed to request download token.");
}
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
if (!downloadTarget.baseUrl) {
throw new Error("Download host is unavailable.");
}
const fileName = tokenResponse.fileName ?? entry.name;
const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl,
tokenResponse.token,
Platform.OS === "web" ? downloadTarget.authCredentials : null
);
if (Platform.OS === "web") {
triggerBrowserDownload(downloadUrl, fileName);
return;
}
downloadStartTimeRef.current = Date.now();
lastProgressRef.current = null;
showDownloadToast({ status: "downloading", fileName: displayName });
const targetFile = resolveDownloadTargetFile(fileName);
const downloadResumable = LegacyFileSystem.createDownloadResumable(
downloadUrl,
targetFile.uri,
downloadTarget.authHeader
? { headers: { Authorization: downloadTarget.authHeader } }
: undefined,
(data) => {
const now = Date.now();
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
if (totalBytesExpectedToWrite <= 0) {
return;
}
const percent = totalBytesWritten / totalBytesExpectedToWrite;
const elapsed = (now - downloadStartTimeRef.current) / 1000;
const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0;
const remaining = totalBytesExpectedToWrite - totalBytesWritten;
const eta = speed > 0 ? remaining / speed : 0;
lastProgressRef.current = { bytes: totalBytesWritten, time: now };
setDownloadToast((prev) =>
prev?.status === "downloading"
? {
...prev,
progress: {
percent,
bytesWritten: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite,
speed,
eta,
},
}
: prev
);
}
);
const result = await downloadResumable.downloadAsync();
if (!result) {
throw new Error("Download was cancelled.");
}
showDownloadToast({ status: "complete", fileName: displayName });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(result.uri, {
mimeType: tokenResponse.mimeType ?? undefined,
dialogTitle: fileName ? `Share ${fileName}` : "Share file",
});
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to download file.";
if (Platform.OS === "web") {
console.warn("[FileExplorer] Download failed:", message);
return;
}
showDownloadToast({ status: "error", fileName: displayName, message });
}
startDownload({
serverId,
agentId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken,
});
},
[agentId, daemonProfile, requestFileDownloadToken, showDownloadToast]
[agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload]
);
const menuPosition = useMemo(() => {
@@ -991,53 +872,6 @@ export function FileExplorerPane({
</View>
)}
</BottomSheetModal>
{downloadToast && (
<View style={styles.downloadToast}>
<View style={styles.downloadToastContent}>
{downloadToast.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : downloadToast.status === "complete" ? (
<Check size={18} color={theme.colors.primary} />
) : (
<XCircle size={18} color={theme.colors.destructive} />
)}
<View style={styles.downloadToastTextContainer}>
<Text style={styles.downloadToastFileName} numberOfLines={1}>
{downloadToast.fileName}
</Text>
<Text style={styles.downloadToastStatus}>
{downloadToast.status === "downloading"
? downloadToast.progress
? `${Math.round(downloadToast.progress.percent * 100)}% · ${formatSpeed(downloadToast.progress.speed)} · ${formatEta(downloadToast.progress.eta)}`
: "Starting..."
: downloadToast.status === "complete"
? "Download complete"
: downloadToast.message ?? "Download failed"}
</Text>
{downloadToast.status === "downloading" && downloadToast.progress && (
<View style={styles.downloadProgressBar}>
<View
style={[
styles.downloadProgressFill,
{ width: `${Math.round(downloadToast.progress.percent * 100)}%` },
]}
/>
</View>
)}
</View>
{downloadToast.status !== "downloading" && (
<Pressable
onPress={() => setDownloadToast(null)}
hitSlop={8}
style={styles.downloadToastDismiss}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
)}
</View>
</View>
)}
</View>
);
}
@@ -1056,28 +890,6 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function formatSpeed(bytesPerSecond: number): string {
if (bytesPerSecond < 1024) {
return `${Math.round(bytesPerSecond)} B/s`;
}
if (bytesPerSecond < 1024 * 1024) {
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
}
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
}
function formatEta(seconds: number): string {
if (seconds < 1) {
return "< 1s";
}
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${mins}m ${secs}s`;
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
@@ -1176,120 +988,6 @@ function getExtension(name: string): string | null {
return name.slice(index + 1).toLowerCase();
}
type DownloadTarget = {
baseUrl: string | null;
authHeader: string | null;
authCredentials: { username: string; password: string } | null;
};
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
if (!rawUrl) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
if (parsed.protocol === "ws:") {
parsed.protocol = "http:";
} else if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
}
let authCredentials: { username: string; password: string } | null = null;
if (parsed.username || parsed.password) {
authCredentials = {
username: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
parsed.username = "";
parsed.password = "";
}
parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/");
const baseUrl = parsed.origin;
const authHeader = authCredentials
? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`
: null;
return { baseUrl, authHeader, authCredentials };
}
function buildDownloadUrl(
baseUrl: string,
token: string,
authCredentials: { username: string; password: string } | null
): string {
const url = new URL("/api/files/download", baseUrl);
url.searchParams.set("token", token);
if (authCredentials) {
url.username = authCredentials.username;
url.password = authCredentials.password;
}
return url.toString();
}
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener");
}
return;
}
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
}
function resolveDownloadTargetFile(fileName: string): FSFile {
const directory = Paths.cache ?? Paths.document;
if (!directory) {
throw new Error("No download directory available.");
}
const safeName = sanitizeDownloadFileName(fileName);
const split = splitFileName(safeName);
let targetFile = new FSFile(directory, safeName);
let suffix = 1;
while (targetFile.exists) {
targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`);
suffix += 1;
}
return targetFile;
}
function sanitizeDownloadFileName(fileName: string): string {
const trimmed = fileName.trim();
if (!trimmed) {
return "download";
}
return trimmed.replace(/[\\/:*?"<>|]+/g, "_");
}
function splitFileName(fileName: string): { base: string; ext: string } {
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) {
return { base: fileName, ext: "" };
}
return {
base: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -1577,55 +1275,4 @@ const styles = StyleSheet.create((theme) => ({
width: "100%",
aspectRatio: 1,
},
downloadToast: {
position: "absolute",
bottom: theme.spacing[4],
left: theme.spacing[4],
right: theme.spacing[4],
zIndex: 1000,
},
downloadToastContent: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 8,
},
downloadToastTextContainer: {
flex: 1,
gap: theme.spacing[1],
},
downloadToastFileName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
downloadToastStatus: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
downloadProgressBar: {
height: 3,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.full,
marginTop: theme.spacing[1],
overflow: "hidden",
},
downloadProgressFill: {
height: "100%",
backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.full,
},
downloadToastDismiss: {
padding: theme.spacing[1],
},
}));
@@ -286,6 +286,11 @@ const DiffFileSection = memo(function DiffFileSection({
<Text style={styles.newBadgeText}>New</Text>
</View>
)}
{file.isDeleted && (
<View style={styles.deletedBadge}>
<Text style={styles.deletedBadgeText}>Deleted</Text>
</View>
)}
</View>
<View style={styles.fileHeaderRight}>
<Text style={styles.additions}>+{file.additions}</Text>
@@ -579,6 +584,18 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
deletedBadge: {
backgroundColor: "rgba(248, 81, 73, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
deletedBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
additions: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,