feat(app): add @ workspace file autocomplete in agent chat
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,27 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
|
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||||
import { Theme } from "@/styles/theme";
|
import { File, Folder } from 'lucide-react-native'
|
||||||
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
import { Theme } from '@/styles/theme'
|
||||||
|
import { getAutocompleteScrollOffset } from './autocomplete-utils'
|
||||||
|
|
||||||
export interface AutocompleteOption {
|
export interface AutocompleteOption {
|
||||||
id: string;
|
id: string
|
||||||
label: string;
|
label: string
|
||||||
detail?: string;
|
detail?: string
|
||||||
description?: string;
|
description?: string
|
||||||
|
kind?: 'command' | 'file' | 'directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutocompleteProps {
|
interface AutocompleteProps {
|
||||||
options: readonly AutocompleteOption[];
|
options: readonly AutocompleteOption[]
|
||||||
selectedIndex: number;
|
selectedIndex: number
|
||||||
onSelect: (option: AutocompleteOption) => void;
|
onSelect: (option: AutocompleteOption) => void
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
errorMessage?: string;
|
errorMessage?: string
|
||||||
loadingText?: string;
|
loadingText?: string
|
||||||
emptyText?: string;
|
emptyText?: string
|
||||||
maxHeight?: number;
|
maxHeight?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Autocomplete({
|
export function Autocomplete({
|
||||||
@@ -28,23 +30,24 @@ export function Autocomplete({
|
|||||||
onSelect,
|
onSelect,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
loadingText = "Loading...",
|
loadingText = 'Loading...',
|
||||||
emptyText = "No results found",
|
emptyText = 'No results found',
|
||||||
maxHeight = 220,
|
maxHeight = 220,
|
||||||
}: AutocompleteProps) {
|
}: AutocompleteProps) {
|
||||||
const scrollRef = useRef<ScrollView>(null);
|
const { theme } = useUnistyles()
|
||||||
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
|
const scrollRef = useRef<ScrollView>(null)
|
||||||
const viewportHeightRef = useRef(0);
|
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map())
|
||||||
const scrollOffsetRef = useRef(0);
|
const viewportHeightRef = useRef(0)
|
||||||
|
const scrollOffsetRef = useRef(0)
|
||||||
|
|
||||||
const ensureActiveItemVisible = useCallback(() => {
|
const ensureActiveItemVisible = useCallback(() => {
|
||||||
if (selectedIndex < 0) {
|
if (selectedIndex < 0) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = rowLayoutsRef.current.get(selectedIndex);
|
const layout = rowLayoutsRef.current.get(selectedIndex)
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextOffset = getAutocompleteScrollOffset({
|
const nextOffset = getAutocompleteScrollOffset({
|
||||||
@@ -52,60 +55,60 @@ export function Autocomplete({
|
|||||||
viewportHeight: viewportHeightRef.current,
|
viewportHeight: viewportHeightRef.current,
|
||||||
itemTop: layout.top,
|
itemTop: layout.top,
|
||||||
itemHeight: layout.height,
|
itemHeight: layout.height,
|
||||||
});
|
})
|
||||||
|
|
||||||
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollOffsetRef.current = nextOffset;
|
scrollOffsetRef.current = nextOffset
|
||||||
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex])
|
||||||
|
|
||||||
const pinToBottom = useCallback(() => {
|
const pinToBottom = useCallback(() => {
|
||||||
scrollRef.current?.scrollToEnd({ animated: false });
|
scrollRef.current?.scrollToEnd({ animated: false })
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollRef.current?.scrollToEnd({ animated: false });
|
scrollRef.current?.scrollToEnd({ animated: false })
|
||||||
});
|
})
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rowLayoutsRef.current.clear();
|
rowLayoutsRef.current.clear()
|
||||||
scrollOffsetRef.current = 0;
|
scrollOffsetRef.current = 0
|
||||||
}, [options]);
|
}, [options])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
pinToBottom();
|
pinToBottom()
|
||||||
}, [options, pinToBottom]);
|
}, [options, pinToBottom])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const raf = requestAnimationFrame(ensureActiveItemVisible);
|
const raf = requestAnimationFrame(ensureActiveItemVisible)
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf)
|
||||||
};
|
}
|
||||||
}, [ensureActiveItemVisible, options.length]);
|
}, [ensureActiveItemVisible, options.length])
|
||||||
|
|
||||||
const handleScrollViewLayout = useCallback(
|
const handleScrollViewLayout = useCallback(
|
||||||
(event: LayoutChangeEvent) => {
|
(event: LayoutChangeEvent) => {
|
||||||
viewportHeightRef.current = event.nativeEvent.layout.height;
|
viewportHeightRef.current = event.nativeEvent.layout.height
|
||||||
ensureActiveItemVisible();
|
ensureActiveItemVisible()
|
||||||
},
|
},
|
||||||
[ensureActiveItemVisible]
|
[ensureActiveItemVisible]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleRowLayout = useCallback(
|
const handleRowLayout = useCallback(
|
||||||
(index: number, event: LayoutChangeEvent) => {
|
(index: number, event: LayoutChangeEvent) => {
|
||||||
rowLayoutsRef.current.set(index, {
|
rowLayoutsRef.current.set(index, {
|
||||||
top: event.nativeEvent.layout.y,
|
top: event.nativeEvent.layout.y,
|
||||||
height: event.nativeEvent.layout.height,
|
height: event.nativeEvent.layout.height,
|
||||||
});
|
})
|
||||||
ensureActiveItemVisible();
|
ensureActiveItemVisible()
|
||||||
},
|
},
|
||||||
[ensureActiveItemVisible]
|
[ensureActiveItemVisible]
|
||||||
);
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -114,7 +117,7 @@ export function Autocomplete({
|
|||||||
<Text style={styles.emptyText}>{loadingText}</Text>
|
<Text style={styles.emptyText}>{loadingText}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
@@ -124,7 +127,7 @@ export function Autocomplete({
|
|||||||
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
|
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
@@ -134,7 +137,7 @@ export function Autocomplete({
|
|||||||
<Text style={styles.emptyText}>{emptyText}</Text>
|
<Text style={styles.emptyText}>{emptyText}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -144,7 +147,7 @@ export function Autocomplete({
|
|||||||
onLayout={handleScrollViewLayout}
|
onLayout={handleScrollViewLayout}
|
||||||
onContentSizeChange={pinToBottom}
|
onContentSizeChange={pinToBottom}
|
||||||
onScroll={(event) => {
|
onScroll={(event) => {
|
||||||
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
scrollOffsetRef.current = event.nativeEvent.contentOffset.y
|
||||||
}}
|
}}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
@@ -152,7 +155,7 @@ export function Autocomplete({
|
|||||||
keyboardShouldPersistTaps="always"
|
keyboardShouldPersistTaps="always"
|
||||||
>
|
>
|
||||||
{options.map((option, index) => {
|
{options.map((option, index) => {
|
||||||
const isSelected = index === selectedIndex;
|
const isSelected = index === selectedIndex
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={option.id}
|
key={option.id}
|
||||||
@@ -163,12 +166,19 @@ export function Autocomplete({
|
|||||||
(hovered || pressed || isSelected) && styles.itemActive,
|
(hovered || pressed || isSelected) && styles.itemActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
{option.kind === 'directory' || option.kind === 'file' ? (
|
||||||
|
<View style={styles.itemLeading}>
|
||||||
|
{option.kind === 'directory' ? (
|
||||||
|
<Folder size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<File size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View style={styles.itemMain}>
|
<View style={styles.itemMain}>
|
||||||
<View style={styles.itemHeader}>
|
<View style={styles.itemHeader}>
|
||||||
<Text style={styles.itemLabel}>{option.label}</Text>
|
<Text style={styles.itemLabel}>{option.label}</Text>
|
||||||
{option.detail ? (
|
{option.detail ? <Text style={styles.itemDetail}>{option.detail}</Text> : null}
|
||||||
<Text style={styles.itemDetail}>{option.detail}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
{option.description ? (
|
{option.description ? (
|
||||||
<Text style={styles.itemDescription} numberOfLines={1}>
|
<Text style={styles.itemDescription} numberOfLines={1}>
|
||||||
@@ -177,11 +187,11 @@ export function Autocomplete({
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||||
@@ -190,7 +200,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
@@ -200,12 +210,18 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
itemLeading: {
|
||||||
|
width: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: theme.spacing[1],
|
||||||
|
},
|
||||||
itemActive: {
|
itemActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
@@ -214,8 +230,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
itemHeader: {
|
itemHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
itemLabel: {
|
itemLabel: {
|
||||||
@@ -240,4 +256,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
})) as any) as Record<string, any>;
|
})) as any) as Record<string, any>
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
|
export type ComboboxOptionKind = 'directory' | 'file'
|
||||||
|
|
||||||
export interface ComboboxOptionModel {
|
export interface ComboboxOptionModel {
|
||||||
id: string;
|
id: string
|
||||||
label: string;
|
label: string
|
||||||
description?: string;
|
description?: string
|
||||||
kind?: "directory";
|
kind?: ComboboxOptionKind
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildVisibleComboboxOptionsInput {
|
export interface BuildVisibleComboboxOptionsInput {
|
||||||
options: ComboboxOptionModel[];
|
options: ComboboxOptionModel[]
|
||||||
searchQuery: string;
|
searchQuery: string
|
||||||
searchable: boolean;
|
searchable: boolean
|
||||||
allowCustomValue: boolean;
|
allowCustomValue: boolean
|
||||||
customValuePrefix: string;
|
customValuePrefix: string
|
||||||
customValueDescription?: string;
|
customValueDescription?: string
|
||||||
customValueKind?: "directory";
|
customValueKind?: ComboboxOptionKind
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowCustomComboboxOption(input: {
|
export function shouldShowCustomComboboxOption(input: {
|
||||||
options: ComboboxOptionModel[];
|
options: ComboboxOptionModel[]
|
||||||
searchQuery: string;
|
searchQuery: string
|
||||||
searchable: boolean;
|
searchable: boolean
|
||||||
allowCustomValue: boolean;
|
allowCustomValue: boolean
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const sanitizedSearchValue = input.searchQuery.trim();
|
const sanitizedSearchValue = input.searchQuery.trim()
|
||||||
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
|
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return !input.options.some(
|
return !input.options.some(
|
||||||
(opt) =>
|
(opt) =>
|
||||||
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
||||||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildVisibleComboboxOptions(
|
export function buildVisibleComboboxOptions(
|
||||||
input: BuildVisibleComboboxOptionsInput
|
input: BuildVisibleComboboxOptionsInput
|
||||||
): ComboboxOptionModel[] {
|
): ComboboxOptionModel[] {
|
||||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
|
||||||
const filteredOptions = normalizedSearch
|
const filteredOptions = normalizedSearch
|
||||||
? input.options.filter(
|
? input.options.filter(
|
||||||
(opt) =>
|
(opt) =>
|
||||||
@@ -44,52 +46,50 @@ export function buildVisibleComboboxOptions(
|
|||||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
opt.id.toLowerCase().includes(normalizedSearch) ||
|
||||||
opt.description?.toLowerCase().includes(normalizedSearch)
|
opt.description?.toLowerCase().includes(normalizedSearch)
|
||||||
)
|
)
|
||||||
: input.options;
|
: input.options
|
||||||
|
|
||||||
const sanitizedSearchValue = input.searchQuery.trim();
|
const sanitizedSearchValue = input.searchQuery.trim()
|
||||||
const showCustomOption = shouldShowCustomComboboxOption({
|
const showCustomOption = shouldShowCustomComboboxOption({
|
||||||
options: input.options,
|
options: input.options,
|
||||||
searchQuery: input.searchQuery,
|
searchQuery: input.searchQuery,
|
||||||
searchable: input.searchable,
|
searchable: input.searchable,
|
||||||
allowCustomValue: input.allowCustomValue,
|
allowCustomValue: input.allowCustomValue,
|
||||||
});
|
})
|
||||||
|
|
||||||
const visibleOptions: ComboboxOptionModel[] = [];
|
const visibleOptions: ComboboxOptionModel[] = []
|
||||||
|
|
||||||
if (showCustomOption) {
|
if (showCustomOption) {
|
||||||
const trimmedPrefix = input.customValuePrefix.trim();
|
const trimmedPrefix = input.customValuePrefix.trim()
|
||||||
const customLabel =
|
const customLabel =
|
||||||
trimmedPrefix.length > 0
|
trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
|
||||||
? `${trimmedPrefix} "${sanitizedSearchValue}"`
|
|
||||||
: sanitizedSearchValue;
|
|
||||||
visibleOptions.push({
|
visibleOptions.push({
|
||||||
id: sanitizedSearchValue,
|
id: sanitizedSearchValue,
|
||||||
label: customLabel,
|
label: customLabel,
|
||||||
description: input.customValueDescription,
|
description: input.customValueDescription,
|
||||||
kind: input.customValueKind,
|
kind: input.customValueKind,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
visibleOptions.push(...filteredOptions);
|
visibleOptions.push(...filteredOptions)
|
||||||
return visibleOptions;
|
return visibleOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderVisibleComboboxOptions(
|
export function orderVisibleComboboxOptions(
|
||||||
visibleOptions: ComboboxOptionModel[],
|
visibleOptions: ComboboxOptionModel[],
|
||||||
optionsPosition: "below-search" | "above-search"
|
optionsPosition: 'below-search' | 'above-search'
|
||||||
): ComboboxOptionModel[] {
|
): ComboboxOptionModel[] {
|
||||||
if (optionsPosition !== "above-search") {
|
if (optionsPosition !== 'above-search') {
|
||||||
return visibleOptions;
|
return visibleOptions
|
||||||
}
|
}
|
||||||
return [...visibleOptions].reverse();
|
return [...visibleOptions].reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getComboboxFallbackIndex(
|
export function getComboboxFallbackIndex(
|
||||||
itemCount: number,
|
itemCount: number,
|
||||||
optionsPosition: "below-search" | "above-search"
|
optionsPosition: 'below-search' | 'above-search'
|
||||||
): number {
|
): number {
|
||||||
if (itemCount <= 0) {
|
if (itemCount <= 0) {
|
||||||
return -1;
|
return -1
|
||||||
}
|
}
|
||||||
return optionsPosition === "above-search" ? itemCount - 1 : 0;
|
return optionsPosition === 'above-search' ? itemCount - 1 : 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -10,87 +10,88 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from 'react-native'
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from '@gorhom/bottom-sheet'
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
||||||
import { Check, Folder, Search } from "lucide-react-native";
|
import { Check, File, Folder, Search } from 'lucide-react-native'
|
||||||
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native";
|
import {
|
||||||
import { getNextActiveIndex } from "./combobox-keyboard";
|
flip,
|
||||||
|
offset as floatingOffset,
|
||||||
|
shift,
|
||||||
|
size as floatingSize,
|
||||||
|
useFloating,
|
||||||
|
} from '@floating-ui/react-native'
|
||||||
|
import { getNextActiveIndex } from './combobox-keyboard'
|
||||||
import {
|
import {
|
||||||
buildVisibleComboboxOptions,
|
buildVisibleComboboxOptions,
|
||||||
getComboboxFallbackIndex,
|
getComboboxFallbackIndex,
|
||||||
orderVisibleComboboxOptions,
|
orderVisibleComboboxOptions,
|
||||||
shouldShowCustomComboboxOption,
|
shouldShowCustomComboboxOption,
|
||||||
} from "./combobox-options";
|
} from './combobox-options'
|
||||||
import type { ComboboxOptionModel } from "./combobox-options";
|
import type { ComboboxOptionModel } from './combobox-options'
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === 'web'
|
||||||
|
|
||||||
export type ComboboxOption = ComboboxOptionModel;
|
export type ComboboxOption = ComboboxOptionModel
|
||||||
|
|
||||||
export interface ComboboxProps {
|
export interface ComboboxProps {
|
||||||
options: ComboboxOption[];
|
options: ComboboxOption[]
|
||||||
value: string;
|
value: string
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void
|
||||||
onSearchQueryChange?: (query: string) => void;
|
onSearchQueryChange?: (query: string) => void
|
||||||
searchable?: boolean;
|
searchable?: boolean
|
||||||
placeholder?: string;
|
placeholder?: string
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string
|
||||||
emptyText?: string;
|
emptyText?: string
|
||||||
allowCustomValue?: boolean;
|
allowCustomValue?: boolean
|
||||||
customValuePrefix?: string;
|
customValuePrefix?: string
|
||||||
customValueDescription?: string;
|
customValueDescription?: string
|
||||||
customValueKind?: "directory";
|
customValueKind?: 'directory' | 'file'
|
||||||
optionsPosition?: "below-search" | "above-search";
|
optionsPosition?: 'below-search' | 'above-search'
|
||||||
title?: string;
|
title?: string
|
||||||
open?: boolean;
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void
|
||||||
desktopPlacement?: "top-start" | "bottom-start";
|
desktopPlacement?: 'top-start' | 'bottom-start'
|
||||||
/**
|
/**
|
||||||
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
* Prevents an initial frame at 0,0 by hiding desktop content until floating
|
||||||
* coordinates resolve. This intentionally disables fade enter/exit animation
|
* coordinates resolve. This intentionally disables fade enter/exit animation
|
||||||
* for that combobox instance to avoid animation overriding hidden opacity.
|
* for that combobox instance to avoid animation overriding hidden opacity.
|
||||||
*/
|
*/
|
||||||
desktopPreventInitialFlash?: boolean;
|
desktopPreventInitialFlash?: boolean
|
||||||
anchorRef: React.RefObject<View | null>;
|
anchorRef: React.RefObject<View | null>
|
||||||
children?: ReactNode;
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNumericStyleValue(value: unknown): number | null {
|
function toNumericStyleValue(value: unknown): number | null {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
return value;
|
return value
|
||||||
}
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === 'string') {
|
||||||
const parsed = Number.parseFloat(value);
|
const parsed = Number.parseFloat(value)
|
||||||
if (Number.isFinite(parsed)) {
|
if (Number.isFinite(parsed)) {
|
||||||
return parsed;
|
return parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
return (
|
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
|
||||||
<Animated.View
|
|
||||||
pointerEvents="none"
|
|
||||||
style={[style, styles.bottomSheetBackground]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchInputProps {
|
interface SearchInputProps {
|
||||||
placeholder: string;
|
placeholder: string
|
||||||
value: string;
|
value: string
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void
|
||||||
onSubmitEditing?: () => void;
|
onSubmitEditing?: () => void
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchInput({
|
function SearchInput({
|
||||||
@@ -100,18 +101,18 @@ function SearchInput({
|
|||||||
onSubmitEditing,
|
onSubmitEditing,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
}: SearchInputProps): ReactElement {
|
}: SearchInputProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles()
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null)
|
||||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && IS_WEB && inputRef.current) {
|
if (autoFocus && IS_WEB && inputRef.current) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus()
|
||||||
}, 50);
|
}, 50)
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [autoFocus]);
|
}, [autoFocus])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.searchInputContainer}>
|
<View style={styles.searchInputContainer}>
|
||||||
@@ -119,7 +120,7 @@ function SearchInput({
|
|||||||
<InputComponent
|
<InputComponent
|
||||||
ref={inputRef as any}
|
ref={inputRef as any}
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
|
style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -129,17 +130,17 @@ function SearchInput({
|
|||||||
onSubmitEditing={onSubmitEditing}
|
onSubmitEditing={onSubmitEditing}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComboboxItemProps {
|
export interface ComboboxItemProps {
|
||||||
label: string;
|
label: string
|
||||||
description?: string;
|
description?: string
|
||||||
kind?: "directory";
|
kind?: 'directory' | 'file'
|
||||||
selected?: boolean;
|
selected?: boolean
|
||||||
active?: boolean;
|
active?: boolean
|
||||||
onPress: () => void;
|
onPress: () => void
|
||||||
testID?: string;
|
testID?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComboboxItem({
|
export function ComboboxItem({
|
||||||
@@ -151,7 +152,7 @@ export function ComboboxItem({
|
|||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
}: ComboboxItemProps): ReactElement {
|
}: ComboboxItemProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles()
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={testID}
|
testID={testID}
|
||||||
@@ -163,15 +164,23 @@ export function ComboboxItem({
|
|||||||
active && styles.comboboxItemActive,
|
active && styles.comboboxItemActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{kind === "directory" ? (
|
{kind === 'directory' || kind === 'file' ? (
|
||||||
<View style={styles.comboboxItemLeadingSlot}>
|
<View style={styles.comboboxItemLeadingSlot}>
|
||||||
<Folder size={16} color={theme.colors.foregroundMuted} />
|
{kind === 'directory' ? (
|
||||||
|
<Folder size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<File size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.comboboxItemContent}>
|
<View style={styles.comboboxItemContent}>
|
||||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
{description ? (
|
{description ? (
|
||||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
|
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
@@ -180,11 +189,15 @@ export function ComboboxItem({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
|
||||||
return <Text testID="combobox-empty-text" style={styles.emptyText}>{children}</Text>;
|
return (
|
||||||
|
<Text testID="combobox-empty-text" style={styles.emptyText}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Combobox({
|
export function Combobox({
|
||||||
@@ -193,202 +206,196 @@ export function Combobox({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
searchable = true,
|
searchable = true,
|
||||||
placeholder = "Search...",
|
placeholder = 'Search...',
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
emptyText = "No options match your search.",
|
emptyText = 'No options match your search.',
|
||||||
allowCustomValue = false,
|
allowCustomValue = false,
|
||||||
customValuePrefix = "Use",
|
customValuePrefix = 'Use',
|
||||||
customValueDescription,
|
customValueDescription,
|
||||||
customValueKind,
|
customValueKind,
|
||||||
optionsPosition = "below-search",
|
optionsPosition = 'below-search',
|
||||||
title = "Select",
|
title = 'Select',
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
desktopPlacement = "top-start",
|
desktopPlacement = 'top-start',
|
||||||
desktopPreventInitialFlash = true,
|
desktopPreventInitialFlash = true,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
const isMobile =
|
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
|
||||||
const effectiveOptionsPosition =
|
|
||||||
isMobile ? "below-search" : optionsPosition;
|
|
||||||
const isDesktopAboveSearch =
|
const isDesktopAboveSearch =
|
||||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
!isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
const { height: windowHeight } = useWindowDimensions()
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null)
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ['60%', '90%'], [])
|
||||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
|
||||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
null
|
||||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
)
|
||||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [referenceTop, setReferenceTop] = useState<number | null>(null)
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
|
||||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number>(-1)
|
||||||
|
const desktopOptionsScrollRef = useRef<ScrollView>(null)
|
||||||
|
|
||||||
const isControlled = typeof open === "boolean";
|
const isControlled = typeof open === 'boolean'
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const isOpen = isControlled ? open : internalOpen;
|
const isOpen = isControlled ? open : internalOpen
|
||||||
|
|
||||||
const setOpen = useCallback(
|
const setOpen = useCallback(
|
||||||
(nextOpen: boolean) => {
|
(nextOpen: boolean) => {
|
||||||
if (!isControlled) {
|
if (!isControlled) {
|
||||||
setInternalOpen(nextOpen);
|
setInternalOpen(nextOpen)
|
||||||
}
|
}
|
||||||
onOpenChange?.(nextOpen);
|
onOpenChange?.(nextOpen)
|
||||||
},
|
},
|
||||||
[isControlled, onOpenChange]
|
[isControlled, onOpenChange]
|
||||||
);
|
)
|
||||||
|
|
||||||
const setSearchQueryWithCallback = useCallback(
|
const setSearchQueryWithCallback = useCallback(
|
||||||
(nextQuery: string) => {
|
(nextQuery: string) => {
|
||||||
setSearchQuery(nextQuery);
|
setSearchQuery(nextQuery)
|
||||||
onSearchQueryChange?.(nextQuery);
|
onSearchQueryChange?.(nextQuery)
|
||||||
},
|
},
|
||||||
[onSearchQueryChange]
|
[onSearchQueryChange]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false)
|
||||||
setSearchQueryWithCallback("");
|
setSearchQueryWithCallback('')
|
||||||
}, [setOpen, setSearchQueryWithCallback]);
|
}, [setOpen, setSearchQueryWithCallback])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setSearchQueryWithCallback("");
|
setSearchQueryWithCallback('')
|
||||||
}
|
}
|
||||||
}, [isOpen, setSearchQueryWithCallback]);
|
}, [isOpen, setSearchQueryWithCallback])
|
||||||
|
|
||||||
const collisionPadding = useMemo(() => {
|
const collisionPadding = useMemo(() => {
|
||||||
const basePadding = 16;
|
const basePadding = 16
|
||||||
if (Platform.OS !== "android") return basePadding;
|
if (Platform.OS !== 'android') return basePadding
|
||||||
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
const statusBarHeight = StatusBar.currentHeight ?? 0
|
||||||
return Math.max(basePadding, statusBarHeight + basePadding);
|
return Math.max(basePadding, statusBarHeight + basePadding)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const middleware = useMemo(
|
const middleware = useMemo(
|
||||||
() => [
|
() => [
|
||||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
floatingOffset(Platform.OS === 'web' ? 0 : 4),
|
||||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
|
||||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||||
floatingSize({
|
floatingSize({
|
||||||
padding: collisionPadding,
|
padding: collisionPadding,
|
||||||
apply({ availableWidth, availableHeight, rects }) {
|
apply({ availableWidth, availableHeight, rects }) {
|
||||||
setAvailableSize((prev) => {
|
setAvailableSize((prev) => {
|
||||||
const next = { width: availableWidth, height: availableHeight };
|
const next = { width: availableWidth, height: availableHeight }
|
||||||
if (!prev) return next;
|
if (!prev) return next
|
||||||
if (prev.width === next.width && prev.height === next.height) return prev;
|
if (prev.width === next.width && prev.height === next.height) return prev
|
||||||
return next;
|
return next
|
||||||
});
|
})
|
||||||
setReferenceWidth((prev) => {
|
setReferenceWidth((prev) => {
|
||||||
const next = rects.reference.width;
|
const next = rects.reference.width
|
||||||
if (prev === next) return prev;
|
if (prev === next) return prev
|
||||||
return next;
|
return next
|
||||||
});
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
[collisionPadding, isDesktopAboveSearch]
|
[collisionPadding, isDesktopAboveSearch]
|
||||||
);
|
)
|
||||||
|
|
||||||
const { refs, floatingStyles, update } = useFloating({
|
const { refs, floatingStyles, update } = useFloating({
|
||||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
|
||||||
middleware,
|
middleware,
|
||||||
sameScrollView: false,
|
sameScrollView: false,
|
||||||
elements: {
|
elements: {
|
||||||
reference: anchorRef.current ?? undefined,
|
reference: anchorRef.current ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || isMobile) {
|
if (!isOpen || isMobile) {
|
||||||
setAvailableSize(null);
|
setAvailableSize(null)
|
||||||
setReferenceWidth(null);
|
setReferenceWidth(null)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
const raf = requestAnimationFrame(() => void update());
|
const raf = requestAnimationFrame(() => void update())
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf)
|
||||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
}, [desktopPlacement, isMobile, update, isOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || isMobile) {
|
if (!isOpen || isMobile) {
|
||||||
setReferenceAtOrigin(false);
|
setReferenceAtOrigin(false)
|
||||||
setReferenceTop(null);
|
setReferenceTop(null)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const referenceEl = anchorRef.current;
|
const referenceEl = anchorRef.current
|
||||||
if (!referenceEl) {
|
if (!referenceEl) {
|
||||||
setReferenceAtOrigin(false);
|
setReferenceAtOrigin(false)
|
||||||
setReferenceTop(null);
|
setReferenceTop(null)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
referenceEl.measureInWindow((x, y) => {
|
referenceEl.measureInWindow((x, y) => {
|
||||||
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
|
||||||
setReferenceTop((prev) => (prev === y ? prev : y));
|
setReferenceTop((prev) => (prev === y ? prev : y))
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
measure();
|
measure()
|
||||||
const raf = requestAnimationFrame(measure);
|
const raf = requestAnimationFrame(measure)
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf)
|
||||||
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]);
|
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
|
||||||
|
|
||||||
const floatingTop = toNumericStyleValue(floatingStyles.top);
|
const floatingTop = toNumericStyleValue(floatingStyles.top)
|
||||||
const floatingLeft = toNumericStyleValue(floatingStyles.left);
|
const floatingLeft = toNumericStyleValue(floatingStyles.left)
|
||||||
const desktopAboveSearchBottom =
|
const desktopAboveSearchBottom =
|
||||||
isDesktopAboveSearch && referenceTop !== null
|
isDesktopAboveSearch && referenceTop !== null
|
||||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||||
: null;
|
: null
|
||||||
const hasResolvedDesktopPosition =
|
const hasResolvedDesktopPosition =
|
||||||
referenceWidth !== null &&
|
referenceWidth !== null &&
|
||||||
floatingLeft !== null &&
|
floatingLeft !== null &&
|
||||||
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
||||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
|
||||||
const shouldHideDesktopContent =
|
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
|
||||||
desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
const shouldUseDesktopFade = !desktopPreventInitialFlash
|
||||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
|
||||||
const desktopPositionStyle = isDesktopAboveSearch
|
const desktopPositionStyle = isDesktopAboveSearch
|
||||||
? {
|
? {
|
||||||
left: floatingLeft ?? 0,
|
left: floatingLeft ?? 0,
|
||||||
bottom: desktopAboveSearchBottom ?? 0,
|
bottom: desktopAboveSearchBottom ?? 0,
|
||||||
}
|
}
|
||||||
: floatingStyles;
|
: floatingStyles
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
bottomSheetRef.current?.present();
|
bottomSheetRef.current?.present()
|
||||||
} else {
|
} else {
|
||||||
bottomSheetRef.current?.dismiss();
|
bottomSheetRef.current?.dismiss()
|
||||||
}
|
}
|
||||||
}, [isOpen, isMobile]);
|
}, [isOpen, isMobile])
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
const handleSheetChange = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
handleClose();
|
handleClose()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleClose]
|
[handleClose]
|
||||||
);
|
)
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
opacity={0.45}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
);
|
)
|
||||||
|
|
||||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
|
||||||
const sanitizedSearchValue = searchQuery.trim();
|
const sanitizedSearchValue = searchQuery.trim()
|
||||||
const showCustomOption = useMemo(
|
const showCustomOption = useMemo(
|
||||||
() =>
|
() =>
|
||||||
shouldShowCustomComboboxOption({
|
shouldShowCustomComboboxOption({
|
||||||
@@ -398,7 +405,7 @@ export function Combobox({
|
|||||||
allowCustomValue,
|
allowCustomValue,
|
||||||
}),
|
}),
|
||||||
[allowCustomValue, options, searchQuery, searchable]
|
[allowCustomValue, options, searchQuery, searchable]
|
||||||
);
|
)
|
||||||
|
|
||||||
const visibleOptions = useMemo(
|
const visibleOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -420,139 +427,132 @@ export function Combobox({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
searchable,
|
searchable,
|
||||||
]
|
]
|
||||||
);
|
)
|
||||||
|
|
||||||
const orderedVisibleOptions = useMemo(
|
const orderedVisibleOptions = useMemo(
|
||||||
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
|
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
|
||||||
[effectiveOptionsPosition, visibleOptions]
|
[effectiveOptionsPosition, visibleOptions]
|
||||||
);
|
)
|
||||||
|
|
||||||
const pinDesktopOptionsToBottom = useCallback(() => {
|
const pinDesktopOptionsToBottom = useCallback(() => {
|
||||||
if (isMobile || effectiveOptionsPosition !== "above-search") {
|
if (isMobile || effectiveOptionsPosition !== 'above-search') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
|
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false });
|
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
|
||||||
});
|
})
|
||||||
}, [effectiveOptionsPosition, isMobile]);
|
}, [effectiveOptionsPosition, isMobile])
|
||||||
|
|
||||||
const handleDesktopOptionsContentSizeChange = useCallback(() => {
|
const handleDesktopOptionsContentSizeChange = useCallback(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
pinDesktopOptionsToBottom();
|
pinDesktopOptionsToBottom()
|
||||||
}, [isOpen, pinDesktopOptionsToBottom]);
|
}, [isOpen, pinDesktopOptionsToBottom])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
pinDesktopOptionsToBottom();
|
pinDesktopOptionsToBottom()
|
||||||
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]);
|
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isOpen || isMobile) {
|
if (!isOpen || isMobile) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
void update();
|
void update()
|
||||||
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]);
|
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return
|
||||||
if (!IS_WEB && isMobile) return;
|
if (!IS_WEB && isMobile) return
|
||||||
|
|
||||||
if (orderedVisibleOptions.length === 0) {
|
if (orderedVisibleOptions.length === 0) {
|
||||||
setActiveIndex(-1);
|
setActiveIndex(-1)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackIndex = getComboboxFallbackIndex(
|
const fallbackIndex = getComboboxFallbackIndex(
|
||||||
orderedVisibleOptions.length,
|
orderedVisibleOptions.length,
|
||||||
effectiveOptionsPosition
|
effectiveOptionsPosition
|
||||||
);
|
)
|
||||||
|
|
||||||
if (normalizedSearch) {
|
if (normalizedSearch) {
|
||||||
setActiveIndex(fallbackIndex);
|
setActiveIndex(fallbackIndex)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value);
|
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
|
||||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex);
|
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
|
||||||
}, [
|
}, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
|
||||||
effectiveOptionsPosition,
|
|
||||||
isMobile,
|
|
||||||
isOpen,
|
|
||||||
normalizedSearch,
|
|
||||||
value,
|
|
||||||
orderedVisibleOptions,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
onSelect(id);
|
onSelect(id)
|
||||||
handleClose();
|
handleClose()
|
||||||
},
|
},
|
||||||
[handleClose, onSelect]
|
[handleClose, onSelect]
|
||||||
);
|
)
|
||||||
|
|
||||||
const handleSubmitSearch = useCallback(() => {
|
const handleSubmitSearch = useCallback(() => {
|
||||||
if (showCustomOption) {
|
if (showCustomOption) {
|
||||||
handleSelect(sanitizedSearchValue);
|
handleSelect(sanitizedSearchValue)
|
||||||
}
|
}
|
||||||
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
}, [handleSelect, sanitizedSearchValue, showCustomOption])
|
||||||
|
|
||||||
const handleDesktopKey = useCallback(
|
const handleDesktopKey = useCallback(
|
||||||
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => {
|
(key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return
|
||||||
if (!IS_WEB && isMobile) return;
|
if (!IS_WEB && isMobile) return
|
||||||
|
|
||||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
||||||
event?.preventDefault();
|
event?.preventDefault()
|
||||||
setActiveIndex((currentIndex) =>
|
setActiveIndex((currentIndex) =>
|
||||||
getNextActiveIndex({
|
getNextActiveIndex({
|
||||||
currentIndex,
|
currentIndex,
|
||||||
itemCount: orderedVisibleOptions.length,
|
itemCount: orderedVisibleOptions.length,
|
||||||
key,
|
key,
|
||||||
})
|
})
|
||||||
);
|
)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Enter") {
|
if (key === 'Enter') {
|
||||||
if (orderedVisibleOptions.length === 0) return;
|
if (orderedVisibleOptions.length === 0) return
|
||||||
event?.preventDefault();
|
event?.preventDefault()
|
||||||
const index =
|
const index =
|
||||||
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0;
|
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
|
||||||
handleSelect(orderedVisibleOptions[index]!.id);
|
handleSelect(orderedVisibleOptions[index]!.id)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === "Escape") {
|
if (key === 'Escape') {
|
||||||
event?.preventDefault();
|
event?.preventDefault()
|
||||||
handleClose();
|
handleClose()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
|
||||||
);
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !isOpen) return;
|
if (!IS_WEB || !isOpen) return
|
||||||
|
|
||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
const key = event.key;
|
const key = event.key
|
||||||
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
|
if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
handleDesktopKey(key, event);
|
handleDesktopKey(key, event)
|
||||||
};
|
}
|
||||||
|
|
||||||
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
|
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
|
||||||
window.addEventListener("keydown", handler, true);
|
window.addEventListener('keydown', handler, true)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handler, true);
|
window.removeEventListener('keydown', handler, true)
|
||||||
};
|
}
|
||||||
}, [handleDesktopKey, isOpen]);
|
}, [handleDesktopKey, isOpen])
|
||||||
|
|
||||||
const searchInput = (
|
const searchInput = (
|
||||||
<SearchInput
|
<SearchInput
|
||||||
@@ -562,7 +562,7 @@ export function Combobox({
|
|||||||
onSubmitEditing={handleSubmitSearch}
|
onSubmitEditing={handleSubmitSearch}
|
||||||
autoFocus={!isMobile}
|
autoFocus={!isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
|
|
||||||
const optionsList = (
|
const optionsList = (
|
||||||
<>
|
<>
|
||||||
@@ -582,17 +582,17 @@ export function Combobox({
|
|||||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
|
|
||||||
const defaultContent = (
|
const defaultContent = (
|
||||||
<>
|
<>
|
||||||
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
{effectiveOptionsPosition === 'above-search' ? optionsList : null}
|
||||||
{searchable ? searchInput : null}
|
{searchable ? searchInput : null}
|
||||||
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
{effectiveOptionsPosition === 'below-search' ? optionsList : null}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
|
|
||||||
const content = children ?? defaultContent;
|
const content = children ?? defaultContent
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -620,18 +620,13 @@ export function Combobox({
|
|||||||
{content}
|
{content}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return <></>;
|
if (!isOpen) return <></>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
|
||||||
transparent
|
|
||||||
animationType="none"
|
|
||||||
visible={isOpen}
|
|
||||||
onRequestClose={handleClose}
|
|
||||||
>
|
|
||||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
|
||||||
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -641,13 +636,15 @@ export function Combobox({
|
|||||||
style={[
|
style={[
|
||||||
styles.desktopContainer,
|
styles.desktopContainer,
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
minWidth: referenceWidth ?? 200,
|
minWidth: referenceWidth ?? 200,
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
},
|
},
|
||||||
desktopPositionStyle,
|
desktopPositionStyle,
|
||||||
shouldHideDesktopContent ? { opacity: 0 } : null,
|
shouldHideDesktopContent ? { opacity: 0 } : null,
|
||||||
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
typeof availableSize?.height === 'number'
|
||||||
|
? { maxHeight: Math.min(availableSize.height, 400) }
|
||||||
|
: null,
|
||||||
]}
|
]}
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
@@ -664,7 +661,7 @@ export function Combobox({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{effectiveOptionsPosition === "above-search" ? (
|
{effectiveOptionsPosition === 'above-search' ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={desktopOptionsScrollRef}
|
ref={desktopOptionsScrollRef}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
@@ -680,7 +677,7 @@ export function Combobox({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
) : null}
|
||||||
{searchable ? searchInput : null}
|
{searchable ? searchInput : null}
|
||||||
{effectiveOptionsPosition === "below-search" ? (
|
{effectiveOptionsPosition === 'below-search' ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
contentContainerStyle={styles.desktopScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -695,13 +692,13 @@ export function Combobox({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
searchInputContainer: {
|
searchInputContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
@@ -719,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
comboboxItem: {
|
comboboxItem: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
@@ -744,9 +741,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
comboboxItemTrailingSlot: {
|
comboboxItemTrailingSlot: {
|
||||||
width: 16,
|
width: 16,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
marginLeft: "auto",
|
marginLeft: 'auto',
|
||||||
},
|
},
|
||||||
comboboxItemContent: {
|
comboboxItemContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -754,8 +751,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
comboboxItemLeadingSlot: {
|
comboboxItemLeadingSlot: {
|
||||||
width: 16,
|
width: 16,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
comboboxItemLabel: {
|
comboboxItemLabel: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
@@ -774,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
bottomSheetBackground: {
|
bottomSheetBackground: {
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
borderTopLeftRadius: theme.borderRadius['2xl'],
|
||||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
borderTopRightRadius: theme.borderRadius['2xl'],
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
bottomSheetHandle: {
|
||||||
backgroundColor: theme.colors.palette.zinc[600],
|
backgroundColor: theme.colors.palette.zinc[600],
|
||||||
@@ -788,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
textAlign: "left",
|
textAlign: 'left',
|
||||||
},
|
},
|
||||||
comboboxScrollContent: {
|
comboboxScrollContent: {
|
||||||
paddingBottom: theme.spacing[8],
|
paddingBottom: theme.spacing[8],
|
||||||
@@ -799,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
desktopBackdrop: {
|
desktopBackdrop: {
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
@@ -810,13 +807,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
shadowColor: "#000",
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.2,
|
shadowOpacity: 0.2,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
maxHeight: 400,
|
maxHeight: 400,
|
||||||
overflow: "hidden",
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
@@ -827,6 +824,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
desktopScrollContentAboveSearch: {
|
desktopScrollContentAboveSearch: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
justifyContent: "flex-end",
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
|||||||
@@ -1,125 +1,290 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from 'react'
|
||||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
|
import type { AutocompleteOption } from '@/components/ui/autocomplete'
|
||||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
|
||||||
import { useAutocomplete } from "./use-autocomplete";
|
import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
|
||||||
|
import { useAutocomplete } from './use-autocomplete'
|
||||||
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
|
import {
|
||||||
|
applyFileMentionReplacement,
|
||||||
|
findActiveFileMention,
|
||||||
|
type FileMentionRange,
|
||||||
|
} from '@/utils/file-mention-autocomplete'
|
||||||
|
|
||||||
interface UseAgentAutocompleteInput {
|
interface UseAgentAutocompleteInput {
|
||||||
userInput: string;
|
userInput: string
|
||||||
setUserInput: (nextValue: string) => void;
|
cursorIndex: number
|
||||||
serverId: string;
|
setUserInput: (nextValue: string) => void
|
||||||
agentId: string;
|
serverId: string
|
||||||
draftConfig?: DraftCommandConfig;
|
agentId: string
|
||||||
onAutocompleteApplied?: () => void;
|
draftConfig?: DraftCommandConfig
|
||||||
|
onAutocompleteApplied?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentAutocompleteOption =
|
||||||
|
| (AutocompleteOption & { type: 'command' })
|
||||||
|
| (AutocompleteOption & {
|
||||||
|
type: 'workspace_entry'
|
||||||
|
entryPath: string
|
||||||
|
mention: FileMentionRange
|
||||||
|
})
|
||||||
|
|
||||||
interface AgentAutocompleteResult {
|
interface AgentAutocompleteResult {
|
||||||
isVisible: boolean;
|
isVisible: boolean
|
||||||
options: AutocompleteOption[];
|
options: AutocompleteOption[]
|
||||||
selectedIndex: number;
|
selectedIndex: number
|
||||||
isLoading: boolean;
|
isLoading: boolean
|
||||||
errorMessage?: string;
|
errorMessage?: string
|
||||||
onSelectOption: (option: AutocompleteOption) => void;
|
loadingText: string
|
||||||
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
|
emptyText: string
|
||||||
|
onSelectOption: (option: AutocompleteOption) => void
|
||||||
|
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectorySuggestionEntry {
|
||||||
|
path: string
|
||||||
|
kind: 'file' | 'directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDraftCommandConfig(
|
function normalizeDraftCommandConfig(
|
||||||
draftConfig?: DraftCommandConfig
|
draftConfig?: DraftCommandConfig
|
||||||
): DraftCommandConfig | undefined {
|
): DraftCommandConfig | undefined {
|
||||||
if (!draftConfig) {
|
if (!draftConfig) {
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = draftConfig.cwd.trim();
|
const cwd = draftConfig.cwd.trim()
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
return undefined;
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
const modeId = draftConfig.modeId?.trim() ?? ''
|
||||||
const model = draftConfig.model?.trim() ?? "";
|
const model = draftConfig.model?.trim() ?? ''
|
||||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ''
|
||||||
return {
|
return {
|
||||||
provider: draftConfig.provider,
|
provider: draftConfig.provider,
|
||||||
cwd,
|
cwd,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentAutocomplete(
|
function mapDirectorySuggestionsToEntries(payload: {
|
||||||
input: UseAgentAutocompleteInput
|
entries?: Array<{ path: string; kind: string }>
|
||||||
): AgentAutocompleteResult {
|
directories?: string[]
|
||||||
|
}): DirectorySuggestionEntry[] {
|
||||||
|
if (Array.isArray(payload.entries) && payload.entries.length > 0) {
|
||||||
|
return payload.entries.flatMap((entry) => {
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
typeof entry.path !== 'string' ||
|
||||||
|
(entry.kind !== 'file' && entry.kind !== 'directory')
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [{ path: entry.path, kind: entry.kind }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (payload.directories ?? []).map((path) => ({
|
||||||
|
path,
|
||||||
|
kind: 'directory' as const,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
|
||||||
const {
|
const {
|
||||||
userInput,
|
userInput,
|
||||||
|
cursorIndex,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
draftConfig,
|
draftConfig,
|
||||||
onAutocompleteApplied,
|
onAutocompleteApplied,
|
||||||
} = input;
|
} = input
|
||||||
|
|
||||||
const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ')
|
||||||
const filterQuery = showAutocomplete ? userInput.slice(1) : "";
|
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ''
|
||||||
|
|
||||||
|
const activeFileMention = useMemo(
|
||||||
|
() =>
|
||||||
|
findActiveFileMention({
|
||||||
|
text: userInput,
|
||||||
|
cursorIndex,
|
||||||
|
}),
|
||||||
|
[cursorIndex, userInput]
|
||||||
|
)
|
||||||
|
const showFileAutocomplete = activeFileMention !== null
|
||||||
|
const fileFilterQuery = activeFileMention?.query ?? ''
|
||||||
|
|
||||||
const normalizedDraftConfig = useMemo(
|
const normalizedDraftConfig = useMemo(
|
||||||
() => normalizeDraftCommandConfig(draftConfig),
|
() => normalizeDraftCommandConfig(draftConfig),
|
||||||
[draftConfig]
|
[draftConfig]
|
||||||
);
|
)
|
||||||
|
|
||||||
const isRealAgent = Boolean(agentId) && !agentId.startsWith("__");
|
const isRealAgent = Boolean(agentId) && !agentId.startsWith('__')
|
||||||
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig;
|
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig
|
||||||
const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig);
|
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!queryDraftConfig)
|
||||||
const isVisible = showAutocomplete && canLoadOptions;
|
|
||||||
|
|
||||||
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
|
const agentCwd = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? ''
|
||||||
|
)
|
||||||
|
const autocompleteCwd = useMemo(() => {
|
||||||
|
if (isRealAgent) {
|
||||||
|
return agentCwd.trim()
|
||||||
|
}
|
||||||
|
return queryDraftConfig?.cwd ?? ''
|
||||||
|
}, [agentCwd, isRealAgent, queryDraftConfig])
|
||||||
|
|
||||||
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
|
||||||
|
const isConnected = useSessionStore(
|
||||||
|
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||||
|
)
|
||||||
|
|
||||||
|
const mode: 'command' | 'file' | null = showFileAutocomplete
|
||||||
|
? 'file'
|
||||||
|
: showCommandAutocomplete
|
||||||
|
? 'command'
|
||||||
|
: null
|
||||||
|
const isVisible =
|
||||||
|
mode === 'command'
|
||||||
|
? canLoadCommands
|
||||||
|
: mode === 'file'
|
||||||
|
? Boolean(serverId) && autocompleteCwd.length > 0
|
||||||
|
: false
|
||||||
|
|
||||||
|
const {
|
||||||
|
commands,
|
||||||
|
isLoading: isCommandsLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: canLoadOptions,
|
enabled: mode === 'command' && canLoadCommands,
|
||||||
draftConfig: queryDraftConfig,
|
draftConfig: queryDraftConfig,
|
||||||
});
|
})
|
||||||
|
|
||||||
const options = useMemo<AutocompleteOption[]>(() => {
|
const fileSuggestionsQuery = useQuery({
|
||||||
|
queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true],
|
||||||
|
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Daemon client unavailable')
|
||||||
|
}
|
||||||
|
const response = await client.getDirectorySuggestions({
|
||||||
|
cwd: autocompleteCwd,
|
||||||
|
query: fileFilterQuery,
|
||||||
|
limit: 50,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
|
})
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(response.error)
|
||||||
|
}
|
||||||
|
return mapDirectorySuggestionsToEntries(response)
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
mode === 'file' &&
|
||||||
|
Boolean(serverId) &&
|
||||||
|
autocompleteCwd.length > 0 &&
|
||||||
|
Boolean(client) &&
|
||||||
|
isConnected,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = useMemo<AgentAutocompleteOption[]>(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
return [];
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterLower = filterQuery.toLowerCase();
|
if (mode === 'command') {
|
||||||
const matches = commands.filter((cmd) =>
|
const filterLower = commandFilterQuery.toLowerCase()
|
||||||
cmd.name.toLowerCase().includes(filterLower)
|
const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower))
|
||||||
);
|
const orderedMatches = orderAutocompleteOptions(matches)
|
||||||
const orderedMatches = orderAutocompleteOptions(matches);
|
return orderedMatches.map((cmd) => ({
|
||||||
return orderedMatches.map((cmd) => ({
|
type: 'command' as const,
|
||||||
id: cmd.name,
|
id: cmd.name,
|
||||||
label: `/${cmd.name}`,
|
label: `/${cmd.name}`,
|
||||||
detail: cmd.argumentHint || undefined,
|
detail: cmd.argumentHint || undefined,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
}));
|
kind: 'command',
|
||||||
}, [commands, filterQuery, isVisible]);
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'file' && activeFileMention) {
|
||||||
|
return (fileSuggestionsQuery.data ?? []).map((entry) => ({
|
||||||
|
type: 'workspace_entry' as const,
|
||||||
|
id: `${entry.kind}:${entry.path}`,
|
||||||
|
label: entry.path,
|
||||||
|
detail: entry.kind === 'directory' ? 'Directory' : 'File',
|
||||||
|
kind: entry.kind,
|
||||||
|
entryPath: entry.path,
|
||||||
|
mention: activeFileMention,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode])
|
||||||
|
|
||||||
const onSelectOption = useCallback(
|
const onSelectOption = useCallback(
|
||||||
(option: AutocompleteOption) => {
|
(option: AutocompleteOption) => {
|
||||||
setUserInput(`/${option.id} `);
|
const selected = option as AgentAutocompleteOption
|
||||||
onAutocompleteApplied?.();
|
if (selected.type === 'command') {
|
||||||
|
setUserInput(`/${selected.id} `)
|
||||||
|
onAutocompleteApplied?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextInput = applyFileMentionReplacement({
|
||||||
|
text: userInput,
|
||||||
|
mention: selected.mention,
|
||||||
|
relativePath: selected.entryPath,
|
||||||
|
})
|
||||||
|
setUserInput(nextInput)
|
||||||
|
onAutocompleteApplied?.()
|
||||||
},
|
},
|
||||||
[onAutocompleteApplied, setUserInput]
|
[onAutocompleteApplied, setUserInput, userInput]
|
||||||
);
|
)
|
||||||
|
|
||||||
const { selectedIndex, onKeyPress } = useAutocomplete({
|
const { selectedIndex, onKeyPress } = useAutocomplete({
|
||||||
isVisible,
|
isVisible,
|
||||||
options,
|
options,
|
||||||
query: filterQuery,
|
query: mode === 'command' ? commandFilterQuery : fileFilterQuery,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
onEscape: () => setUserInput(""),
|
onEscape: mode === 'command' ? () => setUserInput('') : undefined,
|
||||||
});
|
})
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
mode === 'command'
|
||||||
|
? isCommandsLoading
|
||||||
|
: mode === 'file'
|
||||||
|
? fileSuggestionsQuery.isPending || fileSuggestionsQuery.isLoading
|
||||||
|
: false
|
||||||
|
const errorMessage =
|
||||||
|
mode === 'command'
|
||||||
|
? isError
|
||||||
|
? (error?.message ?? 'Failed to load')
|
||||||
|
: undefined
|
||||||
|
: mode === 'file'
|
||||||
|
? fileSuggestionsQuery.error instanceof Error
|
||||||
|
? fileSuggestionsQuery.error.message
|
||||||
|
: undefined
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...'
|
||||||
|
const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isVisible,
|
isVisible,
|
||||||
options,
|
options,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
isLoading,
|
isLoading,
|
||||||
errorMessage: isError ? (error?.message ?? "Failed to load") : undefined,
|
errorMessage,
|
||||||
|
loadingText,
|
||||||
|
emptyText,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
onKeyPress,
|
onKeyPress,
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
|
||||||
|
|
||||||
|
describe('findActiveFileMention', () => {
|
||||||
|
it('detects mentions at the start of input', () => {
|
||||||
|
const mention = findActiveFileMention({
|
||||||
|
text: '@src/components',
|
||||||
|
cursorIndex: '@src/components'.length,
|
||||||
|
})
|
||||||
|
expect(mention).toEqual({
|
||||||
|
start: 0,
|
||||||
|
end: '@src/components'.length,
|
||||||
|
query: 'src/components',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects mentions in the middle of input using cursor position', () => {
|
||||||
|
const text = 'read "@src/com" before merging'
|
||||||
|
const cursorIndex = text.indexOf('"') + 9
|
||||||
|
const mention = findActiveFileMention({
|
||||||
|
text,
|
||||||
|
cursorIndex,
|
||||||
|
})
|
||||||
|
expect(mention).toEqual({
|
||||||
|
start: text.indexOf('@'),
|
||||||
|
end: cursorIndex,
|
||||||
|
query: 'src/com',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when cursor is outside the mention token', () => {
|
||||||
|
const text = 'please review @src/components now'
|
||||||
|
const mention = findActiveFileMention({
|
||||||
|
text,
|
||||||
|
cursorIndex: text.length,
|
||||||
|
})
|
||||||
|
expect(mention).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applyFileMentionReplacement', () => {
|
||||||
|
it('replaces only the active @query segment with a quoted relative path', () => {
|
||||||
|
const text = 'open @src/com next'
|
||||||
|
const next = applyFileMentionReplacement({
|
||||||
|
text,
|
||||||
|
mention: { start: 5, end: 13, query: 'src/com' },
|
||||||
|
relativePath: 'src/components/chat.tsx',
|
||||||
|
})
|
||||||
|
expect(next).toBe('open "src/components/chat.tsx" next')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes double quotes in replacement path', () => {
|
||||||
|
const text = '@foo'
|
||||||
|
const next = applyFileMentionReplacement({
|
||||||
|
text,
|
||||||
|
mention: { start: 0, end: 4, query: 'foo' },
|
||||||
|
relativePath: 'src/"quoted".ts',
|
||||||
|
})
|
||||||
|
expect(next).toBe('"src/\\"quoted\\".ts"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export interface FileMentionRange {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
query: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FindActiveFileMentionInput {
|
||||||
|
text: string
|
||||||
|
cursorIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyFileMentionReplacementInput {
|
||||||
|
text: string
|
||||||
|
mention: FileMentionRange
|
||||||
|
relativePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
|
||||||
|
|
||||||
|
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
|
||||||
|
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
|
||||||
|
const beforeCursor = input.text.slice(0, clampedCursor)
|
||||||
|
|
||||||
|
for (
|
||||||
|
let atIndex = beforeCursor.lastIndexOf('@');
|
||||||
|
atIndex >= 0;
|
||||||
|
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
|
||||||
|
) {
|
||||||
|
const query = beforeCursor.slice(atIndex + 1)
|
||||||
|
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: atIndex,
|
||||||
|
end: clampedCursor,
|
||||||
|
query,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
|
||||||
|
const safePath = input.relativePath.replace(/"/g, '\\"')
|
||||||
|
const before = input.text.slice(0, input.mention.start)
|
||||||
|
const after = input.text.slice(input.mention.end)
|
||||||
|
return `${before}"${safePath}"${after}`
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1083
-1159
File diff suppressed because it is too large
Load Diff
+2486
-2707
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
@@ -6,172 +6,176 @@ import {
|
|||||||
SessionInboundMessageSchema,
|
SessionInboundMessageSchema,
|
||||||
SessionOutboundMessageSchema,
|
SessionOutboundMessageSchema,
|
||||||
WSOutboundMessageSchema,
|
WSOutboundMessageSchema,
|
||||||
} from "./messages.js";
|
} from './messages.js'
|
||||||
|
|
||||||
describe("shared messages stream parsing", () => {
|
describe('shared messages stream parsing', () => {
|
||||||
it("parses representative fetch_agent_timeline_response payload", () => {
|
it('parses representative fetch_agent_timeline_response payload', () => {
|
||||||
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
|
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
|
||||||
type: "fetch_agent_timeline_response",
|
type: 'fetch_agent_timeline_response',
|
||||||
payload: {
|
payload: {
|
||||||
requestId: "req-1",
|
requestId: 'req-1',
|
||||||
agentId: "agent_live",
|
agentId: 'agent_live',
|
||||||
direction: "tail",
|
direction: 'tail',
|
||||||
projection: "projected",
|
projection: 'projected',
|
||||||
epoch: "epoch-1",
|
epoch: 'epoch-1',
|
||||||
reset: false,
|
reset: false,
|
||||||
staleCursor: false,
|
staleCursor: false,
|
||||||
gap: false,
|
gap: false,
|
||||||
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
||||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
startCursor: { epoch: 'epoch-1', seq: 1 },
|
||||||
endCursor: { epoch: "epoch-1", seq: 2 },
|
endCursor: { epoch: 'epoch-1', seq: 2 },
|
||||||
hasOlder: false,
|
hasOlder: false,
|
||||||
hasNewer: false,
|
hasNewer: false,
|
||||||
entries: [
|
entries: [
|
||||||
{
|
{
|
||||||
provider: "codex",
|
provider: 'codex',
|
||||||
item: { type: "assistant_message", text: "hello" },
|
item: { type: 'assistant_message', text: 'hello' },
|
||||||
timestamp: "2026-02-08T20:10:00.000Z",
|
timestamp: '2026-02-08T20:10:00.000Z',
|
||||||
seqStart: 1,
|
seqStart: 1,
|
||||||
seqEnd: 2,
|
seqEnd: 2,
|
||||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
||||||
collapsed: ["assistant_merge"],
|
collapsed: ['assistant_merge'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(parsed.payload.entries).toHaveLength(1);
|
expect(parsed.payload.entries).toHaveLength(1)
|
||||||
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
|
expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
|
||||||
});
|
})
|
||||||
|
|
||||||
it("parses representative agent_stream tool_call event", () => {
|
it('parses representative agent_stream tool_call event', () => {
|
||||||
const parsed = AgentStreamMessageSchema.parse({
|
const parsed = AgentStreamMessageSchema.parse({
|
||||||
type: "agent_stream",
|
type: 'agent_stream',
|
||||||
payload: {
|
payload: {
|
||||||
agentId: "agent_live",
|
agentId: 'agent_live',
|
||||||
timestamp: "2026-02-08T20:10:00.000Z",
|
timestamp: '2026-02-08T20:10:00.000Z',
|
||||||
event: {
|
event: {
|
||||||
type: "timeline",
|
type: 'timeline',
|
||||||
provider: "claude",
|
provider: 'claude',
|
||||||
item: {
|
item: {
|
||||||
type: "tool_call",
|
type: 'tool_call',
|
||||||
callId: "call_live",
|
callId: 'call_live',
|
||||||
name: "shell",
|
name: 'shell',
|
||||||
status: "running",
|
status: 'running',
|
||||||
detail: {
|
detail: {
|
||||||
type: "shell",
|
type: 'shell',
|
||||||
command: "ls",
|
command: 'ls',
|
||||||
},
|
},
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(parsed.payload.event.type).toBe("timeline");
|
expect(parsed.payload.event.type).toBe('timeline')
|
||||||
if (parsed.payload.event.type === "timeline") {
|
if (parsed.payload.event.type === 'timeline') {
|
||||||
expect(parsed.payload.event.item.type).toBe("tool_call");
|
expect(parsed.payload.event.item.type).toBe('tool_call')
|
||||||
if (parsed.payload.event.item.type === "tool_call") {
|
if (parsed.payload.event.item.type === 'tool_call') {
|
||||||
expect(parsed.payload.event.item.status).toBe("running");
|
expect(parsed.payload.event.item.status).toBe('running')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
it("rejects removed initialize_agent_request inbound payload", () => {
|
it('rejects removed initialize_agent_request inbound payload', () => {
|
||||||
const parsed = SessionInboundMessageSchema.safeParse({
|
const parsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "initialize_agent_request",
|
type: 'initialize_agent_request',
|
||||||
agentId: "agent-legacy",
|
agentId: 'agent-legacy',
|
||||||
requestId: "req-legacy-1",
|
requestId: 'req-legacy-1',
|
||||||
});
|
})
|
||||||
expect(parsed.success).toBe(false);
|
expect(parsed.success).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("rejects removed initialize_agent_request outbound payload", () => {
|
it('rejects removed initialize_agent_request outbound payload', () => {
|
||||||
const parsed = SessionOutboundMessageSchema.safeParse({
|
const parsed = SessionOutboundMessageSchema.safeParse({
|
||||||
type: "initialize_agent_request",
|
type: 'initialize_agent_request',
|
||||||
payload: {
|
payload: {
|
||||||
requestId: "req-legacy-1",
|
requestId: 'req-legacy-1',
|
||||||
agentId: "agent-legacy",
|
agentId: 'agent-legacy',
|
||||||
agentStatus: "running",
|
agentStatus: 'running',
|
||||||
timelineSize: 12,
|
timelineSize: 12,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
expect(parsed.success).toBe(false);
|
expect(parsed.success).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("parses directory suggestions request and response payloads", () => {
|
it('parses directory suggestions request and response payloads', () => {
|
||||||
const requestParsed = SessionInboundMessageSchema.safeParse({
|
const requestParsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "directory_suggestions_request",
|
type: 'directory_suggestions_request',
|
||||||
query: "proj",
|
query: 'proj',
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
requestId: "req-dir-1",
|
requestId: 'req-dir-1',
|
||||||
});
|
})
|
||||||
expect(requestParsed.success).toBe(true);
|
expect(requestParsed.success).toBe(true)
|
||||||
|
|
||||||
const responseParsed = SessionOutboundMessageSchema.safeParse({
|
const responseParsed = SessionOutboundMessageSchema.safeParse({
|
||||||
type: "directory_suggestions_response",
|
type: 'directory_suggestions_response',
|
||||||
payload: {
|
payload: {
|
||||||
directories: ["/Users/test/projects/paseo"],
|
directories: ['/Users/test/projects/paseo'],
|
||||||
|
entries: [{ path: '/Users/test/projects/paseo', kind: 'directory' }],
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "req-dir-1",
|
requestId: 'req-dir-1',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
expect(responseParsed.success).toBe(true);
|
expect(responseParsed.success).toBe(true)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => {
|
it('rejects websocket envelope for removed agent_stream_snapshot message type', () => {
|
||||||
const fixture = {
|
const fixture = {
|
||||||
type: "agent_stream_snapshot",
|
type: 'agent_stream_snapshot',
|
||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-legacy",
|
agentId: 'agent-legacy',
|
||||||
events: [],
|
events: [],
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
const wrapped = WSOutboundMessageSchema.safeParse({
|
const wrapped = WSOutboundMessageSchema.safeParse({
|
||||||
type: "session",
|
type: 'session',
|
||||||
message: fixture,
|
message: fixture,
|
||||||
});
|
})
|
||||||
expect(wrapped.success).toBe(false);
|
expect(wrapped.success).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("rejects removed legacy git diff request messages", () => {
|
it('rejects removed legacy git diff request messages', () => {
|
||||||
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
|
const gitDiffParsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "git_diff_request",
|
type: 'git_diff_request',
|
||||||
agentId: "agent-1",
|
agentId: 'agent-1',
|
||||||
requestId: "req-1",
|
requestId: 'req-1',
|
||||||
});
|
})
|
||||||
expect(gitDiffParsed.success).toBe(false);
|
expect(gitDiffParsed.success).toBe(false)
|
||||||
|
|
||||||
const highlightedParsed = SessionInboundMessageSchema.safeParse({
|
const highlightedParsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "highlighted_diff_request",
|
type: 'highlighted_diff_request',
|
||||||
agentId: "agent-1",
|
agentId: 'agent-1',
|
||||||
requestId: "req-2",
|
requestId: 'req-2',
|
||||||
});
|
})
|
||||||
expect(highlightedParsed.success).toBe(false);
|
expect(highlightedParsed.success).toBe(false)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("rejects removed legacy git diff response messages", () => {
|
it('rejects removed legacy git diff response messages', () => {
|
||||||
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
|
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
|
||||||
type: "git_diff_response",
|
type: 'git_diff_response',
|
||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: 'agent-1',
|
||||||
diff: "",
|
diff: '',
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "req-1",
|
requestId: 'req-1',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
expect(gitDiffParsed.success).toBe(false);
|
expect(gitDiffParsed.success).toBe(false)
|
||||||
|
|
||||||
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
|
const highlightedParsed = SessionOutboundMessageSchema.safeParse({
|
||||||
type: "highlighted_diff_response",
|
type: 'highlighted_diff_response',
|
||||||
payload: {
|
payload: {
|
||||||
agentId: "agent-1",
|
agentId: 'agent-1',
|
||||||
files: [],
|
files: [],
|
||||||
error: null,
|
error: null,
|
||||||
requestId: "req-2",
|
requestId: 'req-2',
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
expect(highlightedParsed.success).toBe(false);
|
expect(highlightedParsed.success).toBe(false)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +1,237 @@
|
|||||||
import {
|
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||||
mkdtempSync,
|
import { tmpdir } from 'node:os'
|
||||||
mkdirSync,
|
import path from 'node:path'
|
||||||
realpathSync,
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
rmSync,
|
import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import { searchHomeDirectories } from "./directory-suggestions.js";
|
|
||||||
|
|
||||||
describe("searchHomeDirectories", () => {
|
describe('searchHomeDirectories', () => {
|
||||||
let tempRoot: string;
|
let tempRoot: string
|
||||||
let homeDir: string;
|
let homeDir: string
|
||||||
let outsideDir: string;
|
let outsideDir: string
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
|
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
|
||||||
homeDir = path.join(tempRoot, "home");
|
homeDir = path.join(tempRoot, 'home')
|
||||||
outsideDir = path.join(tempRoot, "outside");
|
outsideDir = path.join(tempRoot, 'outside')
|
||||||
|
|
||||||
mkdirSync(homeDir, { recursive: true });
|
mkdirSync(homeDir, { recursive: true })
|
||||||
mkdirSync(outsideDir, { recursive: true });
|
mkdirSync(outsideDir, { recursive: true })
|
||||||
|
|
||||||
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
|
mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
|
||||||
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
|
mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
|
||||||
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
|
mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
|
||||||
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
|
mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
|
||||||
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
|
writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
|
||||||
|
|
||||||
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
|
mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
|
||||||
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
|
symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
|
||||||
});
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tempRoot, { recursive: true, force: true });
|
rmSync(tempRoot, { recursive: true, force: true })
|
||||||
});
|
})
|
||||||
|
|
||||||
it("returns an empty list for blank queries", async () => {
|
it('returns an empty list for blank queries', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
searchHomeDirectories({
|
searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: " ",
|
query: ' ',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
).resolves.toEqual([]);
|
).resolves.toEqual([])
|
||||||
});
|
})
|
||||||
|
|
||||||
it("returns only existing directories", async () => {
|
it('returns only existing directories', async () => {
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "proj",
|
query: 'proj',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(results).toContain(path.join(homeDir, "projects"));
|
expect(results).toContain(path.join(homeDir, 'projects'))
|
||||||
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
|
expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
|
||||||
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
|
expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
|
||||||
});
|
})
|
||||||
|
|
||||||
it("supports home-relative path query syntax", async () => {
|
it('supports home-relative path query syntax', async () => {
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "~/projects/pa",
|
query: '~/projects/pa',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(results).toEqual([
|
expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
|
||||||
path.join(homeDir, "projects", "paseo"),
|
})
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prioritizes exact segment matches before segment-prefix matches", async () => {
|
it('prioritizes exact segment matches before segment-prefix matches', async () => {
|
||||||
const exactSegmentPath = path.join(
|
const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
|
||||||
homeDir,
|
const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
|
||||||
"something",
|
mkdirSync(exactSegmentPath, { recursive: true })
|
||||||
"faro",
|
mkdirSync(prefixSegmentPath, { recursive: true })
|
||||||
"something-else"
|
|
||||||
);
|
|
||||||
const prefixSegmentPath = path.join(
|
|
||||||
homeDir,
|
|
||||||
"something",
|
|
||||||
"somethingelse",
|
|
||||||
"faro-bla"
|
|
||||||
);
|
|
||||||
mkdirSync(exactSegmentPath, { recursive: true });
|
|
||||||
mkdirSync(prefixSegmentPath, { recursive: true });
|
|
||||||
|
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "faro",
|
query: 'faro',
|
||||||
limit: 30,
|
limit: 30,
|
||||||
});
|
})
|
||||||
|
|
||||||
const exactIndex = results.indexOf(exactSegmentPath);
|
const exactIndex = results.indexOf(exactSegmentPath)
|
||||||
const prefixIndex = results.indexOf(prefixSegmentPath);
|
const prefixIndex = results.indexOf(prefixSegmentPath)
|
||||||
expect(exactIndex).toBeGreaterThanOrEqual(0);
|
expect(exactIndex).toBeGreaterThanOrEqual(0)
|
||||||
expect(prefixIndex).toBeGreaterThanOrEqual(0);
|
expect(prefixIndex).toBeGreaterThanOrEqual(0)
|
||||||
expect(exactIndex).toBeLessThan(prefixIndex);
|
expect(exactIndex).toBeLessThan(prefixIndex)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("prioritizes partial matches that appear earlier in the path", async () => {
|
it('prioritizes partial matches that appear earlier in the path', async () => {
|
||||||
const earlierPath = path.join(homeDir, "farofoo");
|
const earlierPath = path.join(homeDir, 'farofoo')
|
||||||
const laterPath = path.join(homeDir, "x", "y", "farofoo");
|
const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
|
||||||
mkdirSync(earlierPath, { recursive: true });
|
mkdirSync(earlierPath, { recursive: true })
|
||||||
mkdirSync(laterPath, { recursive: true });
|
mkdirSync(laterPath, { recursive: true })
|
||||||
|
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "arofo",
|
query: 'arofo',
|
||||||
limit: 30,
|
limit: 30,
|
||||||
});
|
})
|
||||||
|
|
||||||
const earlierIndex = results.indexOf(earlierPath);
|
const earlierIndex = results.indexOf(earlierPath)
|
||||||
const laterIndex = results.indexOf(laterPath);
|
const laterIndex = results.indexOf(laterPath)
|
||||||
expect(earlierIndex).toBeGreaterThanOrEqual(0);
|
expect(earlierIndex).toBeGreaterThanOrEqual(0)
|
||||||
expect(laterIndex).toBeGreaterThanOrEqual(0);
|
expect(laterIndex).toBeGreaterThanOrEqual(0)
|
||||||
expect(earlierIndex).toBeLessThan(laterIndex);
|
expect(earlierIndex).toBeLessThan(laterIndex)
|
||||||
});
|
})
|
||||||
|
|
||||||
it("returns home-root suggestions when query is '~'", async () => {
|
it("returns home-root suggestions when query is '~'", async () => {
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "~",
|
query: '~',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(results).toContain(path.join(homeDir, "projects"));
|
expect(results).toContain(path.join(homeDir, 'projects'))
|
||||||
expect(results).toContain(path.join(homeDir, "documents"));
|
expect(results).toContain(path.join(homeDir, 'documents'))
|
||||||
expect(results).not.toContain(path.join(homeDir, ".hidden"));
|
expect(results).not.toContain(path.join(homeDir, '.hidden'))
|
||||||
});
|
})
|
||||||
|
|
||||||
it("does not return hidden directories during tree search", async () => {
|
it('does not return hidden directories during tree search', async () => {
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "cache",
|
query: 'cache',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
|
expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
|
||||||
});
|
})
|
||||||
|
|
||||||
it("does not return paths that escape home through symlinks", async () => {
|
it('does not return paths that escape home through symlinks', async () => {
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "outside",
|
query: 'outside',
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(results).not.toContain(path.join(homeDir, "outside-link"));
|
expect(results).not.toContain(path.join(homeDir, 'outside-link'))
|
||||||
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
|
expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
|
||||||
});
|
})
|
||||||
|
|
||||||
it("respects the result limit", async () => {
|
it('respects the result limit', async () => {
|
||||||
const results = await searchHomeDirectories({
|
const results = await searchHomeDirectories({
|
||||||
homeDir,
|
homeDir,
|
||||||
query: "p",
|
query: 'p',
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
})
|
||||||
|
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1)
|
||||||
});
|
})
|
||||||
});
|
})
|
||||||
|
|
||||||
|
describe('searchWorkspaceEntries', () => {
|
||||||
|
let tempRoot: string
|
||||||
|
let workspaceDir: string
|
||||||
|
let outsideDir: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
|
||||||
|
workspaceDir = path.join(tempRoot, 'workspace')
|
||||||
|
outsideDir = path.join(tempRoot, 'outside')
|
||||||
|
|
||||||
|
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
|
||||||
|
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
|
||||||
|
|
||||||
|
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
|
||||||
|
writeFileSync(
|
||||||
|
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
|
||||||
|
'export const ChatInput = null;\n'
|
||||||
|
)
|
||||||
|
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
|
||||||
|
|
||||||
|
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns relative file and directory suggestions for workspace queries', async () => {
|
||||||
|
const results = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: 'chat',
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(results).toContainEqual({
|
||||||
|
path: 'src/components/chat-input.tsx',
|
||||||
|
kind: 'file',
|
||||||
|
})
|
||||||
|
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters entries by kind', async () => {
|
||||||
|
const dirsOnly = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: 'src',
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: false,
|
||||||
|
includeDirectories: true,
|
||||||
|
})
|
||||||
|
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
|
||||||
|
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
|
||||||
|
|
||||||
|
const filesOnly = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: 'readme',
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: false,
|
||||||
|
})
|
||||||
|
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports path-style queries and does not escape cwd through symlinks', async () => {
|
||||||
|
const pathResults = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: 'src/co',
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
|
})
|
||||||
|
expect(pathResults).toContainEqual({
|
||||||
|
path: 'src/components',
|
||||||
|
kind: 'directory',
|
||||||
|
})
|
||||||
|
|
||||||
|
const escapedResults = await searchWorkspaceEntries({
|
||||||
|
cwd: workspaceDir,
|
||||||
|
query: 'escaped',
|
||||||
|
limit: 20,
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirectories: true,
|
||||||
|
})
|
||||||
|
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user