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:
@@ -15,7 +15,6 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
@@ -154,7 +153,7 @@ function TreeRowItem({
|
||||
<View style={styles.entryIcon}>
|
||||
{(() => {
|
||||
if (!isDirectory) {
|
||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
||||
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
lineNumber: number;
|
||||
gutterWidth: number;
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
interface FilePreviewBodyProps {
|
||||
preview: ExplorerFile | null;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
interface FileLineSelection {
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
}
|
||||
|
||||
function formatFileSize({ size }: { size: number }): string {
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
||||
file: ExplorerFile | null;
|
||||
imageAttachment: AttachmentMetadata | null;
|
||||
}> {
|
||||
if (!file) {
|
||||
return { file: null, imageAttachment: null };
|
||||
}
|
||||
|
||||
const explorerFile = explorerFileFromReadResult(file);
|
||||
if (file.kind !== "image") {
|
||||
return { file: explorerFile, imageAttachment: null };
|
||||
}
|
||||
|
||||
const imageAttachment = await persistAttachmentFromBytes({
|
||||
id: createPreviewAttachmentId({
|
||||
mimeType: file.mime,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
contentLength: file.bytes.byteLength,
|
||||
}),
|
||||
bytes: file.bytes,
|
||||
mimeType: file.mime,
|
||||
fileName: getFileNameFromPath(file.path),
|
||||
});
|
||||
|
||||
return {
|
||||
file: explorerFile,
|
||||
imageAttachment,
|
||||
};
|
||||
}
|
||||
|
||||
function clampLineSelection(input: {
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
lineCount: number;
|
||||
}): FileLineSelection | null {
|
||||
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
||||
const rawLineEnd =
|
||||
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
||||
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
highlighted,
|
||||
}: CodeLineProps) {
|
||||
const gutterStyle = useMemo(
|
||||
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
||||
[gutterWidth],
|
||||
);
|
||||
const lineStyle = useMemo(
|
||||
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
||||
[highlighted],
|
||||
);
|
||||
const keyedTokens = useMemo(
|
||||
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
||||
[tokens],
|
||||
);
|
||||
return (
|
||||
<View style={lineStyle}>
|
||||
<View style={gutterStyle}>
|
||||
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
||||
{String(lineNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable style={codeLineStyles.lineText}>
|
||||
{keyedTokens.map(({ key, token }) => (
|
||||
<CodeLineToken key={key} token={token} />
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface CodeLineTokenProps {
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
function CodeLineToken({ token }: CodeLineTokenProps) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
line: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
highlightedLine: {
|
||||
backgroundColor: theme.colors.accentBorder,
|
||||
},
|
||||
gutter: {
|
||||
alignItems: "flex-end",
|
||||
paddingRight: theme.spacing[3],
|
||||
flexShrink: 0,
|
||||
},
|
||||
gutterText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
opacity: 0.4,
|
||||
userSelect: "none",
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
function FilePreviewBody({
|
||||
preview,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const isMarkdownFile =
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highlightCode(preview.content ?? "", filePath);
|
||||
}, [isMarkdownFile, preview, filePath]);
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
|
||||
}, [highlightedLines, theme.fontSize.code]);
|
||||
const lineHeight = theme.fontSize.code * 1.45;
|
||||
const lineSelection = useMemo(() => {
|
||||
if (!highlightedLines) {
|
||||
return null;
|
||||
}
|
||||
return clampLineSelection({
|
||||
lineStart: location.lineStart,
|
||||
lineEnd: location.lineEnd,
|
||||
lineCount: highlightedLines.length,
|
||||
});
|
||||
}, [highlightedLines, location.lineEnd, location.lineStart]);
|
||||
|
||||
const imageSource = useMemo(
|
||||
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
||||
[imagePreviewUri],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lineSelection) {
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
previewScrollRef.current?.scrollTo({
|
||||
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
|
||||
animated: false,
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [lineHeight, lineSelection]);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (isMarkdownFile) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewMarkdownScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<MarkdownRenderer text={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||
const keyedLines = lines.map((tokens, index) => ({
|
||||
key: `line-${index}`,
|
||||
tokens,
|
||||
lineNumber: index + 1,
|
||||
}));
|
||||
const codeLines = (
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
||||
<CodeLine
|
||||
key={key}
|
||||
tokens={tokens}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
highlighted={
|
||||
Boolean(lineSelection) &&
|
||||
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
||||
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{isMobile ? (
|
||||
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
||||
) : (
|
||||
<RNScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
{codeLines}
|
||||
</RNScrollView>
|
||||
)}
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "image") {
|
||||
if (!imagePreviewUri) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewImageScrollContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<RNImage
|
||||
source={imageSource ?? undefined}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
location,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
location: WorkspaceFileLocation;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const readTarget = useMemo(
|
||||
() =>
|
||||
normalizedFilePath
|
||||
? resolveFilePreviewReadTarget({
|
||||
path: normalizedFilePath,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
})
|
||||
: null,
|
||||
[normalizedFilePath, normalizedWorkspaceRoot],
|
||||
);
|
||||
|
||||
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
||||
// covers tab switches; active app visibility covers backgrounding and returning
|
||||
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isAppVisible = useAppActivelyVisible();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
||||
enabled: isFileQueryEnabled({
|
||||
hasReadTarget: Boolean(client && readTarget),
|
||||
isTabActive: isActive,
|
||||
isAppVisible,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (!client || !readTarget) {
|
||||
return {
|
||||
file: null as ExplorerFile | null,
|
||||
error: t("workspace.terminal.hostDisconnected"),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
||||
const preview = await createFilePanePreview(file);
|
||||
return {
|
||||
file: preview.file,
|
||||
imageAttachment: preview.imageAttachment,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
file: null,
|
||||
imageAttachment: null,
|
||||
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
|
||||
};
|
||||
}
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
{query.data?.error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{query.data.error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FilePreviewBody
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
binaryMetaText: {
|
||||
marginTop: theme.spacing[2],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
previewScrollContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
previewContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
previewCodeScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
previewMarkdownScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
previewImageScrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
previewImage: {
|
||||
width: "100%",
|
||||
height: 420,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
|
||||
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
|
||||
return <SvgXml xml={getFileIconSvg(fileName)} width={size} height={size} />;
|
||||
}
|
||||
|
||||
export function createMaterialFileIcon(fileName: string): ComponentType<PanelIconProps> {
|
||||
function BoundMaterialFileIcon({ size }: PanelIconProps) {
|
||||
return <MaterialFileIcon fileName={fileName} size={size} />;
|
||||
}
|
||||
return BoundMaterialFileIcon;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
@@ -934,11 +935,17 @@ function SplitPaneView({
|
||||
);
|
||||
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
||||
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
|
||||
const modifiedPaneTabIds = useModifiedPanelTabIds({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabIds: paneTabIds,
|
||||
});
|
||||
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
|
||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||
const { mountedTabIds } = useMountedTabSet({
|
||||
activeTabId: activeTabDescriptor?.tabId ?? null,
|
||||
allTabIds: paneTabIds,
|
||||
retainedTabIds: modifiedPaneTabIds,
|
||||
cap: 3,
|
||||
});
|
||||
const mountedPaneTabIds = useMemo(
|
||||
|
||||
@@ -10,6 +10,7 @@ const theme = {
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
full: 9999,
|
||||
},
|
||||
borderWidth: {
|
||||
1: 1,
|
||||
@@ -102,12 +103,41 @@ describe("control geometry", () => {
|
||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||
});
|
||||
|
||||
it("subtracts segmented control inset from the nested segment radius", () => {
|
||||
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
||||
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
||||
expect(geometry.segmentedContainerXs.padding).toBe(0);
|
||||
expect(geometry.segmentedContainerSm.padding).toBe(0);
|
||||
expect(geometry.segmentedContainerMd.padding).toBe(0);
|
||||
expect(geometry.segmentedSegmentXs.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
|
||||
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
|
||||
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
|
||||
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
|
||||
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
|
||||
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
|
||||
});
|
||||
|
||||
it("keeps one size contract across buttons and segmented controls", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
// xs is a genuinely smaller tier, not sm with a different font.
|
||||
expect(geometry.buttonXs.minHeight).toBe(28);
|
||||
expect(geometry.buttonSm.minHeight).toBe(32);
|
||||
expect(geometry.buttonMd.minHeight).toBe(44);
|
||||
|
||||
// Same size name means the same label size on every control kind.
|
||||
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
|
||||
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
|
||||
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
|
||||
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
|
||||
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
|
||||
|
||||
// Same size name means the same horizontal padding on every control kind.
|
||||
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBe(geometry.buttonXs.paddingHorizontal);
|
||||
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBe(geometry.buttonSm.paddingHorizontal);
|
||||
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBe(geometry.buttonMd.paddingHorizontal);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||
export type FieldControlSize = "sm" | "md";
|
||||
export type SegmentedControlSize = "sm" | "md";
|
||||
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||
|
||||
export interface ControlInteractionState {
|
||||
@@ -22,8 +22,10 @@ export interface ControlInteractionStyleMap {
|
||||
controlDisabled?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const TIGHT_CONTROL_HEIGHT = 28;
|
||||
const COMPACT_CONTROL_HEIGHT = 32;
|
||||
const FIELD_CONTROL_HEIGHT = 44;
|
||||
const SEGMENTED_TIGHT_INSET = 2;
|
||||
const SEGMENTED_COMPACT_INSET = 2;
|
||||
const SEGMENTED_FIELD_INSET = 3;
|
||||
const SWITCH_TRACK_WIDTH = 34;
|
||||
@@ -35,6 +37,7 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
|
||||
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
||||
|
||||
const controlHeights = {
|
||||
tight: TIGHT_CONTROL_HEIGHT,
|
||||
compact: COMPACT_CONTROL_HEIGHT,
|
||||
field: FIELD_CONTROL_HEIGHT,
|
||||
};
|
||||
@@ -47,6 +50,7 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
|
||||
};
|
||||
|
||||
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
||||
xs: ICON_SIZE.xs,
|
||||
sm: ICON_SIZE.sm,
|
||||
md: ICON_SIZE.md,
|
||||
};
|
||||
@@ -58,10 +62,6 @@ export const switchGeometry = {
|
||||
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
||||
};
|
||||
|
||||
function nestedRadius(containerRadius: number, inset: number): number {
|
||||
return Math.max(0, containerRadius - inset);
|
||||
}
|
||||
|
||||
function fieldLineHeight(fontSize: number): number {
|
||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||
}
|
||||
@@ -121,8 +121,6 @@ export function createControlGeometry(theme: Theme) {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: fieldTextMdLineHeight,
|
||||
};
|
||||
const segmentedContainerSmRadius = theme.borderRadius.md;
|
||||
const segmentedContainerMdRadius = theme.borderRadius.lg;
|
||||
const switchControl = {
|
||||
minHeight: controlHeights.compact,
|
||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||
@@ -130,7 +128,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
|
||||
return {
|
||||
buttonXs: {
|
||||
minHeight: controlHeights.compact,
|
||||
minHeight: controlHeights.tight,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
@@ -194,31 +192,41 @@ export function createControlGeometry(theme: Theme) {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
switchControl,
|
||||
segmentedContainerXs: {
|
||||
minHeight: controlHeights.tight,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedContainerSm: {
|
||||
minHeight: controlHeights.compact,
|
||||
padding: SEGMENTED_COMPACT_INSET,
|
||||
borderRadius: segmentedContainerSmRadius,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedContainerMd: {
|
||||
minHeight: controlHeights.field,
|
||||
padding: SEGMENTED_FIELD_INSET,
|
||||
borderRadius: segmentedContainerMdRadius,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedSegmentXs: {
|
||||
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
segmentedLabelSm: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
segmentedLabelMd: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
|
||||
|
||||
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
||||
|
||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
|
||||
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
@@ -53,9 +53,14 @@ export function SegmentedControl<T extends string>({
|
||||
style,
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||
const sizeStyles = {
|
||||
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
|
||||
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
|
||||
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
|
||||
}[size];
|
||||
const containerSizeStyle = sizeStyles.container;
|
||||
const segmentSizeStyle = sizeStyles.segment;
|
||||
const labelSizeStyle = sizeStyles.label;
|
||||
const iconSize = segmentedIconSize[size];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -161,9 +166,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
containerXs: {
|
||||
...geometry.segmentedContainerXs,
|
||||
},
|
||||
containerSm: {
|
||||
...geometry.segmentedContainerSm,
|
||||
@@ -178,6 +186,9 @@ const styles = StyleSheet.create((theme) => {
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentXs: {
|
||||
...geometry.segmentedSegmentXs,
|
||||
},
|
||||
segmentSm: {
|
||||
...geometry.segmentedSegmentSm,
|
||||
},
|
||||
@@ -185,18 +196,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedSegmentMd,
|
||||
},
|
||||
segmentSelected: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
backgroundColor: theme.colors.foreground,
|
||||
},
|
||||
segmentHover: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
segmentPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
segmentDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
@@ -209,6 +215,9 @@ const styles = StyleSheet.create((theme) => {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
labelXs: {
|
||||
...geometry.segmentedLabelXs,
|
||||
},
|
||||
labelSm: {
|
||||
...geometry.segmentedLabelSm,
|
||||
},
|
||||
@@ -216,7 +225,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedLabelMd,
|
||||
},
|
||||
labelSelected: {
|
||||
color: theme.colors.foreground,
|
||||
color: theme.colors.surface0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user