diff --git a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx new file mode 100644 index 0000000000..710b4c1f0e --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx @@ -0,0 +1,167 @@ +import { BarChart, Shield } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' +import { + createLintSummaryPrompt, + LintCategoryBadge, + lintInfoMap, +} from 'components/interfaces/Linter/Linter.utils' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { + AiIconAnimation, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Sheet, + SheetContent, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' +import { Row } from 'ui-patterns' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import LintDetail from 'components/interfaces/Linter/LintDetail' + +export const AdvisorSection = () => { + const { ref: projectRef } = useParams() + const { data: lints, isLoading: isLoadingLints } = useProjectLintsQuery({ projectRef }) + const snap = useAiAssistantStateSnapshot() + + const [selectedLint, setSelectedLint] = useState(null) + + const errorLints: Lint[] = useMemo(() => { + return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? [] + }, [lints]) + + const totalErrors = errorLints.length + + const titleContent = useMemo(() => { + if (totalErrors === 0) return

Assistant found no issues

+ const issuesText = totalErrors === 1 ? 'issue' : 'issues' + const numberDisplay = totalErrors.toString() + return ( +

+ Assistant found {numberDisplay} {issuesText} +

+ ) + }, [totalErrors]) + + const handleAskAssistant = useCallback(() => { + snap.toggleAssistant() + }, [snap]) + + const handleCardClick = useCallback((lint: Lint) => { + setSelectedLint(lint) + }, []) + + return ( +
+ {isLoadingLints ? ( + + ) : ( +
+ {titleContent} + +
+ )} + {isLoadingLints ? ( +
+ + + +
+ ) : errorLints.length > 0 ? ( + <> + + {errorLints.map((lint) => { + return ( + { + handleCardClick(lint) + }} + > + +
+ {lint.categories[0] === 'SECURITY' ? ( + + ) : ( + + )} + {lint.categories[0]} +
+ } + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + snap.newChat({ + name: 'Summarize lint', + open: true, + initialInput: createLintSummaryPrompt(lint), + }) + }} + tooltip={{ + content: { side: 'bottom', text: 'Help me fix this issue' }, + }} + /> +
+ + {lint.detail ? lint.detail.substring(0, 100) : lint.title} + {lint.detail && lint.detail.length > 100 && '...'} + +
+ ) + })} +
+ setSelectedLint(null)}> + + {selectedLint && ( + <> + +
+ + {lintInfoMap.find((item) => item.name === selectedLint.name)?.title ?? + 'Unknown'} + + +
+
+ + {selectedLint && projectRef && ( + setSelectedLint(null)} + /> + )} + + + )} +
+
+ + ) : ( + + + +

+ No security or performance errors found +

+
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/HomeNew/Home.tsx index 5c2390d1c4..d2cc0c2908 100644 --- a/apps/studio/components/interfaces/HomeNew/Home.tsx +++ b/apps/studio/components/interfaces/HomeNew/Home.tsx @@ -16,6 +16,7 @@ import { } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' +import { AdvisorSection } from './AdvisorSection' export const HomeV2 = () => { const { ref, enableBranching } = useParams() @@ -100,11 +101,15 @@ export const HomeV2 = () => { )} strategy={verticalListSortingStrategy} > - {sectionOrder.map((id) => ( - - {id} - - ))} + {sectionOrder.map((id) => { + if (id === 'advisor') { + return ( + + + + ) + } + })} diff --git a/apps/studio/components/interfaces/Linter/LintDetail.tsx b/apps/studio/components/interfaces/Linter/LintDetail.tsx new file mode 100644 index 0000000000..d499ac91e0 --- /dev/null +++ b/apps/studio/components/interfaces/Linter/LintDetail.tsx @@ -0,0 +1,73 @@ +import Link from 'next/link' +import ReactMarkdown from 'react-markdown' + +import { createLintSummaryPrompt, lintInfoMap } from 'components/interfaces/Linter/Linter.utils' +import { EntityTypeIcon, LintCTA, LintCategoryBadge, LintEntity } from './Linter.utils' +import { Lint } from 'data/lint/lint-query' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { AiIconAnimation, Button } from 'ui' +import { ExternalLink } from 'lucide-react' + +interface LintDetailProps { + lint: Lint + projectRef: string + onAskAssistant?: () => void +} + +const LintDetail = ({ lint, projectRef, onAskAssistant }: LintDetailProps) => { + const snap = useAiAssistantStateSnapshot() + + return ( +
+

Entity

+
+ + +
+ +

Issue

+ + {lint.detail.replace(/\\`/g, '`')} + +

Description

+ + {lint.description.replace(/\\`/g, '`')} + + +

Resolve

+
+ + + +
+
+ ) +} + +export default LintDetail diff --git a/apps/studio/components/interfaces/Linter/LintPageTabs.tsx b/apps/studio/components/interfaces/Linter/LintPageTabs.tsx index ede1abe89b..e4c5b9cb97 100644 --- a/apps/studio/components/interfaces/Linter/LintPageTabs.tsx +++ b/apps/studio/components/interfaces/Linter/LintPageTabs.tsx @@ -20,17 +20,10 @@ import { useRouter } from 'next/router' interface LintPageTabsProps { currentTab: string setCurrentTab: (value: LINTER_LEVELS) => void - setSelectedLint: (value: Lint | null) => void isLoading: boolean activeLints: Lint[] } -const LintPageTabs = ({ - currentTab, - setCurrentTab, - setSelectedLint, - isLoading, - activeLints, -}: LintPageTabsProps) => { +const LintPageTabs = ({ currentTab, setCurrentTab, isLoading, activeLints }: LintPageTabsProps) => { const router = useRouter() const warnLintsCount = activeLints.filter((x) => x.level === 'WARN').length @@ -73,7 +66,6 @@ const LintPageTabs = ({ defaultValue={currentTab} onValueChange={(value) => { setCurrentTab(value as LINTER_LEVELS) - setSelectedLint(null) const { sort, search, ...rest } = router.query router.push({ ...router, query: { ...rest, preset: value, id: null } }) }} diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index 8aed1dac98..0b114eb79f 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -382,3 +382,23 @@ export const NoIssuesFound = ({ level }: { level: string }) => { ) } + +export const createLintSummaryPrompt = (lint: Lint) => { + const title = lintInfoMap.find((item) => item.name === lint.name)?.title ?? lint.title + const entity = + (lint.metadata && + (lint.metadata.entity || + (lint.metadata.schema && + lint.metadata.name && + `${lint.metadata.schema}.${lint.metadata.name}`))) || + 'N/A' + const schema = lint.metadata?.schema ?? 'N/A' + const issue = lint.detail ? lint.detail.replace(/\\`/g, '`') : 'N/A' + const description = lint.description ? lint.description.replace(/\\`/g, '`') : 'N/A' + return `Summarize the issue and suggest fixes for the following lint item: +Title: ${title} +Entity: ${entity} +Schema: ${schema} +Issue Details: ${issue} +Description: ${description}` +} diff --git a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx index a8c8873ecb..a7e9c29e8a 100644 --- a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx +++ b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx @@ -1,35 +1,27 @@ -import { ExternalLink, X } from 'lucide-react' -import Link from 'next/link' +import { X } from 'lucide-react' import { useRef, useState } from 'react' import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' import ReactMarkdown from 'react-markdown' import { useParams } from 'common' import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' -import { LintEntity, NoIssuesFound, lintInfoMap } from 'components/interfaces/Linter/Linter.utils' +import { + LintCategoryBadge, + LintEntity, + NoIssuesFound, + lintInfoMap, +} from 'components/interfaces/Linter/Linter.utils' import { Lint } from 'data/lint/lint-query' import { useRouter } from 'next/router' -import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' -import { - AiIconAnimation, - Button, - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' +import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { EntityTypeIcon, LintCTA, LintCategoryBadge } from './Linter.utils' +import { EntityTypeIcon } from './Linter.utils' +import LintDetail from './LintDetail' interface LinterDataGridProps { isLoading: boolean filteredLints: Lint[] selectedLint: Lint | null - setSelectedLint: (value: Lint | null) => void currentTab: LINTER_LEVELS } @@ -37,15 +29,11 @@ const LinterDataGrid = ({ isLoading, filteredLints, selectedLint, - setSelectedLint, currentTab, }: LinterDataGridProps) => { const gridRef = useRef(null) const { ref } = useParams() const router = useRouter() - const snap = useAiAssistantStateSnapshot() - - const [view, setView] = useState<'details' | 'suggestion'>('details') const lintCols = [ { @@ -121,7 +109,6 @@ const LinterDataGrid = ({ }) function handleSidepanelClose() { - setSelectedLint(null) const { id, ...otherParams } = router.query router.push({ query: otherParams }) } @@ -158,7 +145,6 @@ const LinterDataGrid = ({ {...props} onClick={() => { if (typeof idx === 'number' && idx >= 0) { - setSelectedLint(props.row) gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx }) const { id, ...rest } = router.query router.push({ ...router, query: { ...rest, id: props.row.cache_key } }) @@ -181,109 +167,18 @@ const LinterDataGrid = ({ <> - - - - - - - - )} - - +
+
+

+ {lintInfoMap.find((item) => item.name === selectedLint.name)?.title ?? 'Unknown'} +

+ +
+
+
+ +
)} diff --git a/apps/studio/pages/project/[ref]/advisors/performance.tsx b/apps/studio/pages/project/[ref]/advisors/performance.tsx index 8bccf30af7..ff4321175a 100644 --- a/apps/studio/pages/project/[ref]/advisors/performance.tsx +++ b/apps/studio/pages/project/[ref]/advisors/performance.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useParams } from 'common' import LintPageTabs from 'components/interfaces/Linter/LintPageTabs' @@ -28,8 +28,6 @@ const ProjectLints: NextPageWithLayout = () => { const [currentTab, setCurrentTab] = useState( (preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR ) - const [selectedLint, setSelectedLint] = useState(null) - const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({ projectRef: project?.ref, }) @@ -53,9 +51,8 @@ const ProjectLints: NextPageWithLayout = () => { value: type.name, })) - useEffect(() => { - // check the URL for an ID and set the selected lint - if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null) + const selectedLint: Lint | null = useMemo(() => { + return activeLints.find((lint) => lint.cache_key === id) ?? null }, [id, activeLints]) return ( @@ -70,7 +67,6 @@ const ProjectLints: NextPageWithLayout = () => { isLoading={isLoading} currentTab={currentTab} setCurrentTab={setCurrentTab} - setSelectedLint={setSelectedLint} /> { filteredLints={filteredLints} currentTab={currentTab} selectedLint={selectedLint} - setSelectedLint={setSelectedLint} isLoading={isLoading} /> diff --git a/apps/studio/pages/project/[ref]/advisors/security.tsx b/apps/studio/pages/project/[ref]/advisors/security.tsx index dba3e9245b..cd324bed41 100644 --- a/apps/studio/pages/project/[ref]/advisors/security.tsx +++ b/apps/studio/pages/project/[ref]/advisors/security.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { useParams } from 'common' import LintPageTabs from 'components/interfaces/Linter/LintPageTabs' @@ -28,8 +28,6 @@ const ProjectLints: NextPageWithLayout = () => { const [currentTab, setCurrentTab] = useState( (preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR ) - const [selectedLint, setSelectedLint] = useState(null) - const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({ projectRef: project?.ref, }) @@ -55,9 +53,8 @@ const ProjectLints: NextPageWithLayout = () => { value: type.name, })) - useEffect(() => { - // check the URL for an ID and set the selected lint - if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null) + const selectedLint: Lint | null = useMemo(() => { + return activeLints.find((lint) => lint.cache_key === id) ?? null }, [id, activeLints]) return ( @@ -72,7 +69,6 @@ const ProjectLints: NextPageWithLayout = () => { isLoading={isLoading} currentTab={currentTab} setCurrentTab={setCurrentTab} - setSelectedLint={setSelectedLint} /> { filteredLints={filteredLints} currentTab={currentTab} selectedLint={selectedLint} - setSelectedLint={setSelectedLint} isLoading={isLoading} /> { + // columns can be a fixed number or an array [lg, md, sm] + columns: number | [number, number, number] + children: ReactNode + className?: string + /** gap between items in pixels */ + gap?: number + /** show left/right arrow buttons */ + showArrows?: boolean + /** scrolling behavior for arrow navigation */ + scrollBehavior?: ScrollBehavior +} + +export const Row = forwardRef(function Row( + { columns, children, className, gap = 16, showArrows = true, scrollBehavior = 'smooth', ...rest }, + ref +) { + const containerRef = useRef(null) + // We forward the ref to the outer wrapper; consumers needing the scroll container + // can use a separate ref prop in the future if required. + + const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children]) + + const [scrollPosition, setScrollPosition] = useState(0) + const [maxScroll, setMaxScroll] = useState(0) + + const resolveColumnsForWidth = (width: number): number => { + if (!Array.isArray(columns)) return columns + // Interpret as [lg, md, sm] + const [lgCols, mdCols, smCols] = columns + if (width >= 1024) return lgCols + if (width >= 768) return mdCols + return smCols + } + + const getRenderColumns = (): number => { + const width = containerRef.current?.getBoundingClientRect().width ?? 0 + return resolveColumnsForWidth(width) + } + + const scrollByStep = (direction: -1 | 1) => { + const el = containerRef.current + if (!el) return + const widthLocal = el.getBoundingClientRect().width + const colsLocal = resolveColumnsForWidth(widthLocal) + const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal + const scrollAmount = columnWidth + gap + setScrollPosition((prev) => Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount))) + } + + const scrollLeft = () => scrollByStep(-1) + const scrollRight = () => scrollByStep(1) + + const canScrollLeft = scrollPosition > 0 + const canScrollRight = scrollPosition < maxScroll + + useEffect(() => { + const element = containerRef.current + if (!element) return + + const computeMaxScroll = (width: number) => { + const colsLocal = resolveColumnsForWidth(width) + const columnWidth = (width - (colsLocal - 1) * gap) / colsLocal + const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap + const maxScrollValue = Math.max(0, totalWidth - width) + setMaxScroll(maxScrollValue) + } + + // Initial calculation + computeMaxScroll(element.getBoundingClientRect().width) + + if (typeof ResizeObserver !== 'undefined') { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + computeMaxScroll(entry.contentRect.width) + } + }) + resizeObserver.observe(element) + return () => resizeObserver.disconnect() + } else { + const handleResize = () => computeMaxScroll(element.getBoundingClientRect().width) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + } + }, [childrenArray.length, gap, columns]) + + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + if (containerRef.current && containerRef.current.contains(e.target as Node)) { + if (e.deltaX !== 0) { + e.preventDefault() + + const scrollAmount = Math.abs(e.deltaX) * 2 + const direction = e.deltaX > 0 ? 1 : -1 + + setScrollPosition((prev) => { + const newPosition = prev + scrollAmount * direction + return Math.max(0, Math.min(maxScroll, newPosition)) + }) + } + } + } + + const container = containerRef.current + if (container) { + container.addEventListener('wheel', handleWheel, { passive: false }) + return () => container.removeEventListener('wheel', handleWheel) + } + }, [maxScroll]) + + useEffect(() => { + setScrollPosition((prev) => Math.min(prev, maxScroll)) + }, [maxScroll]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (containerRef.current && document.activeElement === containerRef.current) { + if (e.key === 'ArrowLeft' && canScrollLeft) { + e.preventDefault() + scrollLeft() + } else if (e.key === 'ArrowRight' && canScrollRight) { + e.preventDefault() + scrollRight() + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [canScrollLeft, canScrollRight]) + + return ( +
+ {showArrows && canScrollLeft && ( + + )} + + {showArrows && canScrollRight && ( + + )} + +
+
+ {childrenArray.map((child, index) => ( +
+ {child} +
+ ))} +
+
+
+ ) +}) + +Row.displayName = 'Row'