Edit workspace files directly on web (#2270)
* feat(files): edit workspace files on web Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge. * feat(panels): surface and protect modified tabs Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently. * fix(tests): use portable fake timeout handle * fix(files): harden editor conflict handling Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface. * fix(files): close editor concurrency gaps Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads. * test(files): expect read revision metadata
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
Plus,
|
||||
FolderGit2,
|
||||
SquareTerminal,
|
||||
Code2,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
@@ -65,6 +66,7 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
||||
import { EditorSection } from "@/screens/settings/editor-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommunityLinks } from "@/components/community-links";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
@@ -135,6 +137,7 @@ interface SidebarSectionItem {
|
||||
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
|
||||
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
|
||||
{ id: "editor", labelKey: "settings.sections.editor", icon: Code2 },
|
||||
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
|
||||
{
|
||||
id: "integrations",
|
||||
@@ -1403,6 +1406,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
);
|
||||
case "appearance":
|
||||
return <AppearanceSection />;
|
||||
case "editor":
|
||||
return <EditorSection />;
|
||||
case "shortcuts":
|
||||
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
|
||||
case "integrations":
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Switch, Text, View } from "react-native";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
export function EditorSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const handleChange = useCallback(
|
||||
(vimKeybindings: boolean) => void updateSettings({ vimKeybindings }),
|
||||
[updateSettings],
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("settings.editor.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.editor.vimKeybindings")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.editor.vimHint")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.vimKeybindings}
|
||||
onValueChange={handleChange}
|
||||
accessibilityLabel={t("settings.editor.vimKeybindings")}
|
||||
testID="vim-keybindings-toggle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -49,4 +49,23 @@ describe("useMountedTabSet", () => {
|
||||
rerender({ activeTabId: "third" });
|
||||
expect(mountedIds(result)).toEqual(["third", "second"]);
|
||||
});
|
||||
|
||||
it("keeps retained panels mounted beyond the normal cap", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeTabId }) =>
|
||||
useMountedTabSet({
|
||||
activeTabId,
|
||||
allTabIds: ["modified", "second", "third", "fourth"],
|
||||
retainedTabIds: new Set(["modified"]),
|
||||
cap: 2,
|
||||
}),
|
||||
{ initialProps: { activeTabId: "modified" } },
|
||||
);
|
||||
|
||||
rerender({ activeTabId: "second" });
|
||||
rerender({ activeTabId: "third" });
|
||||
rerender({ activeTabId: "fourth" });
|
||||
|
||||
expect(mountedIds(result)).toEqual(["fourth", "modified"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ interface UseMountedTabSetInput {
|
||||
activeTabId: string | null;
|
||||
allTabIds: string[];
|
||||
cap: number;
|
||||
retainedTabIds?: Set<string>;
|
||||
}
|
||||
|
||||
interface UseMountedTabSetResult {
|
||||
@@ -15,6 +16,7 @@ interface DeriveMountedTabLruInput {
|
||||
availableTabIds: Set<string>;
|
||||
cap: number;
|
||||
previousLru: string[];
|
||||
retainedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] {
|
||||
@@ -25,7 +27,7 @@ function createInitialMountedTabLru(input: UseMountedTabSetInput): string[] {
|
||||
}
|
||||
|
||||
function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] {
|
||||
const { activeTabId, availableTabIds, cap, previousLru } = input;
|
||||
const { activeTabId, availableTabIds, cap, previousLru, retainedTabIds } = input;
|
||||
const maxSize = Math.max(1, cap);
|
||||
|
||||
const next: string[] = [];
|
||||
@@ -33,6 +35,10 @@ function deriveMountedTabLru(input: DeriveMountedTabLruInput): string[] {
|
||||
next.push(activeTabId);
|
||||
}
|
||||
|
||||
for (const tabId of retainedTabIds) {
|
||||
if (tabId !== activeTabId && availableTabIds.has(tabId)) next.push(tabId);
|
||||
}
|
||||
|
||||
for (const tabId of previousLru) {
|
||||
if (next.length >= maxSize) break;
|
||||
if (tabId !== activeTabId && availableTabIds.has(tabId)) {
|
||||
@@ -57,8 +63,9 @@ export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSet
|
||||
availableTabIds,
|
||||
cap,
|
||||
previousLru: committedLruRef.current,
|
||||
retainedTabIds: input.retainedTabIds ?? new Set(),
|
||||
}),
|
||||
[activeTabId, availableTabIds, cap],
|
||||
[activeTabId, availableTabIds, cap, input.retainedTabIds],
|
||||
);
|
||||
const mountedTabIds = useMemo(() => new Set<string>(mountedTabLru), [mountedTabLru]);
|
||||
|
||||
|
||||
@@ -553,12 +553,14 @@ function TabChip({
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
dragHandleProps: DraggableListDragHandleProps | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab;
|
||||
const middleClickRef = useMiddleClickClose(
|
||||
useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]),
|
||||
);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isHighlighted = isActive || hovered || isCloseHovered;
|
||||
const showTrailingAffordance = showCloseButton || presentation.modified;
|
||||
const closeButtonDragBlockers = isWeb
|
||||
? ({
|
||||
onPointerDown: (event: { stopPropagation?: () => void }) => {
|
||||
@@ -630,16 +632,19 @@ function TabChip({
|
||||
[isFocused],
|
||||
);
|
||||
const tabLabelSkeletonStyle = useMemo(
|
||||
() => [styles.tabLabelSkeleton, showCloseButton && styles.tabLabelSkeletonWithCloseButton],
|
||||
[showCloseButton],
|
||||
() => [
|
||||
styles.tabLabelSkeleton,
|
||||
showTrailingAffordance && styles.tabLabelSkeletonWithCloseButton,
|
||||
],
|
||||
[showTrailingAffordance],
|
||||
);
|
||||
const tabLabelStyle = useMemo(
|
||||
() => [
|
||||
styles.tabLabel,
|
||||
isHighlighted && styles.tabLabelActive,
|
||||
showCloseButton && styles.tabLabelWithCloseButton,
|
||||
showTrailingAffordance && styles.tabLabelWithCloseButton,
|
||||
],
|
||||
[isHighlighted, showCloseButton],
|
||||
[isHighlighted, showTrailingAffordance],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -672,7 +677,7 @@ function TabChip({
|
||||
tabLabelStyle={tabLabelStyle}
|
||||
/>
|
||||
|
||||
{showCloseButton ? (
|
||||
{showTrailingAffordance ? (
|
||||
<Pressable
|
||||
{...(closeButtonDragBlockers as object | undefined)}
|
||||
testID={closeButtonTestId}
|
||||
@@ -683,28 +688,43 @@ function TabChip({
|
||||
onPress={handleCloseButtonPress}
|
||||
style={closeButtonStyle}
|
||||
>
|
||||
{({ hovered: closeHovered, pressed }) =>
|
||||
isClosingTab ? (
|
||||
<ThemedActivityIndicator
|
||||
size={12}
|
||||
uniProps={
|
||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
||||
}
|
||||
{({ hovered: closeHovered, pressed }) => {
|
||||
const highlighted = closeHovered || pressed;
|
||||
if (isClosingTab) {
|
||||
return (
|
||||
<ThemedActivityIndicator
|
||||
size={12}
|
||||
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (highlighted || !presentation.modified) {
|
||||
return (
|
||||
<ThemedX
|
||||
size={12}
|
||||
uniProps={highlighted ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={styles.tabModifiedDot}
|
||||
accessibilityLabel={t("workspace.tabs.modified")}
|
||||
testID={`workspace-tab-modified-${buildDeterministicWorkspaceTabId(tab.target)}`}
|
||||
/>
|
||||
) : (
|
||||
<ThemedX
|
||||
size={12}
|
||||
uniProps={
|
||||
closeHovered || pressed ? foregroundColorMapping : mutedColorMapping
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="center"
|
||||
offset={8}
|
||||
maxWidth={720}
|
||||
testID={`workspace-tab-tooltip-${buildDeterministicWorkspaceTabId(tab.target)}`}
|
||||
>
|
||||
{tab.target.kind === "agent" ? (
|
||||
<View style={styles.tooltipAgentRow}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
@@ -1161,7 +1181,7 @@ function ResolvedDesktopTabChip({
|
||||
const tooltipLabel =
|
||||
presentation.titleState === "loading"
|
||||
? t("workspace.tabs.loadingAgentTitle")
|
||||
: presentation.label;
|
||||
: presentation.tooltip;
|
||||
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
@@ -1331,6 +1351,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
tabCloseButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
tabModifiedDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
newTabActionButton: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
|
||||
@@ -168,6 +168,10 @@ import {
|
||||
closeBulkWorkspaceTabs,
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import { resolveCloseAgentTabPolicy } from "@/subagents";
|
||||
import {
|
||||
getPanelInstanceAttributes,
|
||||
useModifiedPanelTabIds,
|
||||
} from "@/panels/panel-instance-attributes";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||
@@ -2661,11 +2665,8 @@ function WorkspaceScreenContent({
|
||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
|
||||
);
|
||||
|
||||
const handleCloseDraftOrFileTab = useCallback(
|
||||
function handleCloseDraftOrFileTab(input: {
|
||||
tabId: string;
|
||||
target?: WorkspaceTabTarget | null;
|
||||
}) {
|
||||
const handleClosePassiveTab = useCallback(
|
||||
function handleClosePassiveTab(input: { tabId: string; target?: WorkspaceTabTarget | null }) {
|
||||
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
|
||||
@@ -2674,12 +2675,37 @@ function WorkspaceScreenContent({
|
||||
[closeWorkspaceTabWithCleanup, persistenceKey],
|
||||
);
|
||||
|
||||
const confirmDiscardModifiedTab = useCallback(
|
||||
async (tabId: string): Promise<boolean> => {
|
||||
const attributes = getPanelInstanceAttributes({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
if (!attributes.modified) return true;
|
||||
const resumePendingSave = attributes.suspendPendingSave?.();
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("workspace.tabs.confirmations.unsavedTitle"),
|
||||
message: t("workspace.tabs.confirmations.unsavedMessage"),
|
||||
confirmLabel: t("workspace.tabs.confirmations.closeWithoutSaving"),
|
||||
cancelLabel: t("workspace.tabs.confirmations.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) resumePendingSave?.();
|
||||
return confirmed;
|
||||
},
|
||||
[normalizedServerId, normalizedWorkspaceId, t],
|
||||
);
|
||||
|
||||
const handleCloseTabById = useCallback(
|
||||
async (tabId: string) => {
|
||||
const tab = allTabDescriptorsById.get(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
if (!(await confirmDiscardModifiedTab(tabId))) {
|
||||
return;
|
||||
}
|
||||
if (tab.target.kind === "terminal") {
|
||||
await handleCloseTerminalTab({ tabId, terminalId: tab.target.terminalId });
|
||||
return;
|
||||
@@ -2688,9 +2714,15 @@ function WorkspaceScreenContent({
|
||||
await handleCloseAgentTab({ tabId, agentId: tab.target.agentId });
|
||||
return;
|
||||
}
|
||||
handleCloseDraftOrFileTab({ tabId, target: tab.target });
|
||||
handleClosePassiveTab({ tabId, target: tab.target });
|
||||
},
|
||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
||||
[
|
||||
allTabDescriptorsById,
|
||||
confirmDiscardModifiedTab,
|
||||
handleCloseAgentTab,
|
||||
handleClosePassiveTab,
|
||||
handleCloseTerminalTab,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCopyAgentId = useCallback(
|
||||
@@ -2834,9 +2866,21 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
const groups = classifyBulkClosableTabs(tabsToClose);
|
||||
const modifiedCount = tabsToClose.filter(
|
||||
(tab) =>
|
||||
getPanelInstanceAttributes({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: tab.tabId,
|
||||
}).modified,
|
||||
).length;
|
||||
const bulkMessage = buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels);
|
||||
const confirmed = await confirmDialog({
|
||||
title,
|
||||
message: buildBulkCloseConfirmationMessage(groups, bulkCloseConfirmationLabels),
|
||||
message:
|
||||
modifiedCount > 0
|
||||
? `${bulkMessage}\n\n${t("workspace.tabs.confirmations.bulkUnsaved", { count: modifiedCount })}`
|
||||
: bulkMessage,
|
||||
confirmLabel: t("workspace.tabs.confirmations.close"),
|
||||
cancelLabel: t("workspace.tabs.confirmations.cancel"),
|
||||
destructive: true,
|
||||
@@ -2869,6 +2913,8 @@ function WorkspaceScreenContent({
|
||||
client,
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
persistenceKey,
|
||||
t,
|
||||
],
|
||||
@@ -3055,12 +3101,15 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
if (action.id === "workspace.pane.close") {
|
||||
for (const tabId of focusedPane.tabIds) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: allTabDescriptorsById.get(tabId)?.target ?? null,
|
||||
});
|
||||
}
|
||||
const tabsToClose = focusedPane.tabIds.flatMap((tabId) => {
|
||||
const tab = allTabDescriptorsById.get(tabId);
|
||||
return tab ? [tab] : [];
|
||||
});
|
||||
void handleBulkCloseTabs({
|
||||
tabsToClose,
|
||||
title: t("workspace.tabs.confirmations.closePaneTitle"),
|
||||
logLabel: "from pane close",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3068,14 +3117,15 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[
|
||||
allTabDescriptorsById,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
focusWorkspacePane,
|
||||
handleBulkCloseTabs,
|
||||
handleCreateDraftSplit,
|
||||
moveWorkspaceTabToPane,
|
||||
persistenceKey,
|
||||
focusedPaneTabState.activeTabId,
|
||||
focusedPaneTabState.pane,
|
||||
toggleFocusMode,
|
||||
t,
|
||||
workspaceLayout,
|
||||
],
|
||||
);
|
||||
@@ -3210,10 +3260,16 @@ function WorkspaceScreenContent({
|
||||
[focusedPaneTabState.pane],
|
||||
);
|
||||
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
||||
const modifiedFocusedPaneTabIds = useModifiedPanelTabIds({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabIds: focusedPaneTabIds,
|
||||
});
|
||||
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
||||
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
|
||||
activeTabId,
|
||||
allTabIds: focusedPaneTabIds,
|
||||
retainedTabIds: modifiedFocusedPaneTabIds,
|
||||
cap: 3,
|
||||
});
|
||||
const mountedFocusedPaneTabIds = useMemo(
|
||||
|
||||
@@ -12,12 +12,15 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
|
||||
export interface WorkspaceTabPresentation {
|
||||
key: string;
|
||||
kind: WorkspaceTabDescriptor["kind"];
|
||||
label: string;
|
||||
subtitle: string;
|
||||
tooltip: string;
|
||||
modified: boolean;
|
||||
titleState: "ready" | "loading";
|
||||
icon: React.ComponentType<{ size: number; color: string }>;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
@@ -72,7 +75,9 @@ function WorkspaceTabPresentationResolverInner({
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
const attributes = usePanelInstanceAttributes({ serverId, workspaceId, tabId: tab.tabId });
|
||||
|
||||
const presentation = useMemo(
|
||||
() => ({
|
||||
@@ -80,6 +85,8 @@ function WorkspaceTabPresentationResolverInner({
|
||||
kind: tab.kind,
|
||||
label: descriptor.label,
|
||||
subtitle: descriptor.subtitle,
|
||||
tooltip: descriptor.tooltip,
|
||||
modified: attributes.modified,
|
||||
titleState: descriptor.titleState,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
@@ -87,11 +94,13 @@ function WorkspaceTabPresentationResolverInner({
|
||||
[
|
||||
descriptor.icon,
|
||||
descriptor.label,
|
||||
descriptor.tooltip,
|
||||
descriptor.statusBucket,
|
||||
descriptor.subtitle,
|
||||
descriptor.titleState,
|
||||
tab.key,
|
||||
tab.kind,
|
||||
attributes.modified,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -208,6 +217,9 @@ export function WorkspaceTabOptionRow({
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{presentation.modified ? (
|
||||
<View style={styles.optionModifiedDot} accessibilityLabel={t("workspace.tabs.modified")} />
|
||||
) : null}
|
||||
{selected ? (
|
||||
<View style={styles.optionTrailingSlot}>
|
||||
<ThemedCheckIcon size={16} uniProps={mutedColorMapping} />
|
||||
@@ -303,6 +315,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
optionModifiedDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
optionTrailingAccessorySlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
Reference in New Issue
Block a user