diff --git a/ee/tabby-ui/app/(dashboard)/settings/subscription/components/license-form.tsx b/ee/tabby-ui/app/(dashboard)/settings/subscription/components/license-form.tsx index f71ce59fdc8b..627d5bb74538 100644 --- a/ee/tabby-ui/app/(dashboard)/settings/subscription/components/license-form.tsx +++ b/ee/tabby-ui/app/(dashboard)/settings/subscription/components/license-form.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import * as z from 'zod' import { graphql } from '@/lib/gql/generates' +import { useDebounceCallback } from '@/lib/hooks/use-debounce' import { useMutation } from '@/lib/tabby/gql' import { cn } from '@/lib/utils' import { @@ -64,9 +65,34 @@ export function LicenseForm({ resolver: zodResolver(formSchema) }) const license = form.watch('license') - const { isSubmitting } = form.formState - const [isReseting, setIsDeleting] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) const [resetDialogOpen, setResetDialogOpen] = React.useState(false) + const [isResetting, setIsResetting] = React.useState(false) + + const toggleSubmitting = useDebounceCallback( + (value: boolean, success?: boolean) => { + setIsSubmitting(value) + if (success) { + form.reset({ license: '' }) + toast.success('License is uploaded') + onSuccess?.() + } + }, + 500, + { leading: true } + ) + + const toggleResetting = useDebounceCallback( + (value: boolean, success?: boolean) => { + setIsResetting(value) + if (success) { + setResetDialogOpen(false) + onSuccess?.() + } + }, + 500, + { leading: true } + ) const uploadLicense = useMutation(uploadLicenseMutation, { form @@ -75,34 +101,27 @@ export function LicenseForm({ const resetLicense = useMutation(resetLicenseMutation) const onSubmit = (values: FormValues) => { + toggleSubmitting.run(true) return uploadLicense(values).then(res => { - if (res?.data?.uploadLicense) { - form.reset({ license: '' }) - toast.success('License is uploaded') - onSuccess?.() - } + toggleSubmitting.run(false, res?.data?.uploadLicense) }) } const onReset: React.MouseEventHandler = e => { e.preventDefault() - setIsDeleting(true) - resetLicense() - .then(res => { - if (res?.data?.resetLicense) { - setResetDialogOpen(false) - onSuccess?.() - } else if (res?.error) { - toast.error(res.error.message ?? 'reset failed') - } - }) - .finally(() => { - setIsDeleting(false) - }) + toggleResetting.run(true) + resetLicense().then(res => { + const isSuccess = res?.data?.resetLicense + toggleResetting.run(false, isSuccess) + + if (res?.error) { + toast.error(res.error.message ?? 'reset failed') + } + }) } const onResetDialogOpenChange = (v: boolean) => { - if (isReseting) return + if (isResetting) return setResetDialogOpen(v) } @@ -126,6 +145,7 @@ export function LicenseForm({ )} /> +
- {isReseting && ( + {isResetting && ( )} Yes, reset it @@ -169,7 +189,6 @@ export function LicenseForm({
- ) diff --git a/ee/tabby-ui/app/files/components/file-directory-panel.tsx b/ee/tabby-ui/app/files/components/file-directory-panel.tsx index 80bfce9fc5c2..0d0f01d4a6cb 100644 --- a/ee/tabby-ui/app/files/components/file-directory-panel.tsx +++ b/ee/tabby-ui/app/files/components/file-directory-panel.tsx @@ -1,7 +1,7 @@ import React from 'react' import { find, omit } from 'lodash-es' -import { useDebounce } from '@/lib/hooks/use-debounce' +import { useDebounceValue } from '@/lib/hooks/use-debounce' import { cn } from '@/lib/utils' import { IconDirectorySolid, IconFile } from '@/components/ui/icons' import { Skeleton } from '@/components/ui/skeleton' @@ -28,7 +28,7 @@ const DirectoryPanel: React.FC = ({ return getCurrentDirFromTree(fileTreeData, activePath) }, [fileTreeData, activePath]) - const loading = useDebounce(propsLoading, 300) + const [loading] = useDebounceValue(propsLoading, 300) const showParentEntry = currentFileRoutes?.length > 0 diff --git a/ee/tabby-ui/app/files/components/file-tree.tsx b/ee/tabby-ui/app/files/components/file-tree.tsx index a65cbf33ae4d..eeb9f9d595e9 100644 --- a/ee/tabby-ui/app/files/components/file-tree.tsx +++ b/ee/tabby-ui/app/files/components/file-tree.tsx @@ -4,7 +4,7 @@ import React from 'react' import { SWRResponse } from 'swr' import useSWRImmutable from 'swr/immutable' -import { useDebounce } from '@/lib/hooks/use-debounce' +import { useDebounceValue } from '@/lib/hooks/use-debounce' import fetcher from '@/lib/tabby/fetcher' import type { ResolveEntriesResponse, TFile } from '@/lib/types' import { cn } from '@/lib/utils' @@ -225,7 +225,7 @@ const DirectoryTreeNode: React.FC = ({ !fileMap?.[node.fullPath]?.treeExpanded && expanded - const { data, isValidating }: SWRResponse = + const { data, isLoading }: SWRResponse = useSWRImmutable( shouldFetchChildren ? `/repositories/${repositoryName}/resolve/${basename}` @@ -262,7 +262,7 @@ const DirectoryTreeNode: React.FC = ({ onSelectTreeNode?.(node) } - const loading = useDebounce(isValidating, 100) + const [loading] = useDebounceValue(isLoading, 300) const existingChildren = !!node?.children?.length diff --git a/ee/tabby-ui/lib/hooks/use-debounce.ts b/ee/tabby-ui/lib/hooks/use-debounce.ts new file mode 100644 index 000000000000..180711764893 --- /dev/null +++ b/ee/tabby-ui/lib/hooks/use-debounce.ts @@ -0,0 +1,62 @@ +import React from 'react' +import { debounce, type DebounceSettings } from 'lodash-es' + +import { useLatest } from './use-latest' +import { useUnmount } from './use-unmount' + +type noop = (...args: any[]) => any + +// interface UseDebounceOptions extends DebounceSettings { +// onFire?: (value: T) => void +// } + +function useDebounceCallback( + fn: T, + wait: number, + options?: DebounceSettings +) { + const fnRef = useLatest(fn) + const debounced = React.useMemo( + () => + debounce( + (...args: Parameters): ReturnType => { + return fnRef.current(...args) + }, + wait, + options + ), + [] + ) + + useUnmount(() => debounced.cancel()) + + return { + run: debounced, + cancel: debounced.cancel, + flush: debounced.flush + } +} + +function useDebounceValue( + value: T, + wait: number, + options?: DebounceSettings +): [T, React.Dispatch>] { + const [debouncedValue, setDebouncedValue] = React.useState(value) + + const { run } = useDebounceCallback( + () => { + setDebouncedValue(value) + }, + wait, + options + ) + + React.useEffect(() => { + run() + }, [value]) + + return [debouncedValue, setDebouncedValue] +} + +export { useDebounceCallback, useDebounceValue } diff --git a/ee/tabby-ui/lib/hooks/use-debounce.tsx b/ee/tabby-ui/lib/hooks/use-debounce.tsx deleted file mode 100644 index 51a7ea9e032e..000000000000 --- a/ee/tabby-ui/lib/hooks/use-debounce.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' - -export function useDebounce(value: T, delay: number) { - const [debouncedValue, setDebouncedValue] = React.useState(value) - - React.useEffect(() => { - const handler = setTimeout(() => { - setDebouncedValue(value) - }, delay) - - return () => clearTimeout(handler) - }, [value, delay]) - - return debouncedValue -} diff --git a/ee/tabby-ui/lib/hooks/use-latest.ts b/ee/tabby-ui/lib/hooks/use-latest.ts new file mode 100644 index 000000000000..dd16138c97e6 --- /dev/null +++ b/ee/tabby-ui/lib/hooks/use-latest.ts @@ -0,0 +1,10 @@ +import React from 'react' + +function useLatest(value: T) { + const ref = React.useRef(value) + ref.current = value + + return ref +} + +export { useLatest } diff --git a/ee/tabby-ui/lib/hooks/use-unmount.ts b/ee/tabby-ui/lib/hooks/use-unmount.ts new file mode 100644 index 000000000000..d376e37d6b8c --- /dev/null +++ b/ee/tabby-ui/lib/hooks/use-unmount.ts @@ -0,0 +1,16 @@ +import React from 'react' + +import { useLatest } from './use-latest' + +const useUnmount = (fn: () => void) => { + const fnRef = useLatest(fn) + + React.useEffect( + () => () => { + fnRef.current() + }, + [] + ) +} + +export { useUnmount }