Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions apps/studio/components/interfaces/App/AppBannerWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@ import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag } from 'common'
import dayjs from 'dayjs'
import { usePathname } from 'next/navigation'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import {
SELECT_26_STUDIO_DISMISSAL_KEY,
useSelect26PromotionActive,
} from 'ui-patterns/Banners/Select26Promotion'

import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
import { isLogsOrObservabilityPath } from './AppBannerWrapper.utils'
import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner'
import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner'
import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner'
import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation'
import { BannerSelect2026 } from '@/components/ui/BannerStack/Banners/BannerSelect2026'
import {
SELECT_26_BANNER_PRIORITY,
shouldShowSelect26Banner,
} from '@/components/ui/BannerStack/Banners/BannerSelect2026.utils'
import { BannerTOSUpdate } from '@/components/ui/BannerStack/Banners/BannerTOSUpdate'
import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
Expand Down Expand Up @@ -36,6 +45,38 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
false
)

const [isSelect26BannerDismissed, , { isSuccess: isSelect26DismissalLoaded }] =
useLocalStorageQuery(SELECT_26_STUDIO_DISMISSAL_KEY, false)
const isSelect26PromotionActive = useSelect26PromotionActive()

useEffect(() => {
if (!isSelect26DismissalLoaded) return

const shouldShow = shouldShowSelect26Banner({
isPlatform: IS_PLATFORM,
dismissalLoaded: isSelect26DismissalLoaded,
isActive: isSelect26PromotionActive,
isDismissed: isSelect26BannerDismissed,
})

if (shouldShow) {
addBanner({
id: BANNER_ID.SELECT_26,
isDismissed: false,
content: <BannerSelect2026 />,
priority: SELECT_26_BANNER_PRIORITY,
})
} else {
dismissBanner(BANNER_ID.SELECT_26)
}
}, [
isSelect26DismissalLoaded,
isSelect26PromotionActive,
isSelect26BannerDismissed,
addBanner,
dismissBanner,
])

useEffect(() => {
if (Date.now() >= TOSUpdateExpiry.getTime()) return

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react'
import { useParams } from 'common'
import { ArrowRight, Loader2, Square, X, type LucideIcon } from 'lucide-react'
import { useParams, useReducedMotion } from 'common'
import { useMemo } from 'react'
import { cn } from 'ui'

import { getStatusName } from '../Pipeline.utils'
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import {
EdgeVisualChip,
getEdgeVisual,
type ReplicationState,
} from '@/components/ui/ReactFlow/EdgeVisual'
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
import {
Expand All @@ -19,67 +22,6 @@ type EdgeData = {
shiftEdgeEnd: boolean
}

interface ReplicationState {
isComingUp: boolean
isReplicating: boolean
isFailed: boolean
}

interface EdgeVisual {
Icon: LucideIcon
// CSS color shared by the icon and the connecting line so they always match.
color: string
opacity: number
dashArray: string
shouldAnimate: boolean
shouldSpin?: boolean
isFilled?: boolean
strokeWidth?: number
}

// Picks the icon + line appearance for a replication state. Both the icon and the line are derived
// here from the same state so they always stay in sync. We deliberately don't surface lag: the line
// just communicates whether data is moving, stopped, starting, or broken.
const getEdgeVisual = ({ isComingUp, isReplicating, isFailed }: ReplicationState): EdgeVisual => {
if (isFailed) {
return {
Icon: X,
color: 'hsl(var(--destructive-default))',
opacity: 1,
dashArray: '5 5',
shouldAnimate: false,
strokeWidth: 4,
}
}
if (isComingUp) {
return {
Icon: Loader2,
color: 'var(--foreground-light)',
opacity: 1,
dashArray: '5',
shouldAnimate: true,
shouldSpin: true,
}
}
if (isReplicating) {
return {
Icon: ArrowRight,
color: 'hsl(var(--brand-default))',
opacity: 1,
dashArray: '5',
shouldAnimate: true,
}
}
return {
Icon: Square,
color: 'var(--foreground-lighter)',
opacity: 0.5,
dashArray: '5 5',
shouldAnimate: false,
isFilled: true,
}
}

export const SmoothstepEdge = ({
sourceX,
sourceY,
Expand All @@ -92,6 +34,7 @@ export const SmoothstepEdge = ({
data,
}: EdgeProps) => {
const { ref: projectRef = 'default' } = useParams()
const prefersReducedMotion = useReducedMotion()
const { identifier, shiftEdgeEnd } = (data || {}) as EdgeData

const { data: pipelinesData } = useReplicationPipelinesQuery({ projectRef })
Expand Down Expand Up @@ -126,16 +69,7 @@ export const SmoothstepEdge = ({
targetPosition,
})

const {
Icon,
color,
opacity,
dashArray,
shouldAnimate,
shouldSpin,
isFilled,
strokeWidth = 2,
} = getEdgeVisual(replicationState)
const visual = getEdgeVisual(replicationState)

return (
<>
Expand All @@ -144,11 +78,14 @@ export const SmoothstepEdge = ({
markerEnd={markerEnd}
style={{
...style,
stroke: color,
strokeWidth,
opacity,
strokeDasharray: dashArray,
animation: shouldAnimate ? 'dashdraw 0.5s linear infinite' : undefined,
stroke: visual.color,
strokeWidth: visual.strokeWidth,
opacity: visual.opacity,
strokeDasharray: visual.dashArray,
animation:
visual.shouldAnimate && !prefersReducedMotion
? 'dashdraw 0.5s linear infinite'
: undefined,
}}
/>
<EdgeLabelRenderer>
Expand All @@ -160,18 +97,7 @@ export const SmoothstepEdge = ({
}}
className="nodrag nopan"
>
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center border bg-surface-100'
)}
style={{ borderColor: color }}
>
<Icon
size={14}
className={cn(shouldSpin && 'animate-spin')}
style={{ color, fill: isFilled ? color : undefined }}
/>
</div>
<EdgeVisualChip visual={visual} />
</div>
</EdgeLabelRenderer>
</>
Expand Down
59 changes: 57 additions & 2 deletions apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { acceptUntrustedSql } from '@supabase/pg-meta'
import { useQueryClient } from '@tanstack/react-query'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import {
Check,
Expand Down Expand Up @@ -59,7 +60,10 @@ import { type QueryEditorHandle } from './QueryEditor'
import { createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useContentDeleteMutation } from '@/data/content/content-delete-mutation'
import { hasDiscardableChanges } from '@/data/content/notebooks/notebook-cache'
import {
evictNotebookFromCaches,
hasDiscardableChanges,
} from '@/data/content/notebooks/notebook-cache'
import {
isQueryCell,
WritableCell,
Expand All @@ -80,6 +84,7 @@ export const ExplorerNotebookTab = () => {
const { id, ref } = useParams()
const tabs = useTabsStateSnapshot()
const snap = useNotebooksStateSnapshot()
const queryClient = useQueryClient()
const { createChat, isCreating } = useCreateChat()

const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
Expand All @@ -96,6 +101,7 @@ export const ExplorerNotebookTab = () => {
const [isRunningNotebook, setIsRunningNotebook] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isSaveBeforeAnalyzeOpen, setIsSaveBeforeAnalyzeOpen] = useState(false)
const [isSaveConflictOpen, setIsSaveConflictOpen] = useState(false)
const [pendingMutationCells, setPendingMutationCells] = useState<
{ id: string; title: string }[] | null
>(null)
Expand Down Expand Up @@ -199,7 +205,7 @@ export const ExplorerNotebookTab = () => {
runNotebook({ cellIdsToRun, force: true })
}

const handleSaveNotebook = () => {
const persistNotebook = () => {
const notebookId = currentNotebook?.notebook.id
if (!ref || !notebookId || !name || !content) return

Expand Down Expand Up @@ -231,6 +237,10 @@ export const ExplorerNotebookTab = () => {
}),
}

if (snap.serverDivergedWhileDirty.get(notebookId) === 'deleted') {
writableContent.cells = writableContent.cells.map(({ _id: _, ...cell }) => cell)
}

// [Joshen] For tracking if a notebook is updated while being saved, so that we do not
// incorrectly show the saved toast if it's subsequently then saved once again while
// the initial save is midflight
Expand All @@ -245,6 +255,32 @@ export const ExplorerNotebookTab = () => {
})
}

const handleSaveNotebook = () => {
const notebookId = currentNotebook?.notebook.id
if (notebookId && snap.serverDivergedWhileDirty.get(notebookId)) {
setIsSaveConflictOpen(true)
return
}

persistNotebook()
}

const handleSaveAnyway = () => {
setIsSaveConflictOpen(false)
persistNotebook()
}

const handleDiscardNotebookChanges = async () => {
if (!ref || !id) return

const wasDeletedOnServer = snap.serverDivergedWhileDirty.get(id) === 'deleted'
setIsSaveConflictOpen(false)
const evicted = await evictNotebookFromCaches({ queryClient, projectRef: ref, id })
if (wasDeletedOnServer && evicted) {
tabs.removeTab(createTabId('notebook', { id }))
}
}

const handleAnalyze = () => {
createChat({
name: `Analyze ${name} notebook`,
Expand Down Expand Up @@ -463,6 +499,25 @@ export const ExplorerNotebookTab = () => {
</p>
</ConfirmationModal>

<ConfirmationModal
size="small"
visible={isSaveConflictOpen}
title="Assistant changes detected"
additionalActionLabel="Discard changes"
confirmLabel={
id && snap.serverDivergedWhileDirty.get(id) === 'deleted' ? 'Recreate' : 'Save anyway'
}
onAdditionalAction={handleDiscardNotebookChanges}
onCancel={() => setIsSaveConflictOpen(false)}
onConfirm={handleSaveAnyway}
>
<p className="text-sm">
{id && snap.serverDivergedWhileDirty.get(id) === 'deleted'
? 'An assistant deleted this notebook after your local changes. Saving will recreate it.'
: "An assistant updated this notebook after your local changes. Saving will overwrite the assistant's update."}
</p>
</ConfirmationModal>

<ConfirmationModal
size="small"
visible={pendingMutationCells !== null}
Expand Down
Loading
Loading