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:
@@ -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