Merge branch 'improve-property-tooltip' into loginPage

This commit is contained in:
choizhang
2025-03-15 00:11:50 +08:00
57 changed files with 1822 additions and 606 deletions

View File

@@ -0,0 +1,66 @@
import { useState, useCallback } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import Button from '@/components/ui/Button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select'
import { useSettingsStore } from '@/stores/settings'
import { PaletteIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function AppSettings() {
const [opened, setOpened] = useState<boolean>(false)
const { t } = useTranslation()
const language = useSettingsStore.use.language()
const setLanguage = useSettingsStore.use.setLanguage()
const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
const handleLanguageChange = useCallback((value: string) => {
setLanguage(value as 'en' | 'zh')
}, [setLanguage])
const handleThemeChange = useCallback((value: string) => {
setTheme(value as 'light' | 'dark' | 'system')
}, [setTheme])
return (
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<PaletteIcon className="h-5 w-5" />
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-56">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.language')}</label>
<Select value={language} onValueChange={handleLanguageChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="zh"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('settings.theme')}</label>
<Select value={theme} onValueChange={handleThemeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t('settings.light')}</SelectItem>
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
<SelectItem value="system">{t('settings.system')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,24 @@
import { StrictMode, useEffect, useState } from 'react'
import { initializeI18n } from '@/i18n'
import App from '@/App'
export const Root = () => {
const [isI18nInitialized, setIsI18nInitialized] = useState(false)
useEffect(() => {
// Initialize i18n immediately with persisted language
initializeI18n().then(() => {
setIsI18nInitialized(true)
})
}, [])
if (!isI18nInitialized) {
return null // or a loading spinner
}
return (
<StrictMode>
<App />
</StrictMode>
)
}

View File

@@ -1,4 +1,4 @@
import { createContext, useEffect, useState } from 'react'
import { createContext, useEffect } from 'react'
import { Theme, useSettingsStore } from '@/stores/settings'
type ThemeProviderProps = {
@@ -21,30 +21,32 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
* Component that provides the theme state and setter function to its children.
*/
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(useSettingsStore.getState().theme)
const theme = useSettingsStore.use.theme()
const setTheme = useSettingsStore.use.setTheme()
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
setTheme(systemTheme)
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e: MediaQueryListEvent) => {
root.classList.remove('light', 'dark')
root.classList.add(e.matches ? 'dark' : 'light')
}
root.classList.add(theme)
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
} else {
root.classList.add(theme)
}
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
useSettingsStore.getState().setTheme(theme)
setTheme(theme)
}
setTheme
}
return (

View File

@@ -13,15 +13,24 @@ const FocusOnNode = ({ node, move }: { node: string | null; move?: boolean }) =>
* When the selected item changes, highlighted the node and center the camera on it.
*/
useEffect(() => {
if (!node) return
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
if (move) {
gotoNode(node)
if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
gotoNode(node)
} else {
// If no node is selected but move is true, reset to default view
sigma.setCustomBBox(null)
sigma.getCamera().animate({ x: 0.5, y: 0.5, ratio: 1 }, { duration: 0 })
}
useGraphStore.getState().setMoveToSelectedNode(false)
} else if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', true)
}
return () => {
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
if (node) {
sigma.getGraph().setNodeAttribute(node, 'highlighted', false)
}
}
}, [node, move, sigma, gotoNode])

View File

@@ -1,10 +1,11 @@
import { useLoadGraph, useRegisterEvents, useSetSettings, useSigma } from '@react-sigma/core'
import Graph from 'graphology'
// import { useLayoutCircular } from '@react-sigma/layout-circular'
import { useLayoutForceAtlas2 } from '@react-sigma/layout-forceatlas2'
import { useEffect } from 'react'
// import useRandomGraph, { EdgeType, NodeType } from '@/hooks/useRandomGraph'
import useLightragGraph, { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import { EdgeType, NodeType } from '@/hooks/useLightragGraph'
import useTheme from '@/hooks/useTheme'
import * as Constants from '@/lib/constants'
@@ -21,7 +22,6 @@ const isButtonPressed = (ev: MouseEvent | TouchEvent) => {
}
const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean }) => {
const { lightrageGraph } = useLightragGraph()
const sigma = useSigma<NodeType, EdgeType>()
const registerEvents = useRegisterEvents<NodeType, EdgeType>()
const setSettings = useSetSettings<NodeType, EdgeType>()
@@ -34,21 +34,25 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const { theme } = useTheme()
const hideUnselectedEdges = useSettingsStore.use.enableHideUnselectedEdges()
const enableEdgeEvents = useSettingsStore.use.enableEdgeEvents()
const renderEdgeLabels = useSettingsStore.use.showEdgeLabel()
const renderLabels = useSettingsStore.use.showNodeLabel()
const selectedNode = useGraphStore.use.selectedNode()
const focusedNode = useGraphStore.use.focusedNode()
const selectedEdge = useGraphStore.use.selectedEdge()
const focusedEdge = useGraphStore.use.focusedEdge()
const sigmaGraph = useGraphStore.use.sigmaGraph()
/**
* When component mount or maxIterations changes
* => load the graph and apply layout
*/
useEffect(() => {
// Create & load the graph
const graph = lightrageGraph()
loadGraph(graph)
assignLayout()
}, [assignLayout, loadGraph, lightrageGraph, maxIterations])
if (sigmaGraph) {
loadGraph(sigmaGraph as unknown as Graph<NodeType, EdgeType>)
assignLayout()
}
}, [assignLayout, loadGraph, sigmaGraph, maxIterations])
/**
* When component mount
@@ -58,39 +62,52 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const { setFocusedNode, setSelectedNode, setFocusedEdge, setSelectedEdge, clearSelection } =
useGraphStore.getState()
// Register the events
registerEvents({
enterNode: (event) => {
// Define event types
type NodeEvent = { node: string; event: { original: MouseEvent | TouchEvent } }
type EdgeEvent = { edge: string; event: { original: MouseEvent | TouchEvent } }
// Register all events, but edge events will only be processed if enableEdgeEvents is true
const events: Record<string, any> = {
enterNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedNode(event.node)
}
},
leaveNode: (event) => {
leaveNode: (event: NodeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedNode(null)
}
},
clickNode: (event) => {
clickNode: (event: NodeEvent) => {
setSelectedNode(event.node)
setSelectedEdge(null)
},
clickEdge: (event) => {
clickStage: () => clearSelection()
}
// Only add edge event handlers if enableEdgeEvents is true
if (enableEdgeEvents) {
events.clickEdge = (event: EdgeEvent) => {
setSelectedEdge(event.edge)
setSelectedNode(null)
},
enterEdge: (event) => {
}
events.enterEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedEdge(event.edge)
}
},
leaveEdge: (event) => {
}
events.leaveEdge = (event: EdgeEvent) => {
if (!isButtonPressed(event.event.original)) {
setFocusedEdge(null)
}
},
clickStage: () => clearSelection()
})
}, [registerEvents])
}
}
// Register the events
registerEvents(events)
}, [registerEvents, enableEdgeEvents])
/**
* When component mount or hovered node change
@@ -101,7 +118,14 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
const labelColor = isDarkTheme ? Constants.labelColorDarkTheme : undefined
const edgeColor = isDarkTheme ? Constants.edgeColorDarkTheme : undefined
// Update all dynamic settings directly without recreating the sigma container
setSettings({
// Update display settings
enableEdgeEvents,
renderEdgeLabels,
renderLabels,
// Node reducer for node appearance
nodeReducer: (node, data) => {
const graph = sigma.getGraph()
const newData: NodeType & {
@@ -140,6 +164,8 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
}
return newData
},
// Edge reducer for edge appearance
edgeReducer: (edge, data) => {
const graph = sigma.getGraph()
const newData = { ...data, hidden: false, labelColor, color: edgeColor }
@@ -181,7 +207,10 @@ const GraphControl = ({ disableHoverEffect }: { disableHoverEffect?: boolean })
sigma,
disableHoverEffect,
theme,
hideUnselectedEdges
hideUnselectedEdges,
enableEdgeEvents,
renderEdgeLabels,
renderLabels
])
return null

View File

@@ -1,37 +1,48 @@
import { useCallback } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { AsyncSelect } from '@/components/ui/AsyncSelect'
import { getGraphLabels } from '@/api/lightrag'
import { useSettingsStore } from '@/stores/settings'
import { useGraphStore } from '@/stores/graph'
import { labelListLimit } from '@/lib/constants'
import MiniSearch from 'minisearch'
import { useTranslation } from 'react-i18next'
const lastGraph: any = {
graph: null,
searchEngine: null,
labels: []
}
const GraphLabels = () => {
const { t } = useTranslation()
const label = useSettingsStore.use.queryLabel()
const graph = useGraphStore.use.sigmaGraph()
const allDatabaseLabels = useGraphStore.use.allDatabaseLabels()
const labelsLoadedRef = useRef(false)
const getSearchEngine = useCallback(async () => {
if (lastGraph.graph == graph) {
return {
labels: lastGraph.labels,
searchEngine: lastGraph.searchEngine
}
}
const labels = ['*'].concat(await getGraphLabels())
// Ensure query label exists
if (!labels.includes(useSettingsStore.getState().queryLabel)) {
useSettingsStore.getState().setQueryLabel(labels[0])
// Track if a fetch is in progress to prevent multiple simultaneous fetches
const fetchInProgressRef = useRef(false)
// Fetch labels once on component mount, using global flag to prevent duplicates
useEffect(() => {
// Check if we've already attempted to fetch labels in this session
const labelsFetchAttempted = useGraphStore.getState().labelsFetchAttempted
// Only fetch if we haven't attempted in this session and no fetch is in progress
if (!labelsFetchAttempted && !fetchInProgressRef.current) {
fetchInProgressRef.current = true
// Set global flag to indicate we've attempted to fetch in this session
useGraphStore.getState().setLabelsFetchAttempted(true)
console.log('Fetching graph labels (once per session)...')
useGraphStore.getState().fetchAllDatabaseLabels()
.then(() => {
labelsLoadedRef.current = true
fetchInProgressRef.current = false
})
.catch((error) => {
console.error('Failed to fetch labels:', error)
fetchInProgressRef.current = false
// Reset global flag to allow retry
useGraphStore.getState().setLabelsFetchAttempted(false)
})
}
}, []) // Empty dependency array ensures this only runs once on mount
const getSearchEngine = useCallback(() => {
// Create search engine
const searchEngine = new MiniSearch({
idField: 'id',
@@ -46,41 +57,32 @@ const GraphLabels = () => {
})
// Add documents
const documents = labels.map((str, index) => ({ id: index, value: str }))
const documents = allDatabaseLabels.map((str, index) => ({ id: index, value: str }))
searchEngine.addAll(documents)
lastGraph.graph = graph
lastGraph.searchEngine = searchEngine
lastGraph.labels = labels
return {
labels,
labels: allDatabaseLabels,
searchEngine
}
}, [graph])
}, [allDatabaseLabels])
const fetchData = useCallback(
async (query?: string): Promise<string[]> => {
const { labels, searchEngine } = await getSearchEngine()
const { labels, searchEngine } = getSearchEngine()
let result: string[] = labels
if (query) {
// Search labels
result = searchEngine.search(query).map((r) => labels[r.id])
result = searchEngine.search(query).map((r: { id: number }) => labels[r.id])
}
return result.length <= labelListLimit
? result
: [...result.slice(0, labelListLimit), t('graphLabels.andOthers', { count: result.length - labelListLimit })]
: [...result.slice(0, labelListLimit), '...']
},
[getSearchEngine]
)
const setQueryLabel = useCallback((label: string) => {
if (label.startsWith('And ') && label.endsWith(' others')) return
useSettingsStore.getState().setQueryLabel(label)
}, [])
return (
<AsyncSelect<string>
className="ml-2"
@@ -94,8 +96,38 @@ const GraphLabels = () => {
notFound={<div className="py-6 text-center text-sm">No labels found</div>}
label={t('graphPanel.graphLabels.label')}
placeholder={t('graphPanel.graphLabels.placeholder')}
value={label !== null ? label : ''}
onChange={setQueryLabel}
value={label !== null ? label : '*'}
onChange={(newLabel) => {
const currentLabel = useSettingsStore.getState().queryLabel
// select the last item means query all
if (newLabel === '...') {
newLabel = '*'
}
// Reset the fetch attempted flag to force a new data fetch
useGraphStore.getState().setGraphDataFetchAttempted(false)
// Clear current graph data to ensure complete reload when label changes
if (newLabel !== currentLabel) {
const graphStore = useGraphStore.getState();
graphStore.clearSelection();
// Reset the graph state but preserve the instance
if (graphStore.sigmaGraph) {
const nodes = Array.from(graphStore.sigmaGraph.nodes());
nodes.forEach(node => graphStore.sigmaGraph?.dropNode(node));
}
}
if (newLabel === currentLabel && newLabel !== '*') {
// reselect the same itme means qery all
useSettingsStore.getState().setQueryLabel('*')
} else {
useSettingsStore.getState().setQueryLabel(newLabel)
}
}}
clearable={false} // Prevent clearing value on reselect
/>
)
}

View File

@@ -1,4 +1,4 @@
import { FC, useCallback, useMemo } from 'react'
import { FC, useCallback, useEffect, useMemo } from 'react'
import {
EdgeById,
NodeById,
@@ -28,6 +28,7 @@ function OptionComponent(item: OptionItem) {
}
const messageId = '__message_item'
// Reset this cache when graph changes to ensure fresh search results
const lastGraph: any = {
graph: null,
searchEngine: null
@@ -48,6 +49,15 @@ export const GraphSearchInput = ({
const { t } = useTranslation()
const graph = useGraphStore.use.sigmaGraph()
// Force reset the cache when graph changes
useEffect(() => {
if (graph) {
// Reset cache to ensure fresh search results with new graph data
lastGraph.graph = null;
lastGraph.searchEngine = null;
}
}, [graph]);
const searchEngine = useMemo(() => {
if (lastGraph.graph == graph) {
return lastGraph.searchEngine
@@ -85,8 +95,19 @@ export const GraphSearchInput = ({
const loadOptions = useCallback(
async (query?: string): Promise<OptionItem[]> => {
if (onFocus) onFocus(null)
if (!query || !searchEngine) return []
const result: OptionItem[] = searchEngine.search(query).map((r) => ({
if (!graph || !searchEngine) return []
// If no query, return first searchResultLimit nodes
if (!query) {
const nodeIds = graph.nodes().slice(0, searchResultLimit)
return nodeIds.map(id => ({
id,
type: 'nodes'
}))
}
// If has query, search nodes
const result: OptionItem[] = searchEngine.search(query).map((r: { id: string }) => ({
id: r.id,
type: 'nodes'
}))
@@ -103,7 +124,7 @@ export const GraphSearchInput = ({
}
]
},
[searchEngine, onFocus]
[graph, searchEngine, onFocus, t]
)
return (

View File

@@ -96,9 +96,9 @@ const refineNodeProperties = (node: RawNodeType): NodeType => {
const neighbour = state.rawGraph.getNode(neighbourId)
if (neighbour) {
relationships.push({
type: isTarget ? 'Target' : 'Source',
type: 'Neighbour',
id: neighbourId,
label: neighbour.labels.join(', ')
label: neighbour.properties['entity_id'] ? neighbour.properties['entity_id'] : neighbour.labels.join(', ')
})
}
}
@@ -132,14 +132,22 @@ const PropertyRow = ({
onClick?: () => void
tooltip?: string
}) => {
const { t } = useTranslation()
const getPropertyNameTranslation = (name: string) => {
const translationKey = `graphPanel.propertiesView.node.propertyNames.${name}`
const translation = t(translationKey)
return translation === translationKey ? name : translation
}
return (
<div className="flex items-center gap-2">
<label className="text-primary/60 tracking-wide">{name}</label>:
<label className="text-primary/60 tracking-wide whitespace-nowrap">{getPropertyNameTranslation(name)}</label>:
<Text
className="hover:bg-primary/20 rounded p-1 text-ellipsis"
className="hover:bg-primary/20 rounded p-1 overflow-hidden text-ellipsis"
tooltipClassName="max-w-80"
text={value}
tooltip={tooltip || value}
tooltip={tooltip || (typeof value === 'string' ? value : JSON.stringify(value, null, 2))}
side="left"
onClick={onClick}
/>
@@ -174,7 +182,7 @@ const NodePropertiesView = ({ node }: { node: NodeType }) => {
{node.relationships.length > 0 && (
<>
<label className="text-md pl-1 font-bold tracking-wide text-teal-600/90">
{t('graphPanel.propertiesView.node.relationships')}
{t('graphPanel.propertiesView.node.relationships')}
</label>
<div className="bg-primary/5 max-h-96 overflow-auto rounded p-1">
{node.relationships.map(({ type, id, label }) => {

View File

@@ -8,9 +8,10 @@ import Input from '@/components/ui/Input'
import { controlButtonVariant } from '@/lib/constants'
import { useSettingsStore } from '@/stores/settings'
import { useBackendState } from '@/stores/state'
import { useGraphStore } from '@/stores/graph'
import { SettingsIcon } from 'lucide-react'
import { useTranslation } from "react-i18next";
import { SettingsIcon, RefreshCwIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next';
/**
* Component that displays a checkbox with a label.
@@ -114,6 +115,7 @@ const LabeledNumberInput = ({
export default function Settings() {
const [opened, setOpened] = useState<boolean>(false)
const [tempApiKey, setTempApiKey] = useState<string>('')
const refreshLayout = useGraphStore.use.refreshLayout()
const showPropertyPanel = useSettingsStore.use.showPropertyPanel()
const showNodeSearchBar = useSettingsStore.use.showNodeSearchBar()
@@ -208,116 +210,126 @@ export default function Settings() {
const { t } = useTranslation();
return (
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button variant={controlButtonVariant} tooltip={t("graphPanel.sideBar.settings.settings")} size="icon">
<SettingsIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="mb-2 p-2"
onCloseAutoFocus={(e) => e.preventDefault()}
<>
<Button
variant={controlButtonVariant}
tooltip={t('graphPanel.sideBar.settings.refreshLayout')}
size="icon"
onClick={refreshLayout}
>
<div className="flex flex-col gap-2">
<LabeledCheckBox
checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck}
label={t("graphPanel.sideBar.settings.healthCheck")}
/>
<Separator />
<LabeledCheckBox
checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel}
label={t("graphPanel.sideBar.settings.showPropertyPanel")}
/>
<LabeledCheckBox
checked={showNodeSearchBar}
onCheckedChange={setShowNodeSearchBar}
label={t("graphPanel.sideBar.settings.showSearchBar")}
/>
<Separator />
<LabeledCheckBox
checked={showNodeLabel}
onCheckedChange={setShowNodeLabel}
label={t("graphPanel.sideBar.settings.showNodeLabel")}
/>
<LabeledCheckBox
checked={enableNodeDrag}
onCheckedChange={setEnableNodeDrag}
label={t("graphPanel.sideBar.settings.nodeDraggable")}
/>
<Separator />
<LabeledCheckBox
checked={showEdgeLabel}
onCheckedChange={setShowEdgeLabel}
label={t("graphPanel.sideBar.settings.showEdgeLabel")}
/>
<LabeledCheckBox
checked={enableHideUnselectedEdges}
onCheckedChange={setEnableHideUnselectedEdges}
label={t("graphPanel.sideBar.settings.hideUnselectedEdges")}
/>
<LabeledCheckBox
checked={enableEdgeEvents}
onCheckedChange={setEnableEdgeEvents}
label={t("graphPanel.sideBar.settings.edgeEvents")}
/>
<Separator />
<LabeledNumberInput
label={t("graphPanel.sideBar.settings.maxQueryDepth")}
min={1}
value={graphQueryMaxDepth}
onEditFinished={setGraphQueryMaxDepth}
/>
<LabeledNumberInput
label={t("graphPanel.sideBar.settings.minDegree")}
min={0}
value={graphMinDegree}
onEditFinished={setGraphMinDegree}
/>
<LabeledNumberInput
label={t("graphPanel.sideBar.settings.maxLayoutIterations")}
min={1}
max={20}
value={graphLayoutMaxIterations}
onEditFinished={setGraphLayoutMaxIterations}
/>
<Separator />
<RefreshCwIcon />
</Button>
<Popover open={opened} onOpenChange={setOpened}>
<PopoverTrigger asChild>
<Button variant={controlButtonVariant} tooltip={t('graphPanel.sideBar.settings.settings')} size="icon">
<SettingsIcon />
</Button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="mb-2 p-2"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t("graphPanel.sideBar.settings.apiKey")}</label>
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
<div className="w-0 flex-1">
<Input
type="password"
value={tempApiKey}
onChange={handleTempApiKeyChange}
placeholder={t("graphPanel.sideBar.settings.enterYourAPIkey")}
className="max-h-full w-full min-w-0"
autoComplete="off"
/>
</div>
<Button
onClick={setApiKey}
variant="outline"
size="sm"
className="max-h-full shrink-0"
>
{t("graphPanel.sideBar.settings.save")}
</Button>
</form>
<LabeledCheckBox
checked={enableHealthCheck}
onCheckedChange={setEnableHealthCheck}
label={t('graphPanel.sideBar.settings.healthCheck')}
/>
<Separator />
<LabeledCheckBox
checked={showPropertyPanel}
onCheckedChange={setShowPropertyPanel}
label={t('graphPanel.sideBar.settings.showPropertyPanel')}
/>
<LabeledCheckBox
checked={showNodeSearchBar}
onCheckedChange={setShowNodeSearchBar}
label={t('graphPanel.sideBar.settings.showSearchBar')}
/>
<Separator />
<LabeledCheckBox
checked={showNodeLabel}
onCheckedChange={setShowNodeLabel}
label={t('graphPanel.sideBar.settings.showNodeLabel')}
/>
<LabeledCheckBox
checked={enableNodeDrag}
onCheckedChange={setEnableNodeDrag}
label={t('graphPanel.sideBar.settings.nodeDraggable')}
/>
<Separator />
<LabeledCheckBox
checked={showEdgeLabel}
onCheckedChange={setShowEdgeLabel}
label={t('graphPanel.sideBar.settings.showEdgeLabel')}
/>
<LabeledCheckBox
checked={enableHideUnselectedEdges}
onCheckedChange={setEnableHideUnselectedEdges}
label={t('graphPanel.sideBar.settings.hideUnselectedEdges')}
/>
<LabeledCheckBox
checked={enableEdgeEvents}
onCheckedChange={setEnableEdgeEvents}
label={t('graphPanel.sideBar.settings.edgeEvents')}
/>
<Separator />
<LabeledNumberInput
label={t('graphPanel.sideBar.settings.maxQueryDepth')}
min={1}
value={graphQueryMaxDepth}
onEditFinished={setGraphQueryMaxDepth}
/>
<LabeledNumberInput
label={t('graphPanel.sideBar.settings.minDegree')}
min={0}
value={graphMinDegree}
onEditFinished={setGraphMinDegree}
/>
<LabeledNumberInput
label={t('graphPanel.sideBar.settings.maxLayoutIterations')}
min={1}
max={30}
value={graphLayoutMaxIterations}
onEditFinished={setGraphLayoutMaxIterations}
/>
<Separator />
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('graphPanel.sideBar.settings.apiKey')}</label>
<form className="flex h-6 gap-2" onSubmit={(e) => e.preventDefault()}>
<div className="w-0 flex-1">
<Input
type="password"
value={tempApiKey}
onChange={handleTempApiKeyChange}
placeholder={t('graphPanel.sideBar.settings.enterYourAPIkey')}
className="max-h-full w-full min-w-0"
autoComplete="off"
/>
</div>
<Button
onClick={setApiKey}
variant="outline"
size="sm"
className="max-h-full shrink-0"
>
{t('graphPanel.sideBar.settings.save')}
</Button>
</form>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
</>
)
}

View File

@@ -0,0 +1,21 @@
import { useSettingsStore } from '@/stores/settings'
import { useTranslation } from 'react-i18next'
/**
* Component that displays current values of important graph settings
* Positioned to the right of the toolbar at the bottom-left corner
*/
const SettingsDisplay = () => {
const { t } = useTranslation()
const graphQueryMaxDepth = useSettingsStore.use.graphQueryMaxDepth()
const graphMinDegree = useSettingsStore.use.graphMinDegree()
return (
<div className="absolute bottom-2 left-[calc(2rem+2.5rem)] flex items-center gap-2 text-xs text-gray-400">
<div>{t('graphPanel.sideBar.settings.depth')}: {graphQueryMaxDepth}</div>
<div>{t('graphPanel.sideBar.settings.degree')}: {graphMinDegree}</div>
</div>
)
}
export default SettingsDisplay

View File

@@ -25,7 +25,7 @@ export default function QuerySettings() {
}, [])
return (
<Card className="flex shrink-0 flex-col">
<Card className="flex shrink-0 flex-col min-w-[180px]">
<CardHeader className="px-4 pt-4 pb-2">
<CardTitle>{t('retrievePanel.querySettings.parametersTitle')}</CardTitle>
<CardDescription>{t('retrievePanel.querySettings.parametersDescription')}</CardDescription>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { Loader2 } from 'lucide-react'
import { useDebounce } from '@/hooks/useDebounce'
@@ -193,7 +193,7 @@ export function AsyncSearch<T>({
</div>
)}
</div>
<CommandList hidden={!open || debouncedSearchTerm.length === 0}>
<CommandList hidden={!open}>
{error && <div className="text-destructive p-4 text-center">{error}</div>}
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)}
{!loading &&
@@ -204,7 +204,7 @@ export function AsyncSearch<T>({
))}
<CommandGroup>
{options.map((option, idx) => (
<>
<React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}>
<CommandItem
key={getOptionValue(option) + `${idx}`}
value={getOptionValue(option)}
@@ -215,9 +215,9 @@ export function AsyncSearch<T>({
{renderOption(option)}
</CommandItem>
{idx !== options.length - 1 && (
<div key={idx} className="bg-foreground/10 h-[1px]" />
<div key={`divider-${idx}`} className="bg-foreground/10 h-[1px]" />
)}
</>
</React.Fragment>
))}
</CommandGroup>
</CommandList>

View File

@@ -0,0 +1,37 @@
import React, { useEffect } from 'react';
import { useTabVisibility } from '@/contexts/useTabVisibility';
interface TabContentProps {
tabId: string;
children: React.ReactNode;
className?: string;
}
/**
* TabContent component that manages visibility based on tab selection
* Works with the TabVisibilityContext to show/hide content based on active tab
*/
const TabContent: React.FC<TabContentProps> = ({ tabId, children, className = '' }) => {
const { isTabVisible, setTabVisibility } = useTabVisibility();
const isVisible = isTabVisible(tabId);
// Register this tab with the context when mounted
useEffect(() => {
setTabVisibility(tabId, true);
// Cleanup when unmounted
return () => {
setTabVisibility(tabId, false);
};
}, [tabId, setTabVisibility]);
// Use CSS to hide content instead of not rendering it
// This prevents components from unmounting when tabs are switched
return (
<div className={`${className} ${isVisible ? '' : 'hidden'}`}>
{children}
</div>
);
};
export default TabContent;

View File

@@ -42,9 +42,13 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'data-[state=inactive]:invisible data-[state=active]:visible',
'h-full w-full',
className
)}
// Force mounting of inactive tabs to preserve WebGL contexts
forceMount
{...props}
/>
))

View File

@@ -10,30 +10,43 @@ const TooltipTrigger = TooltipPrimitive.Trigger
const processTooltipContent = (content: string) => {
if (typeof content !== 'string') return content
return content.split('\\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < content.split('\\n').length - 1 && <br />}
</React.Fragment>
))
return (
<div className="relative top-0 pt-1 whitespace-pre-wrap break-words">
{content}
</div>
)
}
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, children, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 mx-1 max-w-sm overflow-hidden rounded-md border px-3 py-2 text-sm shadow-md',
className
)}
{...props}
>
{typeof children === 'string' ? processTooltipContent(children) : children}
</TooltipPrimitive.Content>
))
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
}
>(({ className, side = 'left', align = 'start', children, ...props }, ref) => {
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0;
}
}, [children]);
return (
<TooltipPrimitive.Content
ref={ref}
side={side}
align={align}
className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-[60vh] overflow-y-auto whitespace-pre-wrap break-words rounded-md border px-3 py-2 text-sm shadow-md',
className
)}
{...props}
>
{typeof children === 'string' ? processTooltipContent(children) : children}
</TooltipPrimitive.Content>
);
})
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }