diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 67b63bbe92..10def09438 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -103,8 +103,9 @@ "hiddenTitle": true, "titleBarStyle": "Overlay", "minHeight": 700, - "minWidth": 1000 + "minWidth": 1000, + "fileDropEnabled": false } ] } -} \ No newline at end of file +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 75750a765d..cbd59fd812 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -32,6 +32,7 @@ import { polling } from '../../../client/src/utils/requestUtils'; import ReportBugModal from '../../../client/src/components/ReportBugModal'; import { UIContext } from '../../../client/src/context/uiContext'; import { DeviceContextProvider } from '../../../client/src/context/providers/DeviceContextProvider'; +import { EnvContext } from '../../../client/src/context/envContext'; import TextSearch from './TextSearch'; import SplashScreen from './SplashScreen'; @@ -226,12 +227,18 @@ function App() { isRepoManagementAllowed: true, forceAnalytics: false, isSelfServe: false, - envConfig, - setEnvConfig, showNativeMessage: message, relaunch, }), - [homeDirectory, indexFolder, os, release, envConfig], + [homeDirectory, indexFolder, os, release], + ); + + const envContextValue = useMemo( + () => ({ + envConfig, + setEnvConfig, + }), + [envConfig], ); const bugReportContextValue = useMemo( @@ -244,26 +251,32 @@ function App() { ); return ( - - - {shouldShowSplashScreen && } - - {shouldShowSplashScreen && ( - - - - - - )} - -
- - {!shouldShowSplashScreen && ( - + + + + + {shouldShowSplashScreen && } + + {shouldShowSplashScreen && ( + + + )} - -
-
+ +
+ + {!shouldShowSplashScreen && } + +
+ + + ); } diff --git a/apps/desktop/src/SplashScreen.tsx b/apps/desktop/src/SplashScreen.tsx index 4ba612fd6d..705d26fc12 100644 --- a/apps/desktop/src/SplashScreen.tsx +++ b/apps/desktop/src/SplashScreen.tsx @@ -26,7 +26,7 @@ const SplashScreen = ({}: Props) => {
-
+
Loading...
diff --git a/client/public/bloopHeadMascot.png b/client/public/bloopHeadMascot.png index 84c7bd3863..d68483d28a 100644 Binary files a/client/public/bloopHeadMascot.png and b/client/public/bloopHeadMascot.png differ diff --git a/client/public/bloopHeadMascotLight.png b/client/public/bloopHeadMascotLight.png new file mode 100644 index 0000000000..036e42b1fb Binary files /dev/null and b/client/public/bloopHeadMascotLight.png differ diff --git a/client/public/stripe_logo.png b/client/public/stripe_logo.png new file mode 100644 index 0000000000..b7ce865a4d Binary files /dev/null and b/client/public/stripe_logo.png differ diff --git a/client/src/App.tsx b/client/src/App.tsx index e4dc87e09f..e72fdc565d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,409 +1,63 @@ -import React, { - useCallback, - useEffect, - useMemo, - useState, - useTransition, -} from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import * as Sentry from '@sentry/react'; -import { DeviceContextType } from './context/deviceContext'; -import './index.css'; -import RepoTab from './pages/RepoTab'; -import { TabsContext } from './context/tabsContext'; -import { - NavigationItem, - RepoProvider, - RepoTabType, - RepoType, - StudioTabType, - TabType, - UITabType, -} from './types/general'; -import { - LAST_ACTIVE_TAB_KEY, - saveJsonToStorage, - savePlainToStorage, - TABS_KEY, -} from './services/storage'; -import { getRepos, initApi } from './services/api'; -import { useComponentWillMount } from './hooks/useComponentWillMount'; -import { RepoSource } from './types'; -import { RepositoriesContext } from './context/repositoriesContext'; +import React, { memo } from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { Toaster } from 'sonner'; import { AnalyticsContextProvider } from './context/providers/AnalyticsContextProvider'; -import { buildURLPart, getNavItemFromURL } from './utils/navigationUtils'; -import { DeviceContextProvider } from './context/providers/DeviceContextProvider'; -import useKeyboardNavigation from './hooks/useKeyboardNavigation'; -import StudioTab from './pages/StudioTab'; -import HomeTab from './pages/HomeTab'; -import Settings from './components/Settings'; -import ReportBugModal from './components/ReportBugModal'; -import { GeneralUiContextProvider } from './context/providers/GeneralUiContextProvider'; -import PromptGuidePopup from './components/PromptGuidePopup'; -import Onboarding from './pages/Onboarding'; -import NavBar from './components/NavBar'; -import StatusBar from './components/StatusBar'; -import CloudFeaturePopup from './components/CloudFeaturePopup'; -import ErrorFallback from './components/ErrorFallback'; import { PersonalQuotaContextProvider } from './context/providers/PersonalQuotaContextProvider'; -import UpgradePopup from './components/UpgradePopup'; -import StudioGuidePopup from './components/StudioGuidePopup'; -import WaitingUpgradePopup from './components/UpgradePopup/WaitingUpgradePopup'; -import { polling } from './utils/requestUtils'; - -type Props = { - deviceContextValue: DeviceContextType; +import ReportBugModal from './components/ReportBugModal'; +import Onboarding from './Onboarding'; +import Project from './Project'; +import CommandBar from './CommandBar'; +import ProjectContextProvider from './context/providers/ProjectContextProvider'; +import CommandBarContextProvider from './context/providers/CommandBarContextProvider'; +import { UIContextProvider } from './context/providers/UIContextProvider'; +import Settings from './Settings'; +import ProjectSettings from './ProjectSettings'; +import TabsContextProvider from './context/providers/TabsContextProvider'; +import { FileHighlightsContextProvider } from './context/providers/FileHighlightsContextProvider'; + +const toastOptions = { + unStyled: true, + classNames: { + toast: + 'w-[20.75rem] p-4 pl-5 flex items-start gap-3 rounded-md border border-bg-border bg-bg-base shadow-high', + error: 'text-red', + info: 'text-label-title', + title: 'body-s-b', + description: '!text-label-muted body-s mt-1.5', + actionButton: 'bg-zinc-400', + cancelButton: 'bg-orange-400', + closeButton: + '!bg-bg-base !text-label-muted !border-none !left-[unset] !right-2 !top-6 !w-6 !h-6', + }, }; -function App({ deviceContextValue }: Props) { - useComponentWillMount(() => - initApi(deviceContextValue.apiUrl, deviceContextValue.isSelfServe), - ); - - const [tabs, setTabs] = useState([ - { - key: 'initial', - name: 'Home', - type: TabType.HOME, - }, - ]); - const location = useLocation(); - const [activeTab, setActiveTab] = useState('initial'); - const [repositories, setRepositories] = useState(); - const [isLoading, setLoading] = useState(true); - const navigate = useNavigate(); - const [isTransitioning, startTransition] = useTransition(); - - useEffect(() => { - if (isLoading) { - return; - } - const tab = tabs.find((t) => t.key === activeTab); - if (tab && tab.type === TabType.HOME) { - navigate('/'); - return; - } else if (tab && tab.type === TabType.REPO) { - const lastNav = tab.navigationHistory[tab.navigationHistory.length - 1]; - navigate( - `/${encodeURIComponent(tab.repoRef)}/${encodeURIComponent( - tab.branch || 'all', - )}/${lastNav ? buildURLPart(lastNav) : ''}`, - ); - } else if (tab && tab.type === TabType.STUDIO) { - navigate( - `/studio/${encodeURIComponent(tab.key)}/${encodeURIComponent( - tab.name, - )}`, - ); - } - }, [activeTab, tabs]); - - const handleAddRepoTab = useCallback( - ( - repoRef: string, - repoName: string, - name: string, - source: RepoSource, - branch?: string | null, - navHistory?: NavigationItem[], - ) => { - const newTab = { - key: repoRef + '#' + Date.now(), - name, - repoName, - repoRef, - source, - branch, - navigationHistory: navHistory || [], - type: TabType.REPO, - }; - setTabs((prev) => [...prev, newTab]); - setActiveTab(newTab.key); - }, - [], - ); - - const handleAddStudioTab = useCallback((name: string, id: string) => { - const newTab: StudioTabType = { - key: id.toString(), - name, - type: TabType.STUDIO, - }; - setTabs((prev: UITabType[]) => { - const existing = prev.find((t) => t.key === newTab.key); - if (existing) { - setActiveTab(existing.key); - return prev; - } - return [...prev, newTab]; - }); - setActiveTab(newTab.key); - }, []); - - useEffect(() => { - if (location.pathname === '/') { - setLoading(false); - return; - } - if (isLoading && repositories?.length) { - const firstPart = decodeURIComponent( - location.pathname.slice(1).split('/')[0], - ); - const repo = repositories.find((r) => r.ref === firstPart); - if (firstPart === 'studio') { - handleAddStudioTab( - decodeURIComponent(location.pathname.slice(1).split('/')[2]), - decodeURIComponent(location.pathname.slice(1).split('/')[1]), - ); - } else if (repo) { - const urlBranch = decodeURIComponent(location.pathname.split('/')[2]); - handleAddRepoTab( - repo.ref, - repo.provider === RepoProvider.GitHub ? repo.ref : repo.name, - repo.name, - repo.provider === RepoProvider.GitHub - ? RepoSource.GH - : RepoSource.LOCAL, - urlBranch === 'all' ? null : urlBranch, - getNavItemFromURL( - location, - repo.provider === RepoProvider.GitHub ? repo.ref : repo.name, - ), - ); - } - setLoading(false); - } - }, [repositories, isLoading]); - - useEffect(() => { - saveJsonToStorage(TABS_KEY, tabs); - }, [tabs]); - - useEffect(() => { - savePlainToStorage(LAST_ACTIVE_TAB_KEY, activeTab); - }, [activeTab]); - - useEffect(() => { - if (!tabs.find((t) => t.key === activeTab)) { - setActiveTab('initial'); - } - }, [activeTab, tabs]); - - const handleRemoveTab = useCallback( - (tabKey: string) => { - setActiveTab((prev) => { - const prevIndex = tabs.findIndex((t) => t.key === prev); - if (tabKey === prev) { - return prevIndex > 0 - ? tabs[prevIndex - 1].key - : tabs[prevIndex + 1].key; - } - return prev; - }); - setTabs((prev) => prev.filter((t) => t.key !== tabKey)); - }, - [tabs], - ); - - const updateTabNavHistory = useCallback( - (tabKey: string, history: (prev: NavigationItem[]) => NavigationItem[]) => { - setTabs((prev) => { - const tabIndex = prev.findIndex((t) => t.key === tabKey); - if (tabIndex < 0 || prev[tabIndex].type !== TabType.REPO) { - return prev; - } - const newTab = { - ...prev[tabIndex], - navigationHistory: history( - (prev[tabIndex] as RepoTabType).navigationHistory, - ), - }; - const newTabs = [...prev]; - newTabs[tabIndex] = newTab; - return newTabs; - }); - }, - [], - ); - - const updateTabBranch = useCallback( - (tabKey: string, branch: null | string) => { - setTabs((prev) => { - const tabIndex = prev.findIndex((t) => t.key === tabKey); - if (tabIndex < 0) { - return prev; - } - const newTab = { - ...prev[tabIndex], - branch, - }; - const newTabs = [...prev]; - newTabs[tabIndex] = newTab; - return newTabs; - }); - }, - [], - ); - - const updateTabName = useCallback((tabKey: string, name: string) => { - setTabs((prev) => { - const tabIndex = prev.findIndex((t) => t.key === tabKey); - if (tabIndex < 0) { - return prev; - } - const newTab = { - ...prev[tabIndex], - name, - }; - const newTabs = [...prev]; - newTabs[tabIndex] = newTab; - return newTabs; - }); - }, []); - - const handleKeyEvent = useCallback( - (e: KeyboardEvent) => { - if (e.metaKey || e.ctrlKey) { - const num = Number(e.key); - if (Object.keys(tabs).includes((num - 1).toString())) { - const newTab = tabs[num - 1]?.key; - if (newTab) { - e.preventDefault(); - setActiveTab(newTab); - } - } else if (e.key === 'w' && activeTab !== 'initial') { - e.preventDefault(); - e.stopPropagation(); - handleRemoveTab(activeTab); - return true; - } - } - }, - [tabs, activeTab], - ); - useKeyboardNavigation(handleKeyEvent); - - const handleChangeActiveTab = useCallback((t: string) => { - startTransition(() => { - setActiveTab(t); - }); - }, []); - - const handleReorderTabs = useCallback((newTabs: UITabType[]) => { - setTabs((prev) => { - return [prev[0], ...newTabs]; - }); - }, []); - - const contextValue = useMemo( - () => ({ - tabs, - handleAddRepoTab, - handleAddStudioTab, - handleRemoveTab, - setActiveTab: handleChangeActiveTab, - updateTabNavHistory, - updateTabBranch, - updateTabName, - handleReorderTabs, - }), - [ - tabs, - handleAddRepoTab, - handleAddStudioTab, - handleRemoveTab, - updateTabNavHistory, - updateTabBranch, - updateTabName, - handleReorderTabs, - ], - ); - - const fetchRepos = useCallback(() => { - return getRepos().then((data) => { - const list = data?.list?.sort((a, b) => (a.name < b.name ? -1 : 1)) || []; - setRepositories((prev) => { - if (JSON.stringify(prev) === JSON.stringify(list)) { - return prev; - } - return list; - }); - }); - }, []); - - useEffect(() => { - const intervalId = polling(fetchRepos, 5000); - return () => { - clearInterval(intervalId); - }; - }, []); - - const reposContextValue = useMemo( - () => ({ - repositories, - setRepositories, - localSyncError: false, - githubSyncError: false, - fetchRepos, - }), - [repositories], - ); - +const App = () => { return ( - - - - - - - -
- {tabs.map((t) => - t.type === TabType.STUDIO ? ( - - ) : t.type === TabType.REPO ? ( - - ) : ( - - ), - )} + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + - + ); -} +}; -export default Sentry.withErrorBoundary(App, { - fallback: (props) => , -}); +export default memo(App); diff --git a/client/src/CloudApp.tsx b/client/src/CloudApp.tsx index ce7d460644..cbd39e4c47 100644 --- a/client/src/CloudApp.tsx +++ b/client/src/CloudApp.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { BrowserRouter } from 'react-router-dom'; import packageJson from '../package.json'; -import { getConfig } from './services/api'; import App from './App'; import { LocaleContext } from './context/localeContext'; import i18n from './i18n'; @@ -11,6 +10,8 @@ import { savePlainToStorage, } from './services/storage'; import { LocaleType } from './types/general'; +import { DeviceContextProvider } from './context/providers/DeviceContextProvider'; +import { EnvContext } from './context/envContext'; const CloudApp = () => { const [envConfig, setEnvConfig] = useState({}); @@ -38,9 +39,14 @@ const CloudApp = () => { isSelfServe: true, forceAnalytics: true, showNativeMessage: alert, + relaunch: () => {}, + }), + [], + ); + const envContextValue = useMemo( + () => ({ envConfig, setEnvConfig, - relaunch: () => {}, }), [envConfig], ); @@ -59,11 +65,18 @@ const CloudApp = () => { ); return ( - - - - - + + + + + + + + + ); }; diff --git a/client/src/CommandBar/Body/Item.tsx b/client/src/CommandBar/Body/Item.tsx new file mode 100644 index 0000000000..e7b2657bff --- /dev/null +++ b/client/src/CommandBar/Body/Item.tsx @@ -0,0 +1,153 @@ +import { + Dispatch, + memo, + ReactElement, + SetStateAction, + useCallback, + useContext, + useEffect, + useRef, +} from 'react'; +import { + CommandBarItemGeneralType, + CommandBarStepEnum, +} from '../../types/general'; +import useShortcuts from '../../hooks/useShortcuts'; +import useKeyboardNavigation from '../../hooks/useKeyboardNavigation'; +import { checkEventKeys } from '../../utils/keyboardUtils'; +import { CommandBarContext } from '../../context/commandBarContext'; +import { CheckmarkInSquareIcon } from '../../icons'; +import { + RECENT_COMMANDS_KEY, + updateArrayInStorage, +} from '../../services/storage'; + +type Props = CommandBarItemGeneralType & { + isFocused?: boolean; + i: number; + isFirst?: boolean; + isWithCheckmark?: boolean; + setFocusedIndex: Dispatch>; + customRightElement?: ReactElement; + focusedItemProps?: Record; + disableKeyNav?: boolean; + itemKey: string; +}; + +const CommandBarItem = ({ + isFocused, + Icon, + label, + shortcut, + i, + setFocusedIndex, + id, + footerBtns, + isFirst, + iconContainerClassName, + footerHint, + customRightElement, + onClick, + focusedItemProps, + disableKeyNav, + isWithCheckmark, + closeOnClick, + itemKey, +}: Props) => { + const ref = useRef(null); + const shortcutKeys = useShortcuts(shortcut); + const { setFocusedItem, setChosenStep, setIsVisible } = useContext( + CommandBarContext.Handlers, + ); + + useEffect(() => { + if (isFocused) { + setFocusedItem({ + footerHint, + footerBtns, + focusedItemProps, + }); + ref.current?.scrollIntoView({ block: 'nearest' }); + } + }, [isFocused, footerBtns, footerHint, focusedItemProps]); + + const handleMouseOver = useCallback(() => { + setFocusedIndex(i); + }, [i, setFocusedIndex]); + + const handleClick = useCallback(() => { + if (onClick) { + onClick(); + if (closeOnClick) { + setIsVisible(false); + } + } else { + setChosenStep({ id: id as CommandBarStepEnum }); + } + updateArrayInStorage(RECENT_COMMANDS_KEY, itemKey); + }, [id, onClick, closeOnClick, itemKey]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + const shortAction = footerBtns.find((b) => checkEventKeys(e, b.shortcut)); + if ( + (isFocused && shortAction && !shortAction.action) || + checkEventKeys(e, shortcut) + ) { + e.preventDefault(); + e.stopPropagation(); + handleClick(); + return; + } + if (isFocused && shortAction?.action) { + e.preventDefault(); + e.stopPropagation(); + shortAction.action(); + } + }, + [isFocused, shortcut, footerBtns, handleClick], + ); + useKeyboardNavigation(handleKeyEvent, disableKeyNav); + + return ( + + ); +}; + +export default memo(CommandBarItem); diff --git a/client/src/CommandBar/Body/Section.tsx b/client/src/CommandBar/Body/Section.tsx new file mode 100644 index 0000000000..4c2d189067 --- /dev/null +++ b/client/src/CommandBar/Body/Section.tsx @@ -0,0 +1,57 @@ +import { Dispatch, memo, SetStateAction } from 'react'; +import { + CommandBarItemCustomType, + CommandBarItemGeneralType, +} from '../../types/general'; +import SectionDivider from './SectionDivider'; +import Item from './Item'; + +type Props = { + title?: string; + items: (CommandBarItemCustomType | CommandBarItemGeneralType)[]; + focusedIndex: number; + setFocusedIndex: Dispatch>; + offset: number; + disableKeyNav?: boolean; +}; + +const CommandBarBodySection = ({ + title, + items, + setFocusedIndex, + offset, + focusedIndex, + disableKeyNav, +}: Props) => { + return ( +
+ {!!title && } + {items.map(({ key, ...Rest }, i) => + 'Component' in Rest ? ( + + ) : ( + + ), + )} +
+ ); +}; + +export default memo(CommandBarBodySection); diff --git a/client/src/CommandBar/Body/SectionDivider.tsx b/client/src/CommandBar/Body/SectionDivider.tsx new file mode 100644 index 0000000000..3c4e7f4dd5 --- /dev/null +++ b/client/src/CommandBar/Body/SectionDivider.tsx @@ -0,0 +1,15 @@ +import { memo } from 'react'; + +type Props = { + text: string; +}; + +const SectionDivider = ({ text }: Props) => { + return ( +
+ {text} +
+ ); +}; + +export default memo(SectionDivider); diff --git a/client/src/CommandBar/Body/index.tsx b/client/src/CommandBar/Body/index.tsx new file mode 100644 index 0000000000..fbbebacd77 --- /dev/null +++ b/client/src/CommandBar/Body/index.tsx @@ -0,0 +1,60 @@ +import { memo, useCallback, useEffect, useState } from 'react'; +import { CommandBarSectionType } from '../../types/general'; +import useKeyboardNavigation from '../../hooks/useKeyboardNavigation'; +import Section from './Section'; + +type Props = { + sections: CommandBarSectionType[]; + disableKeyNav?: boolean; +}; + +const CommandBarBody = ({ sections, disableKeyNav }: Props) => { + const [focusedIndex, setFocusedIndex] = useState(0); + + useEffect(() => { + setFocusedIndex(0); + }, [sections]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev < + sections.reduce((prev, curr) => prev + curr.items.length, 0) - 1 + ? prev + 1 + : 0, + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev > 0 + ? prev - 1 + : sections.reduce((prev, curr) => prev + curr.items.length, 0) - 1, + ); + } + }, + [sections], + ); + useKeyboardNavigation(handleKeyEvent, disableKeyNav); + + return ( +
+ {sections.map((s) => ( +
+ ))} +
+ ); +}; + +export default memo(CommandBarBody); diff --git a/client/src/CommandBar/Footer/HintButton.tsx b/client/src/CommandBar/Footer/HintButton.tsx new file mode 100644 index 0000000000..e617e496a1 --- /dev/null +++ b/client/src/CommandBar/Footer/HintButton.tsx @@ -0,0 +1,33 @@ +import { ForwardedRef, forwardRef, memo } from 'react'; +import useShortcuts from '../../hooks/useShortcuts'; + +type Props = { + label: string; + shortcut?: string[]; +}; + +const HintButton = forwardRef( + ({ label, shortcut }: Props, ref: ForwardedRef) => { + const shortcutKeys = useShortcuts(shortcut); + return ( +
+ {label} + {shortcutKeys?.map((k) => ( +
+ {k} +
+ ))} +
+ ); + }, +); + +HintButton.displayName = 'HintButtonWithRef'; + +export default memo(HintButton); diff --git a/client/src/CommandBar/Footer/index.tsx b/client/src/CommandBar/Footer/index.tsx new file mode 100644 index 0000000000..9bfe07f541 --- /dev/null +++ b/client/src/CommandBar/Footer/index.tsx @@ -0,0 +1,58 @@ +import { memo, useCallback, useContext, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CommandBarContext } from '../../context/commandBarContext'; +import Dropdown from '../../components/Dropdown'; +import useKeyboardNavigation from '../../hooks/useKeyboardNavigation'; +import HintButton from './HintButton'; + +type Props = { + ActionsDropdown?: (props: any) => JSX.Element | null; + actionsDropdownProps?: Record; + onDropdownVisibilityChange?: (isVisible: boolean) => void; +}; + +const CommandBarFooter = ({ + ActionsDropdown, + actionsDropdownProps, + onDropdownVisibilityChange, +}: Props) => { + const { t } = useTranslation(); + const { focusedItem } = useContext(CommandBarContext.FooterValues); + const actionsBtn = useRef(null); + + const handleKeyEvent = useCallback((e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + e.stopPropagation(); + actionsBtn.current?.click(); + } + }, []); + useKeyboardNavigation(handleKeyEvent, !ActionsDropdown); + + return ( +
+

+ {focusedItem?.footerHint} +

+ {focusedItem?.footerBtns?.map((b) => )} + {!!ActionsDropdown && ( + + + + )} +
+ ); +}; + +export default memo(CommandBarFooter); diff --git a/client/src/CommandBar/Header/ChipItem.tsx b/client/src/CommandBar/Header/ChipItem.tsx new file mode 100644 index 0000000000..93c8331b6a --- /dev/null +++ b/client/src/CommandBar/Header/ChipItem.tsx @@ -0,0 +1,13 @@ +import { memo } from 'react'; + +type Props = { text: string }; + +const CommandBarChipItem = ({ text }: Props) => { + return ( +
+ {text} +
+ ); +}; + +export default memo(CommandBarChipItem); diff --git a/client/src/CommandBar/Header/index.tsx b/client/src/CommandBar/Header/index.tsx new file mode 100644 index 0000000000..c8155fb13d --- /dev/null +++ b/client/src/CommandBar/Header/index.tsx @@ -0,0 +1,117 @@ +import { + ChangeEvent, + memo, + ReactElement, + useCallback, + useContext, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import Tooltip from '../../components/Tooltip'; +import useKeyboardNavigation from '../../hooks/useKeyboardNavigation'; +import { CommandBarContext } from '../../context/commandBarContext'; +import ChipItem from './ChipItem'; + +type PropsWithoutInput = { + noInput: true; + customSubmitHandler?: never; + onChange?: never; + value?: never; + placeholder?: never; +}; + +type PropsWithInput = { + noInput?: false; + value: string; + onChange: (e: ChangeEvent) => void; + customSubmitHandler?: (value: string) => void; + placeholder?: string; +}; + +type GeneralProps = { + handleBack?: () => void; + breadcrumbs?: string[]; + customRightComponent?: ReactElement; +}; + +type Props = GeneralProps & (PropsWithInput | PropsWithoutInput); + +const CommandBarHeader = ({ + handleBack, + breadcrumbs, + customRightComponent, + customSubmitHandler, + onChange, + value, + placeholder, +}: Props) => { + const { t } = useTranslation(); + const { isVisible } = useContext(CommandBarContext.General); + const { setIsVisible } = useContext(CommandBarContext.Handlers); + const [isComposing, setIsComposing] = useState(false); + + const onCompositionStart = useCallback(() => { + setIsComposing(true); + }, []); + + const onCompositionEnd = useCallback(() => { + // this event comes before keydown and sets state faster causing unintentional submit + setTimeout(() => setIsComposing(false), 10); + }, []); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + if (handleBack) { + handleBack(); + } else { + setIsVisible(false); + } + } else if (e.key === 'Enter' && customSubmitHandler && !isComposing) { + e.stopPropagation(); + e.preventDefault(); + customSubmitHandler(value); + } + }, + [setIsVisible, handleBack, customSubmitHandler, value, isComposing], + ); + useKeyboardNavigation(handleKeyEvent, !isVisible); + + return ( +
+
+
+ {!!handleBack && ( + + + + )} + {breadcrumbs?.map((b) => )} +
+ {customRightComponent} +
+ +
+ ); +}; + +export default memo(CommandBarHeader); diff --git a/client/src/CommandBar/index.tsx b/client/src/CommandBar/index.tsx new file mode 100644 index 0000000000..3f059a44e7 --- /dev/null +++ b/client/src/CommandBar/index.tsx @@ -0,0 +1,83 @@ +import { memo, useCallback, useContext } from 'react'; +import Modal from '../components/Modal'; +import useKeyboardNavigation from '../hooks/useKeyboardNavigation'; +import { CommandBarStepEnum } from '../types/general'; +import { CommandBarContext } from '../context/commandBarContext'; +import { useGlobalShortcuts } from '../hooks/useGlobalShortcuts'; +import { checkEventKeys } from '../utils/keyboardUtils'; +import Initial from './steps/Initial'; +import PrivateRepos from './steps/PrivateRepos'; +import PublicRepos from './steps/PublicRepos'; +import LocalRepos from './steps/LocalRepos'; +import Documentation from './steps/Documentation'; +import CreateProject from './steps/CreateProject'; +import ManageRepos from './steps/ManageRepos'; +import AddNewRepo from './steps/AddNewRepo'; +import ToggleTheme from './steps/ToggleTheme'; + +type Props = {}; + +const CommandBar = ({}: Props) => { + const { chosenStep } = useContext(CommandBarContext.CurrentStep); + const { isVisible } = useContext(CommandBarContext.General); + const { setChosenStep, setIsVisible } = useContext( + CommandBarContext.Handlers, + ); + const globalShortcuts = useGlobalShortcuts(); + + const handleClose = useCallback(() => { + setIsVisible(false); + setChosenStep({ + id: CommandBarStepEnum.INITIAL, + }); + }, []); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (checkEventKeys(e, ['cmd', 'K'])) { + e.stopPropagation(); + e.preventDefault(); + setIsVisible(true); + } + Object.values(globalShortcuts).forEach((s) => { + if (checkEventKeys(e, s.shortcut)) { + e.stopPropagation(); + e.preventDefault(); + s.action(); + } + }); + }, + [isVisible, globalShortcuts], + ); + useKeyboardNavigation(handleKeyEvent); + + return ( + + {chosenStep.id === CommandBarStepEnum.INITIAL ? ( + + ) : chosenStep.id === CommandBarStepEnum.PRIVATE_REPOS ? ( + + ) : chosenStep.id === CommandBarStepEnum.PUBLIC_REPOS ? ( + + ) : chosenStep.id === CommandBarStepEnum.LOCAL_REPOS ? ( + + ) : chosenStep.id === CommandBarStepEnum.DOCS ? ( + + ) : chosenStep.id === CommandBarStepEnum.CREATE_PROJECT ? ( + + ) : chosenStep.id === CommandBarStepEnum.MANAGE_REPOS ? ( + + ) : chosenStep.id === CommandBarStepEnum.ADD_NEW_REPO ? ( + + ) : chosenStep.id === CommandBarStepEnum.TOGGLE_THEME ? ( + + ) : null} + + ); +}; + +export default memo(CommandBar); diff --git a/client/src/CommandBar/steps/AddNewRepo.tsx b/client/src/CommandBar/steps/AddNewRepo.tsx new file mode 100644 index 0000000000..44da65fa61 --- /dev/null +++ b/client/src/CommandBar/steps/AddNewRepo.tsx @@ -0,0 +1,132 @@ +import { memo, useCallback, useContext, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { useGlobalShortcuts } from '../../hooks/useGlobalShortcuts'; +import { + CommandBarItemGeneralType, + CommandBarStepEnum, +} from '../../types/general'; +import { GlobeIcon, HardDriveIcon, RepositoryIcon } from '../../icons'; +import Header from '../Header'; +import Body from '../Body'; +import Footer from '../Footer'; +import { CommandBarContext } from '../../context/commandBarContext'; +import { DeviceContext } from '../../context/deviceContext'; +import { scanLocalRepos, syncRepo } from '../../services/api'; +import SpinLoaderContainer from '../../components/Loaders/SpinnerLoader'; + +type Props = {}; + +const AddNewRepo = ({}: Props) => { + const { t } = useTranslation(); + const globalShortcuts = useGlobalShortcuts(); + const { setChosenStep } = useContext(CommandBarContext.Handlers); + const { homeDir, chooseFolder } = useContext(DeviceContext); + + const handleBack = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.MANAGE_REPOS }); + }, []); + + const handleChooseFolder = useCallback(async () => { + let folder: string | string[] | null; + if (chooseFolder) { + try { + folder = await chooseFolder({ + directory: true, + defaultPath: homeDir, + }); + } catch (err) { + console.log(err); + } + } + // @ts-ignore + if (typeof folder === 'string') { + scanLocalRepos(folder).then((data) => { + if (data.list.length === 1) { + syncRepo(data.list[0].ref); + toast(t('Indexing repository'), { + description: ( + + repoName has + started indexing. You’ll receive a notification as soon as this + process completes. + + ), + icon: , + unstyled: true, + }); + handleBack(); + return; + } else if (!data.list.length) { + toast.error(t('Not a git repository'), { + description: t('The folder you selected is not a git repository.'), + icon: , + unstyled: true, + }); + } else if (data.list.length > 1) { + toast.error(t('Folder too large'), { + description: t( + 'The folder you selected has multiple git repositories nested inside.', + ), + icon: , + unstyled: true, + }); + } + }); + } + }, [chooseFolder, homeDir, handleBack]); + + const initialSections = useMemo(() => { + const contextItems: CommandBarItemGeneralType[] = [ + { + label: t('Private repository'), + Icon: RepositoryIcon, + id: CommandBarStepEnum.PRIVATE_REPOS, + key: 'private', + shortcut: globalShortcuts.openPrivateRepos.shortcut, + footerHint: '', + footerBtns: [{ label: t('Next'), shortcut: ['entr'] }], + }, + { + label: t('Public repository'), + Icon: GlobeIcon, + id: CommandBarStepEnum.PUBLIC_REPOS, + key: 'public', + shortcut: globalShortcuts.openPublicRepos.shortcut, + footerHint: '', + footerBtns: [{ label: t('Next'), shortcut: ['entr'] }], + }, + { + label: t('Local repository'), + Icon: HardDriveIcon, + id: CommandBarStepEnum.LOCAL_REPOS, + onClick: handleChooseFolder, + key: 'local', + shortcut: globalShortcuts.openLocalRepos.shortcut, + footerHint: '', + footerBtns: [{ label: t('Next'), shortcut: ['entr'] }], + }, + ]; + return [ + { + items: contextItems, + itemsOffset: 0, + key: 'context-items', + }, + ]; + }, [t, globalShortcuts]); + + return ( +
+
+ +
+
+ ); +}; + +export default memo(AddNewRepo); diff --git a/client/src/CommandBar/steps/CreateProject.tsx b/client/src/CommandBar/steps/CreateProject.tsx new file mode 100644 index 0000000000..549651c8e1 --- /dev/null +++ b/client/src/CommandBar/steps/CreateProject.tsx @@ -0,0 +1,81 @@ +import { + ChangeEvent, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import Header from '../Header'; +import Footer from '../Footer'; +import { CommandBarStepEnum } from '../../types/general'; +import { CommandBarContext } from '../../context/commandBarContext'; +import { createProject } from '../../services/api'; +import { ProjectContext } from '../../context/projectContext'; + +type Props = {}; + +const CreateProject = ({}: Props) => { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(''); + const { setChosenStep, setFocusedItem, setIsVisible } = useContext( + CommandBarContext.Handlers, + ); + const { setCurrentProjectId } = useContext(ProjectContext.Current); + const { refreshAllProjects } = useContext(ProjectContext.All); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + useEffect(() => { + setFocusedItem({ + footerHint: t('Provide a short, concise title for your project'), + footerBtns: [{ label: t('Create project'), shortcut: ['entr'] }], + }); + }, [t]); + + const switchProject = useCallback((id: string) => { + setCurrentProjectId(id); + setIsVisible(false); + refreshAllProjects(); + setChosenStep({ + id: CommandBarStepEnum.INITIAL, + }); + }, []); + + const breadcrumbs = useMemo(() => { + return [t('Create project')]; + }, [t]); + + const handleBack = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.INITIAL }); + }, []); + + const submitHandler = useCallback( + async (value: string) => { + setInputValue(''); + const newId = await createProject(value); + switchProject(newId); + }, + [switchProject], + ); + + return ( +
+
+
+
+ ); +}; + +export default memo(CreateProject); diff --git a/client/src/CommandBar/steps/Documentation.tsx b/client/src/CommandBar/steps/Documentation.tsx new file mode 100644 index 0000000000..ec797241a2 --- /dev/null +++ b/client/src/CommandBar/steps/Documentation.tsx @@ -0,0 +1,197 @@ +import { + ChangeEvent, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { CommandBarSectionType, CommandBarStepEnum } from '../../types/general'; +import { CommandBarContext } from '../../context/commandBarContext'; +import { getIndexedDocs, verifyDocsUrl } from '../../services/api'; +import { PlusSignIcon } from '../../icons'; +import { DocShortType } from '../../types/api'; +import Header from '../Header'; +import Body from '../Body'; +import Footer from '../Footer'; +import DocItem from './items/DocItem'; + +type Props = {}; + +const Documentation = ({}: Props) => { + const { t } = useTranslation(); + const [isAddMode, setIsAddMode] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + const [indexedDocs, setIndexedDocs] = useState([]); + const [addedDoc, setAddedDoc] = useState(''); + const { setChosenStep, setFocusedItem } = useContext( + CommandBarContext.Handlers, + ); + const [inputValue, setInputValue] = useState(''); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + const enterAddMode = useCallback(() => { + setFocusedItem({ + footerHint: t('Paste a link to any documentation web page'), + footerBtns: [{ label: t('Sync'), shortcut: ['entr'] }], + }); + setIsAddMode(true); + }, []); + + const addItem = useMemo(() => { + return { + itemsOffset: 0, + key: 'add-docs', + items: [ + { + label: 'Add documentation', + Icon: PlusSignIcon, + footerHint: t('Add any library documentation'), + footerBtns: [ + { + label: t('Add'), + shortcut: ['entr'], + }, + ], + key: 'add', + id: 'Add', + onClick: enterAddMode, + }, + ], + }; + }, []); + const [sections, setSections] = useState([addItem]); + + const breadcrumbs = useMemo(() => { + const arr = [t('Docs')]; + if (isAddMode) { + arr.push(t('Add docs')); + } + return arr; + }, [t, isAddMode]); + + const handleBack = useCallback(() => { + if (isAddMode && (addedDoc || indexedDocs.length)) { + setIsAddMode(false); + } else { + setChosenStep({ id: CommandBarStepEnum.INITIAL }); + } + }, [isAddMode, addedDoc, indexedDocs]); + + const refetchDocs = useCallback(() => { + getIndexedDocs().then((data) => { + setIndexedDocs(data); + setHasFetched(true); + }); + }, []); + + useEffect(() => { + const mapped = indexedDocs.map((d) => ({ + Component: DocItem, + componentProps: { doc: d, isIndexed: !!d.id, refetchDocs }, + key: d.id, + })); + if (!mapped.length && hasFetched && !addedDoc) { + enterAddMode(); + } + if (addedDoc) { + mapped.unshift({ + Component: DocItem, + componentProps: { + doc: { + url: addedDoc, + id: '', + name: '', + favicon: '', + index_status: 'indexing', + }, + isIndexed: false, + refetchDocs: () => { + refetchDocs(); + setAddedDoc(''); + }, + }, + key: addedDoc, + }); + } + console.log(mapped); + setSections([ + addItem, + { + itemsOffset: 1, + key: 'indexed-docs', + label: t('Indexed documentation web pages'), + items: mapped, + }, + ]); + }, [indexedDocs, addedDoc, hasFetched]); + + useEffect(() => { + if (!isAddMode || !hasFetched) { + refetchDocs(); + } + }, [isAddMode]); + + const handleAddSubmit = useCallback((inputValue: string) => { + setFocusedItem({ + footerHint: t('Verifying access...'), + footerBtns: [], + }); + setInputValue(''); + verifyDocsUrl(inputValue.trim()) + .then(() => { + setIsAddMode(false); + setAddedDoc(inputValue); + }) + .catch(() => { + setFocusedItem({ + footerHint: t( + "We couldn't find any docs at that link. Try again or make sure the link is correct!", + ), + footerBtns: [], + }); + }); + }, []); + + const sectionsToShow = useMemo(() => { + if (!inputValue) { + return sections; + } + const newSections: CommandBarSectionType[] = []; + sections.forEach((s) => { + const newItems = s.items.filter((i) => + ('label' in i ? i.label : i.componentProps.doc.name) + .toLowerCase() + .startsWith(inputValue.toLowerCase()), + ); + if (newItems.length) { + newSections.push({ ...s, items: newItems }); + } + }); + return newSections; + }, [inputValue, sections]); + + return ( +
+
+ {isAddMode ? null : } +
+
+ ); +}; + +export default memo(Documentation); diff --git a/client/src/CommandBar/steps/Initial.tsx b/client/src/CommandBar/steps/Initial.tsx new file mode 100644 index 0000000000..5860eaa36b --- /dev/null +++ b/client/src/CommandBar/steps/Initial.tsx @@ -0,0 +1,320 @@ +import { + ChangeEvent, + memo, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { ProjectContext } from '../../context/projectContext'; +import { + BugIcon, + CogIcon, + ColorSwitchIcon, + DocumentsIcon, + DoorOutIcon, + MagazineIcon, + PlusSignIcon, + RegexIcon, + RepositoryIcon, + WalletIcon, +} from '../../icons'; +import { CommandBarContext } from '../../context/commandBarContext'; +import Header from '../Header'; +import Body from '../Body'; +import Footer from '../Footer'; +import { + CommandBarItemGeneralType, + CommandBarSectionType, + CommandBarStepEnum, +} from '../../types/general'; +import { UIContext } from '../../context/uiContext'; +import { useGlobalShortcuts } from '../../hooks/useGlobalShortcuts'; +import { + getJsonFromStorage, + RECENT_COMMANDS_KEY, +} from '../../services/storage'; +import { bubbleUpRecentItems } from '../../utils/commandBarUtils'; + +type Props = {}; + +const InitialCommandBar = ({}: Props) => { + const { t } = useTranslation(); + const { setIsVisible } = useContext(CommandBarContext.Handlers); + const { tabItems, newTabItems } = useContext(CommandBarContext.FocusedTab); + const { projects } = useContext(ProjectContext.All); + const { setCurrentProjectId, project } = useContext(ProjectContext.Current); + const { theme } = useContext(UIContext.Theme); + const [inputValue, setInputValue] = useState(''); + const globalShortcuts = useGlobalShortcuts(); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + const switchProject = useCallback((id: string) => { + setCurrentProjectId(id); + setIsVisible(false); + }, []); + + const initialSections = useMemo(() => { + const recentKeys = getJsonFromStorage(RECENT_COMMANDS_KEY); + const contextItems: CommandBarItemGeneralType[] = [ + { + label: t('Manage repositories'), + Icon: RepositoryIcon, + id: CommandBarStepEnum.MANAGE_REPOS, + key: CommandBarStepEnum.MANAGE_REPOS, + shortcut: globalShortcuts.openManageRepos.shortcut, + footerHint: '', + footerBtns: [{ label: t('Manage'), shortcut: ['entr'] }], + }, + { + label: t('Add new repository'), + Icon: PlusSignIcon, + id: CommandBarStepEnum.ADD_NEW_REPO, + key: CommandBarStepEnum.ADD_NEW_REPO, + shortcut: ['cmd', 'A'], + footerHint: '', + footerBtns: [ + { + label: t('Add'), + shortcut: ['entr'], + }, + ], + }, + ]; + const projectItems: CommandBarItemGeneralType[] = projects + .map( + (p, i): CommandBarItemGeneralType => ({ + label: p.name, + Icon: MagazineIcon, + id: `project-${p.id}`, + key: `project-${p.id}`, + shortcut: i < 9 ? ['cmd', (i + 1).toString()] : undefined, + onClick: () => switchProject(p.id), + footerHint: + project?.id === p.id + ? t('Manage project') + : t(`Switch to`) + ' ' + p.name, + footerBtns: + project?.id === p.id + ? [{ label: t('Manage'), shortcut: ['entr'] }] + : [ + { + label: t('Open'), + shortcut: ['entr'], + }, + ], + }), + ) + .concat({ + label: t('New project'), + Icon: MagazineIcon, + id: CommandBarStepEnum.CREATE_PROJECT, + key: CommandBarStepEnum.CREATE_PROJECT, + shortcut: globalShortcuts.createNewProject.shortcut, + footerHint: t('Create new project'), + footerBtns: [ + { + label: t('Manage'), + shortcut: ['entr'], + }, + ], + }); + const themeItems: CommandBarItemGeneralType[] = [ + { + label: t(`Theme`), + Icon: ColorSwitchIcon, + id: CommandBarStepEnum.TOGGLE_THEME, + key: CommandBarStepEnum.TOGGLE_THEME, + shortcut: globalShortcuts.toggleTheme.shortcut, + footerHint: t(`Change application colour theme`), + footerBtns: [ + { + label: t('Select'), + shortcut: ['entr'], + }, + ], + }, + ]; + const otherCommands: CommandBarItemGeneralType[] = [ + { + label: t(`Account settings`), + Icon: CogIcon, + id: `account-settings`, + key: `account-settings`, + onClick: globalShortcuts.openSettings.action, + shortcut: globalShortcuts.openSettings.shortcut, + footerHint: t(`Open account settings`), + footerBtns: [ + { + label: t('Open'), + shortcut: ['entr'], + }, + ], + }, + { + label: t(`Subscription`), + Icon: WalletIcon, + id: `subscription-settings`, + key: `subscription-settings`, + onClick: globalShortcuts.openSubscriptionSettings.action, + shortcut: globalShortcuts.openSubscriptionSettings.shortcut, + footerHint: t(`Open subscription settings`), + footerBtns: [ + { + label: t('Open'), + shortcut: ['entr'], + }, + ], + }, + { + label: t(`Documentation`), + Icon: DocumentsIcon, + id: `app-docs`, + key: `app-docs`, + onClick: globalShortcuts.openAppDocs.action, + shortcut: globalShortcuts.openAppDocs.shortcut, + footerHint: t(`View bloop app documentation on our website`), + footerBtns: [ + { + label: t('Open'), + shortcut: ['entr'], + }, + ], + }, + { + label: t(`Report a bug`), + Icon: BugIcon, + id: `bug`, + key: `bug`, + onClick: globalShortcuts.reportABug.action, + shortcut: globalShortcuts.reportABug.shortcut, + footerHint: t(`Report a bug`), + footerBtns: [ + { + label: t('Open'), + shortcut: ['entr'], + }, + ], + }, + { + label: t(`Sign out`), + Icon: DoorOutIcon, + id: `sign-out`, + key: `sign-out`, + onClick: globalShortcuts.signOut.action, + shortcut: globalShortcuts.signOut.shortcut, + footerHint: t(`Sign out`), + footerBtns: [ + { + label: t('Sign out'), + shortcut: ['entr'], + }, + ], + }, + { + label: t(`Toggle regex search`), + Icon: RegexIcon, + id: `toggle-regex`, + key: `toggle-regex`, + onClick: globalShortcuts.toggleRegex.action, + shortcut: globalShortcuts.toggleRegex.shortcut, + footerHint: t(`Search your repositories using RegExp`), + footerBtns: [ + { + label: t('Toggle'), + shortcut: ['entr'], + }, + ], + }, + ]; + const commandsItems = [...themeItems, ...otherCommands]; + return bubbleUpRecentItems( + [ + ...(newTabItems.length + ? [ + { + items: newTabItems, + itemsOffset: 0, + key: 'new-tab-items', + }, + ] + : []), + ...(tabItems.length + ? [ + { + items: tabItems, + itemsOffset: newTabItems.length, + key: 'tab-items', + }, + ] + : []), + { + items: contextItems, + itemsOffset: newTabItems.length + tabItems.length, + label: t('Manage context'), + key: 'context-items', + }, + { + items: projectItems, + itemsOffset: + newTabItems.length + tabItems.length + contextItems.length, + label: t('Recent projects'), + key: 'recent-projects', + }, + { + items: commandsItems, + itemsOffset: + newTabItems.length + + tabItems.length + + contextItems.length + + projectItems.length, + label: t('Commands'), + key: 'general-commands', + }, + ], + recentKeys || [], + t('Recently used'), + ); + }, [t, projects, project, theme, globalShortcuts, tabItems, newTabItems]); + + const sectionsToShow = useMemo(() => { + if (!inputValue) { + return initialSections; + } + const newSections: CommandBarSectionType[] = []; + initialSections.forEach((s) => { + const newItems = (s.items as CommandBarItemGeneralType[]).filter((i) => + i.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + if (newItems.length) { + newSections.push({ + ...s, + items: newItems, + itemsOffset: newSections[newSections.length - 1] + ? newSections[newSections.length - 1].items.length + + newSections[newSections.length - 1].itemsOffset + : 0, + }); + } + }); + return newSections; + }, [inputValue, initialSections]); + + return ( +
+
+ {!!sectionsToShow.length && } +
+
+ ); +}; + +export default memo(InitialCommandBar); diff --git a/client/src/CommandBar/steps/LocalRepos.tsx b/client/src/CommandBar/steps/LocalRepos.tsx new file mode 100644 index 0000000000..4cf9d2a977 --- /dev/null +++ b/client/src/CommandBar/steps/LocalRepos.tsx @@ -0,0 +1,171 @@ +import { + ChangeEvent, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { CommandBarContext } from '../../context/commandBarContext'; +import { PlusSignIcon } from '../../icons'; +import { + CommandBarSectionType, + CommandBarStepEnum, + RepoProvider, +} from '../../types/general'; +import { getIndexedRepos, scanLocalRepos, syncRepo } from '../../services/api'; +import { DeviceContext } from '../../context/deviceContext'; +import Footer from '../Footer'; +import Body from '../Body'; +import Header from '../Header'; +import RepoItem from './items/RepoItem'; + +type Props = {}; + +const LocalRepos = ({}: Props) => { + const { t } = useTranslation(); + const [chosenFolder, setChosenFolder] = useState(null); + const [inputValue, setInputValue] = useState(''); + const { homeDir, chooseFolder } = useContext(DeviceContext); + const { setChosenStep, setFocusedItem } = useContext( + CommandBarContext.Handlers, + ); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + const handleChooseFolder = useCallback(async () => { + let folder: string | string[] | null; + if (chooseFolder) { + try { + folder = await chooseFolder({ + directory: true, + defaultPath: homeDir, + }); + } catch (err) { + console.log(err); + } + } + // @ts-ignore + if (typeof folder === 'string') { + setChosenFolder(folder); + } + }, [chooseFolder, homeDir]); + + const enterAddMode = useCallback(async () => { + setFocusedItem({ + footerHint: t('Select a folder containing a git repository'), + footerBtns: [{ label: t('Start indexing'), shortcut: ['entr'] }], + }); + await handleChooseFolder(); + }, []); + + useEffect(() => { + if (chosenFolder) { + scanLocalRepos(chosenFolder).then((data) => { + if (data.list.length === 1) { + syncRepo(data.list[0].ref); + refetchRepos(); + return; + } + }); + } + }, [chosenFolder]); + + const addItem = useMemo(() => { + return { + itemsOffset: 0, + key: 'add', + items: [ + { + label: t('Add local repository'), + Icon: PlusSignIcon, + footerHint: t('Add a repository from your local machine'), + footerBtns: [ + { + label: t('Select folder'), + shortcut: ['entr'], + }, + ], + key: 'add', + id: 'Add', + onClick: enterAddMode, + }, + ], + }; + }, []); + const [sections, setSections] = useState([addItem]); + + const breadcrumbs = useMemo(() => { + return [t('Local repositories')]; + }, [t]); + + const handleBack = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.INITIAL }); + }, []); + + const refetchRepos = useCallback(() => { + getIndexedRepos().then((data) => { + const mapped = data.list + .filter((r) => r.provider === RepoProvider.Local) + .map((r) => ({ + Component: RepoItem, + componentProps: { repo: { ...r, shortName: r.name }, refetchRepos }, + key: r.ref, + })); + if (!mapped.length) { + enterAddMode(); + } + setSections([ + addItem, + { + itemsOffset: 1, + key: 'indexed-repos', + label: t('Indexed local repositories'), + items: mapped, + }, + ]); + }); + }, []); + + useEffect(() => { + refetchRepos(); + }, []); + + const sectionsToShow = useMemo(() => { + if (!inputValue) { + return sections; + } + const newSections: CommandBarSectionType[] = []; + sections.forEach((s) => { + const newItems = s.items.filter((i) => + ('label' in i ? i.label : i.componentProps.repo.shortName) + .toLowerCase() + .startsWith(inputValue.toLowerCase()), + ); + if (newItems.length) { + newSections.push({ ...s, items: newItems }); + } + }); + return newSections; + }, [inputValue, sections]); + + return ( +
+
+ +
+
+ ); +}; + +export default memo(LocalRepos); diff --git a/client/src/CommandBar/steps/ManageRepos/ActionsDropdown.tsx b/client/src/CommandBar/steps/ManageRepos/ActionsDropdown.tsx new file mode 100644 index 0000000000..4f8ce109a4 --- /dev/null +++ b/client/src/CommandBar/steps/ManageRepos/ActionsDropdown.tsx @@ -0,0 +1,191 @@ +import { + Dispatch, + memo, + SetStateAction, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import SectionLabel from '../../../components/Dropdown/Section/SectionLabel'; +import SectionItem from '../../../components/Dropdown/Section/SectionItem'; +import DropdownSection from '../../../components/Dropdown/Section'; +import { CommandBarContext } from '../../../context/commandBarContext'; +import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; +import { HardDriveIcon, ShapesIcon } from '../../../icons'; +import GitHubIcon from '../../../icons/GitHubIcon'; +import { Filter, Provider } from './index'; + +type Props = { + setRepoType: Dispatch>; + repoType: Provider; + setFilter: Dispatch>; + filter: Filter; +}; + +const ActionsDropDown = ({ + setRepoType, + repoType, + setFilter, + filter, +}: Props) => { + const { t } = useTranslation(); + const { focusedItem } = useContext(CommandBarContext.FooterValues); + const [focusedIndex, setFocusedIndex] = useState(0); + + const providerIconMap = useMemo( + () => ({ + [Provider.All]: ShapesIcon, + [Provider.GitHub]: GitHubIcon, + [Provider.Local]: HardDriveIcon, + }), + [], + ); + + const providerOptions = useMemo( + () => [Provider.All, Provider.GitHub, Provider.Local], + [], + ); + const filterOptions = useMemo( + () => [Filter.All, Filter.Indexed, Filter.Indexing, Filter.InThisProject], + [], + ); + + const focusedDropdownItems = useMemo(() => { + return ( + (focusedItem && + 'focusedItemProps' in focusedItem && + focusedItem.focusedItemProps?.dropdownItems) || + [] + ); + }, [focusedItem]); + + const focusedDropdownItemsLength = useMemo(() => { + return focusedDropdownItems.reduce( + (prev: number, curr: { items: Record[]; key: string }) => + prev + curr.items.length, + 0, + ); + }, [focusedDropdownItems]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev < + providerOptions.length + + filterOptions.length + + focusedDropdownItemsLength - + 1 + ? prev + 1 + : 0, + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev > 0 + ? prev - 1 + : providerOptions.length + + filterOptions.length + + focusedDropdownItemsLength - + 1, + ); + } else if (e.key === 'Enter') { + if (focusedIndex < focusedDropdownItemsLength) { + let currentIndex = 0; + + for (let i = 0; i < focusedDropdownItems.length; i++) { + for (let j = 0; j < focusedDropdownItems[i].items.length; j++) { + if (currentIndex === focusedIndex) { + return focusedDropdownItems[i].items[j].onClick(); + } + currentIndex++; + } + } + } else if ( + focusedIndex < + focusedDropdownItemsLength + providerOptions.length + ) { + setRepoType( + providerOptions[focusedIndex - focusedDropdownItemsLength], + ); + } else { + setFilter( + filterOptions[ + focusedIndex - focusedDropdownItemsLength - providerOptions.length + ], + ); + } + } + }, + [focusedIndex, focusedDropdownItems, focusedDropdownItemsLength], + ); + useKeyboardNavigation(handleKeyEvent); + + return ( +
+ {!!focusedDropdownItems.length && + focusedDropdownItems.map( + (section: { + items: Record[]; + key: string; + itemsOffset: number; + }) => ( + + {section.items.map((item: Record, i: number) => ( + + ))} + + ), + )} + + + {providerOptions.map((type, i) => { + const Icon = providerIconMap[type]; + return ( + setRepoType(type)} + label={t(type)} + icon={} + /> + ); + })} + + + + {filterOptions.map((type, i) => ( + setFilter(type)} + label={t(type)} + /> + ))} + +
+ ); +}; + +export default memo(ActionsDropDown); diff --git a/client/src/CommandBar/steps/ManageRepos/index.tsx b/client/src/CommandBar/steps/ManageRepos/index.tsx new file mode 100644 index 0000000000..31d62ddf8d --- /dev/null +++ b/client/src/CommandBar/steps/ManageRepos/index.tsx @@ -0,0 +1,212 @@ +import { + ChangeEvent, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + CommandBarItemCustomType, + CommandBarItemGeneralType, + CommandBarItemType, + CommandBarSectionType, + CommandBarStepEnum, + RepoProvider, + SyncStatus, +} from '../../../types/general'; +import { PlusSignIcon } from '../../../icons'; +import Header from '../../Header'; +import Body from '../../Body'; +import Footer from '../../Footer'; +import { getIndexedRepos } from '../../../services/api'; +import { mapReposBySections } from '../../../utils/mappers'; +import { ProjectContext } from '../../../context/projectContext'; +import { CommandBarContext } from '../../../context/commandBarContext'; +import RepoItem from '../items/RepoItem'; +import ActionsDropdown from './ActionsDropdown'; + +type Props = {}; + +export enum Filter { + All = 'All', + Indexed = 'Indexed', + Indexing = 'Indexing', + InThisProject = 'In this project', +} + +export enum Provider { + All = 'All', + GitHub = 'GitHub', + Local = 'Local', +} + +const ManageRepos = ({}: Props) => { + const { t } = useTranslation(); + const { project } = useContext(ProjectContext.Current); + const { setChosenStep } = useContext(CommandBarContext.Handlers); + const [sections, setSections] = useState([]); + const [sectionsToShow, setSectionsToShow] = useState( + [], + ); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [filter, setFilter] = useState(Filter.All); + const [repoType, setRepoType] = useState(Provider.All); + const [inputValue, setInputValue] = useState(''); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + const addItem = useMemo(() => { + return { + itemsOffset: 0, + key: 'add', + items: [ + { + label: t('Add new repository'), + Icon: PlusSignIcon, + id: CommandBarStepEnum.ADD_NEW_REPO, + shortcut: ['cmd', 'A'], + footerHint: '', + footerBtns: [ + { + label: t('Add'), + shortcut: ['entr'], + }, + ], + key: 'add', + }, + ], + }; + }, []); + + const refetchRepos = useCallback(() => { + getIndexedRepos().then((data) => { + const mapped = mapReposBySections(data.list).map((o) => ({ + items: o.items.map((r) => ({ + Component: RepoItem, + componentProps: { repo: r, refetchRepos }, + key: r.ref, + })), + itemsOffset: o.offset + 1, + label: o.org === 'Local' ? t('Local') : o.org, + key: o.org, + })); + setSections([addItem, ...mapped]); + }); + }, []); + + useEffect(() => { + if (filter === Filter.All && !inputValue && repoType === Provider.All) { + setSectionsToShow(sections); + return; + } + const newSectionsToShow: CommandBarSectionType[] = []; + const filterByStatus = (item: CommandBarItemType) => { + if ('componentProps' in item) { + switch (filter) { + case Filter.Indexing: + return [ + SyncStatus.Syncing, + SyncStatus.Indexing, + SyncStatus.Queued, + ].includes(item.componentProps.repo.sync_status); + case Filter.Indexed: + return item.componentProps.repo.sync_status === SyncStatus.Done; + case Filter.InThisProject: + return !!project?.repos.find( + (r) => r.repo.ref === item.componentProps.repo.ref, + ); + default: + return true; + } + } + return false; + }; + + const filterByProvider = (item: CommandBarItemType) => { + if ('componentProps' in item) { + switch (repoType) { + case Provider.GitHub: + return item.componentProps.repo.provider === RepoProvider.GitHub; + case Provider.Local: + return item.componentProps.repo.provider === RepoProvider.Local; + default: + return true; + } + } + return false; + }; + + const filterByName = ( + item: CommandBarItemGeneralType | CommandBarItemCustomType, + ) => { + return 'componentProps' in item + ? item.componentProps.repo.shortName + .toLowerCase() + .startsWith(inputValue.toLowerCase()) + : item.label.toLowerCase().startsWith(inputValue.toLowerCase()); + }; + + sections.forEach((s) => { + const items = s.items.filter( + (item) => + filterByProvider(item) && filterByStatus(item) && filterByName(item), + ); + + if (items.length) { + newSectionsToShow.push({ + ...s, + items, + itemsOffset: newSectionsToShow[newSectionsToShow.length - 1] + ? newSectionsToShow[newSectionsToShow.length - 1].itemsOffset + + newSectionsToShow[newSectionsToShow.length - 1].items.length + : 0, + }); + } + }); + setSectionsToShow(newSectionsToShow); + }, [sections, filter, inputValue, project?.repos, repoType]); + + useEffect(() => { + refetchRepos(); + }, []); + + const handleBack = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.INITIAL }); + }, []); + + const actionsDropdownProps = useMemo(() => { + return { + repoType, + setRepoType, + filter, + setFilter, + }; + }, [repoType, filter]); + + return ( +
+
+ {!!sectionsToShow.length && ( + + )} +
+
+ ); +}; + +export default memo(ManageRepos); diff --git a/client/src/CommandBar/steps/PrivateRepos/ActionsDropdown.tsx b/client/src/CommandBar/steps/PrivateRepos/ActionsDropdown.tsx new file mode 100644 index 0000000000..9db4637fa8 --- /dev/null +++ b/client/src/CommandBar/steps/PrivateRepos/ActionsDropdown.tsx @@ -0,0 +1,89 @@ +import { memo, useCallback, useContext, useMemo, useState } from 'react'; +import SectionItem from '../../../components/Dropdown/Section/SectionItem'; +import DropdownSection from '../../../components/Dropdown/Section'; +import { CommandBarContext } from '../../../context/commandBarContext'; +import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; + +type Props = {}; + +const ActionsDropDown = ({}: Props) => { + const { focusedItem } = useContext(CommandBarContext.FooterValues); + const [focusedIndex, setFocusedIndex] = useState(0); + + const focusedDropdownItems = useMemo(() => { + return ( + (focusedItem && + 'focusedItemProps' in focusedItem && + focusedItem.focusedItemProps?.dropdownItems) || + [] + ); + }, [focusedItem]); + + const focusedDropdownItemsLength = useMemo(() => { + return focusedDropdownItems.reduce( + (prev: number, curr: { items: Record[]; key: string }) => + prev + curr.items.length, + 0, + ); + }, [focusedDropdownItems]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev < focusedDropdownItemsLength - 1 ? prev + 1 : 0, + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev > 0 ? prev - 1 : focusedDropdownItemsLength - 1, + ); + } else if (e.key === 'Enter') { + let currentIndex = 0; + + for (let i = 0; i < focusedDropdownItems.length; i++) { + for (let j = 0; j < focusedDropdownItems[i].items.length; j++) { + if (currentIndex === focusedIndex) { + return focusedDropdownItems[i].items[j].onClick(); + } + currentIndex++; + } + } + } + }, + [focusedIndex, focusedDropdownItems, focusedDropdownItemsLength], + ); + useKeyboardNavigation(handleKeyEvent); + + return ( +
+ {!!focusedDropdownItems.length && + focusedDropdownItems.map( + (section: { + items: Record[]; + key: string; + itemsOffset: number; + }) => ( + + {section.items.map((item: Record, i: number) => ( + + ))} + + ), + )} +
+ ); +}; + +export default memo(ActionsDropDown); diff --git a/client/src/CommandBar/steps/PrivateRepos/index.tsx b/client/src/CommandBar/steps/PrivateRepos/index.tsx new file mode 100644 index 0000000000..b0d3cb99bc --- /dev/null +++ b/client/src/CommandBar/steps/PrivateRepos/index.tsx @@ -0,0 +1,119 @@ +import { + ChangeEvent, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { CommandBarContext } from '../../../context/commandBarContext'; +import { + CommandBarItemCustomType, + CommandBarSectionType, + CommandBarStepEnum, + RepoProvider, + SyncStatus, +} from '../../../types/general'; +import { getRepos } from '../../../services/api'; +import { mapReposBySections } from '../../../utils/mappers'; +import Header from '../../Header'; +import Body from '../../Body'; +import Footer from '../../Footer'; +import RepoItem from '../items/RepoItem'; +import ActionsDropdown from './ActionsDropdown'; + +type Props = {}; + +const PrivateReposStep = ({}: Props) => { + const { t } = useTranslation(); + const [sections, setSections] = useState([]); + const [sectionsToShow, setSectionsToShow] = useState( + [], + ); + const { setChosenStep } = useContext(CommandBarContext.Handlers); + const [inputValue, setInputValue] = useState(''); + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + const refetchRepos = useCallback(async () => { + const data = await getRepos(); + const mapped = mapReposBySections( + data.list.filter((r) => r.provider !== RepoProvider.Local), + ).map((o) => ({ + items: o.items.map((r) => ({ + Component: RepoItem, + componentProps: { repo: r, refetchRepos }, + key: r.ref, + })), + itemsOffset: o.offset, + label: o.org, + key: o.org, + })); + setSections(mapped); + }, []); + + useEffect(() => { + if (!inputValue) { + setSectionsToShow(sections); + return; + } + const newSectionsToShow: CommandBarSectionType[] = []; + sections.forEach((s) => { + const items = (s.items as CommandBarItemCustomType[]).filter((item) => { + return item.componentProps.repo.shortName + .toLowerCase() + .startsWith(inputValue.toLowerCase()); + }); + + if (items.length) { + newSectionsToShow.push({ + ...s, + items, + itemsOffset: newSectionsToShow[newSectionsToShow.length - 1] + ? newSectionsToShow[newSectionsToShow.length - 1].itemsOffset + + newSectionsToShow[newSectionsToShow.length - 1].items.length + : 0, + }); + } + }); + setSectionsToShow(newSectionsToShow); + }, [sections, inputValue]); + + useEffect(() => { + refetchRepos(); + }, []); + + const breadcrumbs = useMemo(() => { + return [t('Add private repository')]; + }, [t]); + + const handleBack = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.MANAGE_REPOS }); + }, []); + + return ( +
+
+ {!!sectionsToShow.length && ( + + )} +
+
+ ); +}; + +export default memo(PrivateReposStep); diff --git a/client/src/CommandBar/steps/PublicRepos.tsx b/client/src/CommandBar/steps/PublicRepos.tsx new file mode 100644 index 0000000000..39419adfb3 --- /dev/null +++ b/client/src/CommandBar/steps/PublicRepos.tsx @@ -0,0 +1,101 @@ +import { + ChangeEvent, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import axios from 'axios'; +import { CommandBarStepEnum } from '../../types/general'; +import { CommandBarContext } from '../../context/commandBarContext'; +import { syncRepo } from '../../services/api'; +import Header from '../Header'; +import Footer from '../Footer'; + +type Props = {}; + +const PublicRepos = ({}: Props) => { + const { t } = useTranslation(); + const { setChosenStep, setFocusedItem } = useContext( + CommandBarContext.Handlers, + ); + const [inputValue, setInputValue] = useState(''); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + }, []); + + useEffect(() => { + setFocusedItem({ + footerHint: t('Paste a link to any public repository hosted on GitHub'), + footerBtns: [{ label: t('Start indexing'), shortcut: ['entr'] }], + }); + }, []); + + const breadcrumbs = useMemo(() => { + return [t('Add public repository')]; + }, [t]); + + const handleBack = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.MANAGE_REPOS }); + }, []); + + const handleAddSubmit = useCallback((inputValue: string) => { + setFocusedItem({ + footerHint: t('Verifying access...'), + footerBtns: [], + }); + let cleanRef = inputValue + .replace('https://', '') + .replace('github.com/', '') + .replace(/\.git$/, '') + .replace(/"$/, '') + .replace(/^"/, '') + .replace(/\/$/, ''); + if (inputValue.startsWith('git@github.com:')) { + cleanRef = inputValue.slice(15).replace(/\.git$/, ''); + } + axios(`https://api.github.com/repos/${cleanRef}`) + .then((resp) => { + if (resp?.data?.visibility === 'public') { + syncRepo(`github.com/${cleanRef}`); + handleBack(); + } else { + setFocusedItem({ + footerHint: t( + "This is not a public repository / We couldn't find this repository", + ), + footerBtns: [], + }); + } + }) + .catch((err) => { + console.log(err); + setFocusedItem({ + footerHint: t( + "This is not a public repository / We couldn't find this repository", + ), + footerBtns: [], + }); + }); + }, []); + + return ( +
+
+
+
+ ); +}; + +export default memo(PublicRepos); diff --git a/client/src/CommandBar/steps/ToggleTheme.tsx b/client/src/CommandBar/steps/ToggleTheme.tsx new file mode 100644 index 0000000000..5652357fb1 --- /dev/null +++ b/client/src/CommandBar/steps/ToggleTheme.tsx @@ -0,0 +1,75 @@ +import { memo, useCallback, useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + CommandBarItemGeneralType, + CommandBarStepEnum, +} from '../../types/general'; +import { + MacintoshIcon, + ThemeBlackIcon, + ThemeDarkIcon, + ThemeLightIcon, +} from '../../icons'; +import Header from '../Header'; +import Body from '../Body'; +import Footer from '../Footer'; +import { CommandBarContext } from '../../context/commandBarContext'; +import { Theme } from '../../types'; +import { UIContext } from '../../context/uiContext'; + +type Props = {}; + +const ToggleTheme = ({}: Props) => { + const { t } = useTranslation(); + const { setChosenStep } = useContext(CommandBarContext.Handlers); + const { setTheme } = useContext(UIContext.Theme); + + const handleBack = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.INITIAL }); + }, []); + + const initialSections = useMemo(() => { + const themeOptions = ['light', 'dark', 'black', 'system'] as Theme[]; + const themeMap = { + light: ThemeLightIcon, + dark: ThemeDarkIcon, + black: ThemeBlackIcon, + system: MacintoshIcon, + }; + const themeItems: CommandBarItemGeneralType[] = themeOptions.map((th) => ({ + label: t(`Use ${th} theme`), + Icon: themeMap[th], + id: `${th}-theme`, + key: `${th}-theme`, + onClick: () => setTheme(th), + footerHint: t(`Use ${th} theme`), + footerBtns: [ + { + label: t('Toggle'), + shortcut: ['entr'], + }, + ], + })); + return [ + { + items: themeItems, + itemsOffset: 0, + key: 'theme-commands', + }, + ]; + }, [t]); + + return ( +
+
+ +
+
+ ); +}; + +export default memo(ToggleTheme); diff --git a/client/src/CommandBar/steps/items/DocItem.tsx b/client/src/CommandBar/steps/items/DocItem.tsx new file mode 100644 index 0000000000..f7256ade84 --- /dev/null +++ b/client/src/CommandBar/steps/items/DocItem.tsx @@ -0,0 +1,209 @@ +import { + Dispatch, + memo, + SetStateAction, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { format } from 'date-fns'; +import { LinkChainIcon, RepositoryIcon } from '../../../icons'; +import { DeviceContext } from '../../../context/deviceContext'; +import { getDateFnsLocale } from '../../../utils'; +import { LocaleContext } from '../../../context/localeContext'; +import { DocShortType } from '../../../types/api'; +import { deleteDocProvider, getIndexedDocs } from '../../../services/api'; +import Item from '../../Body/Item'; +import SpinLoaderContainer from '../../../components/Loaders/SpinnerLoader'; +import Button from '../../../components/Button'; + +type Props = { + doc: DocShortType; + i: number; + isFocused: boolean; + setFocusedIndex: Dispatch>; + isFirst: boolean; + isIndexed: boolean; + refetchDocs: () => {}; +}; + +const DocItem = ({ + doc, + isFirst, + setFocusedIndex, + isFocused, + i, + isIndexed, + refetchDocs, +}: Props) => { + const { t } = useTranslation(); + const { locale } = useContext(LocaleContext); + const [docToShow, setDocToShow] = useState(doc); + const [indexingStartedAt, setIndexingStartedAt] = useState(Date.now()); + const [isIndexingFinished, setIsIndexingFinished] = useState( + !!doc.id && doc.index_status === 'done', + ); + const { apiUrl, openLink } = useContext(DeviceContext); + const eventSourceRef = useRef(null); + + const refetchDoc = useCallback(() => { + getIndexedDocs().then((data) => { + const newDoc = data.find((d) => d.url === doc.url); + setDocToShow(newDoc || doc); + }); + }, []); + + const startEventSource = useCallback( + (isResync?: boolean) => { + setIsIndexingFinished(false); + setIndexingStartedAt(Date.now()); + eventSourceRef.current = new EventSource( + `${apiUrl.replace('https:', '')}/docs/${ + isResync ? `${docToShow.id}/resync` : `sync?url=${docToShow.url}` + }`, + ); + setTimeout(refetchDoc, 3000); + eventSourceRef.current.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + console.log(data); + if (data.Ok.Done) { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + setIsIndexingFinished(true); + refetchDoc(); + return; + } + } catch (err) { + console.log(err); + eventSourceRef.current?.close(); + eventSourceRef.current = null; + } + }; + eventSourceRef.current.onerror = (err) => { + console.log(err); + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, + [docToShow], + ); + + useEffect(() => { + return () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, []); + + useEffect(() => { + if (!isIndexed && !eventSourceRef.current && !isIndexingFinished) { + startEventSource(); + } + }, [isIndexed]); + + const handleAddToProject = useCallback(() => { + console.log(docToShow); + }, [docToShow]); + + const handleCancelSync = useCallback(() => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + setIsIndexingFinished(true); + }, []); + + const isIndexing = useMemo(() => { + return !isIndexed && !isIndexingFinished; + }, [isIndexed, isIndexingFinished]); + + const handleRemove = useCallback(() => { + if (docToShow.id) { + deleteDocProvider(docToShow.id).then(() => { + refetchDocs(); + }); + } else { + refetchDocs(); + } + }, [docToShow.id]); + + return ( + + {t(`Indexed`)} + + + ) : ( + t('Index repository') + ) + } + onClick={isIndexing ? handleCancelSync : handleAddToProject} + iconContainerClassName={ + isIndexingFinished + ? 'bg-bg-contrast text-label-contrast' + : 'bg-bg-border' + } + footerBtns={ + isIndexingFinished + ? [ + { + label: t('Remove'), + shortcut: ['cmd', 'D'], + action: handleRemove, + }, + { + label: t('Re-sync'), + shortcut: ['cmd', 'R'], + action: () => startEventSource(true), + }, + { + label: t('Add to project'), + shortcut: ['entr'], + action: handleAddToProject, + }, + ] + : [ + { + label: t('Stop indexing'), + shortcut: ['entr'], + action: handleCancelSync, + }, + ] + } + customRightElement={ + isIndexing ? ( +

{t('Indexing...')}

+ ) : undefined + } + /> + ); +}; + +export default memo(DocItem); diff --git a/client/src/CommandBar/steps/items/RepoItem.tsx b/client/src/CommandBar/steps/items/RepoItem.tsx new file mode 100644 index 0000000000..36b21de252 --- /dev/null +++ b/client/src/CommandBar/steps/items/RepoItem.tsx @@ -0,0 +1,360 @@ +import { + Dispatch, + memo, + SetStateAction, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + CommandBarStepEnum, + RepoProvider, + RepoUi, + SyncStatus, +} from '../../../types/general'; +import { + addRepoToProject, + cancelSync, + deleteRepo, + removeRepoFromProject, + syncRepo, +} from '../../../services/api'; +import { + CloseSignInCircleIcon, + LinkChainIcon, + PlusSignIcon, + RepositoryIcon, + TrashCanIcon, +} from '../../../icons'; +import { DeviceContext } from '../../../context/deviceContext'; +import { getFileManagerName } from '../../../utils'; +import Item from '../../Body/Item'; +import SpinLoaderContainer from '../../../components/Loaders/SpinnerLoader'; +import { ProjectContext } from '../../../context/projectContext'; +import { repoStatusMap } from '../../../consts/general'; + +type Props = { + repo: RepoUi; + i: number; + isFocused: boolean; + setFocusedIndex: Dispatch>; + isFirst: boolean; + refetchRepos: () => void; + disableKeyNav?: boolean; +}; + +const RepoItem = ({ + repo, + isFirst, + setFocusedIndex, + isFocused, + i, + refetchRepos, + disableKeyNav, +}: Props) => { + const { t } = useTranslation(); + const { project, refreshCurrentProjectRepos } = useContext( + ProjectContext.Current, + ); + const [status, setStatus] = useState(repo.sync_status); + const [indexingPercent, setIndexingPercent] = useState(null); + const { apiUrl, openFolderInExplorer, os, openLink } = + useContext(DeviceContext); + const eventSourceRef = useRef(null); + + useEffect(() => { + setStatus(repo.sync_status); + }, [repo.sync_status, repo.last_index]); + + const startEventSource = useCallback(() => { + eventSourceRef.current = new EventSource( + `${apiUrl.replace('https:', '')}/repos/status`, + ); + eventSourceRef.current.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.ref === repo.ref) { + if (data.ev?.status_change) { + setStatus(data.ev?.status_change); + if (data.ev?.status_change === SyncStatus.Done) { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + refetchRepos(); + } + } + if ( + Number.isInteger(data.ev?.index_percent) || + data.ev?.index_percent === null + ) { + setStatus((prev) => + prev === SyncStatus.Queued ? SyncStatus.Indexing : prev, + ); + setIndexingPercent(data.ev.index_percent); + } + } + } catch { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + } + }; + eventSourceRef.current.onerror = () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, []); + + useEffect(() => { + return () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, []); + + useEffect(() => { + if ( + [SyncStatus.Indexing, SyncStatus.Syncing, SyncStatus.Queued].includes( + status, + ) && + !eventSourceRef.current + ) { + startEventSource(); + } + }, [status]); + + const onRepoSync = useCallback(async () => { + await syncRepo(repo.ref); + setStatus(SyncStatus.Queued); + startEventSource(); + }, [repo.ref]); + + const handleAddToProject = useCallback(() => { + if (project?.id) { + return addRepoToProject( + project.id, + repo.ref, + repo.branch_filter?.select?.[0], + ).finally(() => { + refreshCurrentProjectRepos(); + }); + } + }, [repo]); + + const handleOpenInFinder = useCallback(() => { + openFolderInExplorer(repo.ref.slice(6)); + }, [openFolderInExplorer, repo.ref]); + + const handleOpenInGitHub = useCallback(() => { + openLink('https://' + repo.ref); + }, [openLink, repo.ref]); + + const handleRemoveFromProject = useCallback(() => { + if (project?.id) { + return removeRepoFromProject(project.id, repo.ref).finally(() => { + refreshCurrentProjectRepos(); + }); + } + }, [repo]); + + const handleCancelSync = useCallback(() => { + cancelSync(repo.ref); + setStatus(SyncStatus.Cancelled); + eventSourceRef.current?.close(); + }, [repo.ref]); + + const isIndexing = useMemo(() => { + return [ + SyncStatus.Indexing, + SyncStatus.Syncing, + SyncStatus.Queued, + ].includes(status); + }, [status]); + + const onRepoRemove = useCallback(async () => { + await deleteRepo(repo.ref); + refetchRepos(); + }, [repo.ref]); + + const isInProject = useMemo(() => { + return project?.repos.find((r) => r.repo.ref === repo.ref); + }, [project, repo.ref]); + + const focusedItemProps = useMemo(() => { + const dropdownItems1 = []; + if (isIndexing) { + dropdownItems1.push({ + onClick: handleCancelSync, + label: t('Stop indexing'), + icon: ( + + + + ), + key: 'stop_indexing', + }); + } + if (status === SyncStatus.Done || status === SyncStatus.Cancelled) { + dropdownItems1.push( + isInProject + ? { + onClick: handleRemoveFromProject, + label: t('Remove from project'), + icon: ( + + + + ), + key: 'remove_from_project', + } + : { + onClick: handleAddToProject, + label: t('Add to project'), + icon: ( + + + + ), + key: 'add_to_project', + }, + ); + dropdownItems1.push({ + onClick: onRepoSync, + label: t('Re-sync'), + shortcut: ['cmd', 'R'], + key: 'resync', + }); + dropdownItems1.push({ + onClick: onRepoRemove, + label: t('Remove'), + shortcut: ['cmd', 'D'], + key: 'remove', + }); + } + const dropdownItems2 = [ + repo.provider === RepoProvider.Local + ? { + onClick: handleOpenInFinder, + label: t(`Open in {{viewer}}`, { + viewer: getFileManagerName(os.type), + }), + key: 'openInFinder', + } + : { + onClick: handleOpenInGitHub, + label: t(`Open in GitHub`), + icon: ( + + + + ), + key: 'openInGitHub', + }, + ]; + const dropdownItems = []; + if (dropdownItems1.length) { + dropdownItems.push({ items: dropdownItems1, key: '1', itemsOffset: 0 }); + } + if (dropdownItems2.length) { + dropdownItems.push({ + items: dropdownItems2, + key: '2', + itemsOffset: dropdownItems1.length, + }); + } + return { + dropdownItems, + }; + }, [ + t, + isInProject, + handleAddToProject, + handleRemoveFromProject, + handleCancelSync, + status, + repo.provider, + isIndexing, + handleOpenInFinder, + handleOpenInGitHub, + onRepoRemove, + ]); + + return ( + + {t(repoStatusMap[status].text)} + {indexingPercent !== null && `· ${indexingPercent}%`} +

+ ) : undefined + } + focusedItemProps={focusedItemProps} + disableKeyNav={disableKeyNav} + /> + ); +}; + +export default memo(RepoItem); diff --git a/client/src/pages/Onboarding/Desktop/FeaturesStep/Feature.tsx b/client/src/Onboarding/Desktop/FeaturesStep/Feature.tsx similarity index 84% rename from client/src/pages/Onboarding/Desktop/FeaturesStep/Feature.tsx rename to client/src/Onboarding/Desktop/FeaturesStep/Feature.tsx index fba0e9f23c..47829c4e66 100644 --- a/client/src/pages/Onboarding/Desktop/FeaturesStep/Feature.tsx +++ b/client/src/Onboarding/Desktop/FeaturesStep/Feature.tsx @@ -13,7 +13,7 @@ const Feature = ({ icon, description, title }: Props) => { {icon} {title}
-
{description}
+
{description}
); }; diff --git a/client/src/pages/Onboarding/Desktop/FeaturesStep/index.tsx b/client/src/Onboarding/Desktop/FeaturesStep/index.tsx similarity index 66% rename from client/src/pages/Onboarding/Desktop/FeaturesStep/index.tsx rename to client/src/Onboarding/Desktop/FeaturesStep/index.tsx index cb4a73e16c..604e5fa0cc 100644 --- a/client/src/pages/Onboarding/Desktop/FeaturesStep/index.tsx +++ b/client/src/Onboarding/Desktop/FeaturesStep/index.tsx @@ -1,17 +1,14 @@ import React, { useCallback } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import Button from '../../../../components/Button'; -import { ChatBubble, PointClick, CodeStudioIcon } from '../../../../icons'; -import DialogText from '../../../../components/SeparateOnboardingStep/DialogText'; -import GoBackButton from '../../../HomeTab/AddRepos/GoBackButton'; +import Button from '../../../components/Button'; +import { ChatBubblesIcon, CodeStudioIcon } from '../../../icons'; import Feature from './Feature'; type Props = { handleNext: (e?: any) => void; - handleBack?: (e?: any) => void; }; -const FeaturesStep = ({ handleNext, handleBack }: Props) => { +const FeaturesStep = ({ handleNext }: Props) => { const { t } = useTranslation(); const handleSubmit = useCallback( (e: React.MouseEvent) => { @@ -23,20 +20,24 @@ const FeaturesStep = ({ handleNext, handleBack }: Props) => { return ( <> - +
+

+ {t('Welcome to bloop')} +

+

+ {t('Unlock the value of your existing code, using AI')} +

+
} + icon={} title={t('Search code in natural language')} description={t( 'Ask questions about your codebases in natural language, just like you’d speak to ChatGPT. Get started by syncing a repo, then open the repo and start chatting.', )} /> } + icon={} title={t('Generate code using AI')} description={t( 'Code studio helps you write scripts, create unit tests, debug issues or generate anything else you can think of using AI! Sync a repo, then create a code studio project.', @@ -48,7 +49,6 @@ const FeaturesStep = ({ handleNext, handleBack }: Props) => {
- {handleBack ? : null} ); }; diff --git a/client/src/pages/Onboarding/Desktop/UserForm/Step1.tsx b/client/src/Onboarding/Desktop/UserForm/Step1.tsx similarity index 69% rename from client/src/pages/Onboarding/Desktop/UserForm/Step1.tsx rename to client/src/Onboarding/Desktop/UserForm/Step1.tsx index adba3e4627..0aed99f170 100644 --- a/client/src/pages/Onboarding/Desktop/UserForm/Step1.tsx +++ b/client/src/Onboarding/Desktop/UserForm/Step1.tsx @@ -7,17 +7,17 @@ import React, { useState, } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import TextInput from '../../../../components/TextInput'; -import { EMAIL_REGEX } from '../../../../consts/validations'; -import Dropdown from '../../../../components/Dropdown/Normal'; -import { themesMap } from '../../../../components/Settings/Preferences'; -import { MenuItemType } from '../../../../types/general'; -import { Theme } from '../../../../types'; -import { previewTheme } from '../../../../utils'; -import Button from '../../../../components/Button'; -import { UIContext } from '../../../../context/uiContext'; +import TextInput from '../../../components/TextInput'; +import { EMAIL_REGEX } from '../../../consts/validations'; +import { themesMap } from '../../../consts/general'; +import Button from '../../../components/Button'; +import { UIContext } from '../../../context/uiContext'; import { Form } from '../../index'; -import { DeviceContext } from '../../../../context/deviceContext'; +import { DeviceContext } from '../../../context/deviceContext'; +import Dropdown from '../../../components/Dropdown'; +import ThemeDropdown from '../../../Settings/Preferences/ThemeDropdown'; +import { themeIconsMap } from '../../../Settings/Preferences'; +import { ChevronDownIcon } from '../../../icons'; type Props = { form: Form; @@ -27,7 +27,7 @@ type Props = { const UserFormStep1 = ({ form, setForm, onContinue }: Props) => { const { t } = useTranslation(); - const { theme, setTheme } = useContext(UIContext.Theme); + const { theme } = useContext(UIContext.Theme); const { openLink } = useContext(DeviceContext); const [showErrors, setShowErrors] = useState(false); @@ -65,7 +65,6 @@ const UserFormStep1 = ({ form, setForm, onContinue }: Props) => { value={form.firstName} name="firstName" placeholder={t('First name')} - variant="filled" onChange={(e) => setForm((prev) => ({ ...prev, firstName: e.target.value })) } @@ -80,7 +79,6 @@ const UserFormStep1 = ({ form, setForm, onContinue }: Props) => { value={form.lastName} name="lastName" placeholder={t('Last name')} - variant="filled" onChange={(e) => setForm((prev) => ({ ...prev, lastName: e.target.value })) } @@ -90,7 +88,6 @@ const UserFormStep1 = ({ form, setForm, onContinue }: Props) => { /> setForm((prev) => ({ ...prev, @@ -98,7 +95,7 @@ const UserFormStep1 = ({ form, setForm, onContinue }: Props) => { emailError: null, })) } - validate={() => { + onBlur={() => { if (form.email && !EMAIL_REGEX.test(form.email)) { setForm((prev) => ({ ...prev, @@ -113,26 +110,21 @@ const UserFormStep1 = ({ form, setForm, onContinue }: Props) => { name="email" placeholder={t('Email address')} /> -
+
+ + Select color theme: + - Select color theme: - - } - btnClassName="w-full border-transparent" - items={Object.entries(themesMap).map(([key, name]) => ({ - type: MenuItemType.DEFAULT, - text: t(name), - onClick: () => setTheme(key as Theme), - onMouseOver: () => previewTheme(key), - }))} - onClose={() => previewTheme(theme)} - selected={{ - type: MenuItemType.DEFAULT, - text: t(themesMap[theme]), - }} - /> + DropdownComponent={ThemeDropdown} + size="small" + dropdownPlacement="bottom-end" + > + +
)} - + + +
@@ -40,14 +54,14 @@ const UserForm = ({ form, setForm, onContinue }: Props) => { Setup bloop {envConfig.credentials_upgrade && ( -

+

We’ve updated our auth service to make bloop more secure, please reauthorise your client with GitHub

)} -

+

{step === 0 ? ( Let’s get you started with bloop! ) : ( diff --git a/client/src/pages/Onboarding/Desktop/index.tsx b/client/src/Onboarding/Desktop/index.tsx similarity index 78% rename from client/src/pages/Onboarding/Desktop/index.tsx rename to client/src/Onboarding/Desktop/index.tsx index 085ea867aa..bb41f85300 100644 --- a/client/src/pages/Onboarding/Desktop/index.tsx +++ b/client/src/Onboarding/Desktop/index.tsx @@ -1,24 +1,24 @@ import React, { memo, useCallback, useContext, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import NavBar from '../../../components/NavBar'; -import { DeviceContext } from '../../../context/deviceContext'; +import NavBar from '../../components/Header'; +import { DeviceContext } from '../../context/deviceContext'; import { getJsonFromStorage, saveJsonToStorage, USER_DATA_FORM, -} from '../../../services/storage'; +} from '../../services/storage'; import { Form } from '../index'; -import { saveUserData } from '../../../services/api'; -import SeparateOnboardingStep from '../../../components/SeparateOnboardingStep'; +import { saveUserData } from '../../services/api'; +import Modal from '../../components/Modal'; +import { EnvContext } from '../../context/envContext'; import FeaturesStep from './FeaturesStep'; import UserForm from './UserForm'; type Props = { - activeTab: string; closeOnboarding: () => void; }; -const Desktop = ({ activeTab, closeOnboarding }: Props) => { +const Desktop = ({ closeOnboarding }: Props) => { const { t } = useTranslation(); const [shouldShowPopup, setShouldShowPopup] = useState(false); const [form, setForm] = useState

({ @@ -28,7 +28,8 @@ const Desktop = ({ activeTab, closeOnboarding }: Props) => { emailError: null, ...getJsonFromStorage(USER_DATA_FORM), }); - const { os, envConfig } = useContext(DeviceContext); + const { os } = useContext(DeviceContext); + const { envConfig } = useContext(EnvContext); const onSubmit = useCallback(() => { saveUserData({ @@ -44,7 +45,12 @@ const Desktop = ({ activeTab, closeOnboarding }: Props) => { return (
- {os.type === 'Darwin' && } + {os.type === 'Darwin' && ( +
+ )} {
- setShouldShowPopup(false)} > setShouldShowPopup(false)} /> - +
); }; diff --git a/client/src/pages/Onboarding/SelfServe/index.tsx b/client/src/Onboarding/SelfServe/index.tsx similarity index 70% rename from client/src/pages/Onboarding/SelfServe/index.tsx rename to client/src/Onboarding/SelfServe/index.tsx index 810db2ef07..1673212501 100644 --- a/client/src/pages/Onboarding/SelfServe/index.tsx +++ b/client/src/Onboarding/SelfServe/index.tsx @@ -1,18 +1,11 @@ import React, { memo, useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; -import NavBar from '../../../components/NavBar'; -import StatusBar from '../../../components/StatusBar'; -import { githubLogin } from '../../../services/api'; -import DialogText from '../../../components/SeparateOnboardingStep/DialogText'; -import Button from '../../../components/Button'; -import { GitHubLogo } from '../../../icons'; +import { githubLogin } from '../../services/api'; +import Button from '../../components/Button'; +import { GitHubLogo } from '../../icons'; -type Props = { - activeTab: string; -}; - -const SelfServe = ({ activeTab }: Props) => { +const SelfServe = () => { const { t } = useTranslation(); const [loginUrl, setLoginUrl] = useState(''); const location = useLocation(); @@ -31,7 +24,6 @@ const SelfServe = ({ activeTab }: Props) => { return (
-
@@ -39,10 +31,12 @@ const SelfServe = ({ activeTab }: Props) => {
- +
+

{t`Sign In`}

+

+ {t`Use GitHub to sign in to your account`} +

+
-
); }; diff --git a/client/src/pages/Onboarding/index.tsx b/client/src/Onboarding/index.tsx similarity index 78% rename from client/src/pages/Onboarding/index.tsx rename to client/src/Onboarding/index.tsx index 26ba24d1c5..ab1d64bbc9 100644 --- a/client/src/pages/Onboarding/index.tsx +++ b/client/src/Onboarding/index.tsx @@ -1,13 +1,14 @@ import React, { memo, useCallback, useContext, useEffect } from 'react'; -import { UIContext } from '../../context/uiContext'; -import { DeviceContext } from '../../context/deviceContext'; +import { UIContext } from '../context/uiContext'; +import { DeviceContext } from '../context/deviceContext'; import { getPlainFromStorage, ONBOARDING_DONE_KEY, REFRESH_TOKEN_KEY, savePlainToStorage, -} from '../../services/storage'; -import { getConfig, refreshToken } from '../../services/api'; +} from '../services/storage'; +import { getConfig, refreshToken } from '../services/api'; +import { EnvContext } from '../context/envContext'; import SelfServe from './SelfServe'; import Desktop from './Desktop'; @@ -18,11 +19,12 @@ export type Form = { emailError: string | null; }; -const Onboarding = ({ activeTab }: { activeTab: string }) => { +const Onboarding = () => { const { shouldShowWelcome, setShouldShowWelcome } = useContext( UIContext.Onboarding, ); - const { isSelfServe, setEnvConfig, envConfig } = useContext(DeviceContext); + const { isSelfServe } = useContext(DeviceContext); + const { setEnvConfig, envConfig } = useContext(EnvContext); const closeOnboarding = useCallback(() => { setShouldShowWelcome(false); @@ -71,9 +73,9 @@ const Onboarding = ({ activeTab }: { activeTab: string }) => { return shouldShowWelcome ? ( isSelfServe ? ( - + ) : ( - + ) ) : null; }; diff --git a/client/src/Project/CurrentTabContent/ChatTab/ActionsDropdown.tsx b/client/src/Project/CurrentTabContent/ChatTab/ActionsDropdown.tsx new file mode 100644 index 0000000000..4a99cce624 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/ActionsDropdown.tsx @@ -0,0 +1,74 @@ +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import DropdownSection from '../../../components/Dropdown/Section'; +import SectionItem from '../../../components/Dropdown/Section/SectionItem'; +import { SplitViewIcon, TrashCanIcon } from '../../../icons'; +import { deleteConversation } from '../../../services/api'; + +type Props = { + handleMoveToAnotherSide: () => void; + refreshCurrentProjectConversations: () => void; + closeTab: (tabKey: string, side: 'left' | 'right') => void; + conversationId?: string; + projectId?: string; + tabKey: string; + side: 'left' | 'right'; +}; + +const ActionsDropdown = ({ + handleMoveToAnotherSide, + refreshCurrentProjectConversations, + conversationId, + projectId, + closeTab, + tabKey, + side, +}: Props) => { + const { t } = useTranslation(); + + const removeConversation = useCallback(async () => { + if (projectId && conversationId) { + await deleteConversation(projectId, conversationId); + refreshCurrentProjectConversations(); + closeTab(tabKey, side); + } + }, [ + projectId, + conversationId, + closeTab, + refreshCurrentProjectConversations, + tabKey, + side, + ]); + + const shortcuts = useMemo(() => { + return { + splitView: ['cmd', ']'], + }; + }, []); + + return ( +
+ + } + /> + {conversationId && ( + } + /> + )} + +
+ ); +}; + +export default memo(ActionsDropdown); diff --git a/client/src/Project/CurrentTabContent/ChatTab/ChatPersistentState.tsx b/client/src/Project/CurrentTabContent/ChatTab/ChatPersistentState.tsx new file mode 100644 index 0000000000..ec7bbe885e --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/ChatPersistentState.tsx @@ -0,0 +1,628 @@ +import { + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { DeviceContext } from '../../../context/deviceContext'; +import { ProjectContext } from '../../../context/projectContext'; +import { + ChatMessage, + ChatMessageAuthor, + ChatMessageServer, + ChatMessageUser, + ChatTabType, + InputValueType, + ParsedQueryType, + ParsedQueryTypeEnum, + TabTypesEnum, +} from '../../../types/general'; +import { conversationsCache } from '../../../services/cache'; +import { mapLoadingSteps, mapUserQuery } from '../../../mappers/conversation'; +import { focusInput } from '../../../utils/domUtils'; +import { ChatsContext } from '../../../context/chatsContext'; +import { TabsContext } from '../../../context/tabsContext'; +import { getConversation } from '../../../services/api'; + +type Options = { + path: string; + lines: [number, number]; + repoRef: string; + branch?: string | null; +}; + +type Props = { + tabKey: string; + tabTitle?: string; + conversationId?: string; + initialQuery?: Options; + side: 'left' | 'right'; +}; + +const ChatPersistentState = ({ + tabKey, + tabTitle, + side, + initialQuery, + conversationId: convId, +}: Props) => { + const { t } = useTranslation(); + const { apiUrl } = useContext(DeviceContext); + const { project, refreshCurrentProjectConversations } = useContext( + ProjectContext.Current, + ); + const { preferredAnswerSpeed } = useContext(ProjectContext.AnswerSpeed); + const { setChats } = useContext(ChatsContext); + const { openNewTab, updateTabProperty } = useContext(TabsContext.Handlers); + + const prevEventSource = useRef(null); + + const [conversation, setConversation] = useState([]); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], conversation } }; + }); + }, [conversation]); + + const [selectedLines, setSelectedLines] = useState<[number, number] | null>( + null, + ); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], selectedLines } }; + }); + }, [selectedLines]); + + const [inputValue, setInputValue] = useState({ + plain: '', + parsed: [], + }); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], inputValue } }; + }); + }, [inputValue]); + + const [submittedQuery, setSubmittedQuery] = useState< + InputValueType & { options?: Options } + >( + initialQuery + ? { + parsed: [ + { + type: ParsedQueryTypeEnum.TEXT, + text: `#explain_${initialQuery.path}:${initialQuery.lines.join( + '-', + )}-${Date.now()}`, + }, + ], + plain: `#explain_${initialQuery.path}:${initialQuery.lines.join( + '-', + )}-${Date.now()}`, + options: initialQuery, + } + : { + parsed: [], + plain: '', + }, + ); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], submittedQuery } }; + }); + }, [submittedQuery]); + + const [isLoading, setLoading] = useState(false); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], isLoading } }; + }); + }, [isLoading]); + + const [isDeprecatedModalOpen, setDeprecatedModalOpen] = useState(false); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], isDeprecatedModalOpen } }; + }); + }, [isDeprecatedModalOpen]); + + const [hideMessagesFrom, setHideMessagesFrom] = useState(null); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], hideMessagesFrom } }; + }); + }, [hideMessagesFrom]); + + const [queryIdToEdit, setQueryIdToEdit] = useState(''); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], queryIdToEdit } }; + }); + }, [queryIdToEdit]); + + const [inputImperativeValue, setInputImperativeValue] = useState | null>(null); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], inputImperativeValue } }; + }); + }, [inputImperativeValue]); + + const [threadId, setThreadId] = useState(''); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], threadId } }; + }); + }, [threadId]); + + const [conversationId, setConversationId] = useState(''); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], conversationId } }; + }); + }, [conversationId]); + + const closeDeprecatedModal = useCallback(() => { + setDeprecatedModalOpen(false); + }, []); + + useEffect(() => { + setChats((prev) => { + return { + ...prev, + [tabKey]: { + ...prev[tabKey], + setConversation, + setInputValue, + setSelectedLines, + setSubmittedQuery, + setThreadId, + closeDeprecatedModal, + }, + }; + }); + }, []); + + const setInputValueImperatively = useCallback( + (value: ParsedQueryType[] | string) => { + setInputImperativeValue({ + type: 'paragraph', + content: + typeof value === 'string' + ? [ + { + type: 'text', + text: value, + }, + ] + : value + .filter((pq) => + ['path', 'lang', 'text', 'repo'].includes(pq.type), + ) + .map((pq) => + pq.type === 'text' + ? { type: 'text', text: pq.text } + : { + type: 'mention', + attrs: { + id: pq.text, + display: pq.text, + type: pq.type, + isFirst: false, + }, + }, + ), + }); + focusInput(); + }, + [], + ); + useEffect(() => { + setChats((prev) => { + return { + ...prev, + [tabKey]: { ...prev[tabKey], setInputValueImperatively }, + }; + }); + }, [setInputValueImperatively]); + + const makeSearch = useCallback( + (query: string, options?: Options) => { + if (!query) { + return; + } + prevEventSource.current?.close(); + setInputValue({ plain: '', parsed: [] }); + setInputImperativeValue(null); + setLoading(true); + setQueryIdToEdit(''); + setHideMessagesFrom(null); + const url = `${apiUrl}/projects/${project?.id}/answer${ + options ? `/explain` : `` + }`; + const queryParams: Record = { + model: + preferredAnswerSpeed === 'normal' + ? 'gpt-4' + : 'gpt-3.5-turbo-finetuned', + }; + if (conversationId) { + queryParams.conversation_id = conversationId; + if (queryIdToEdit) { + queryParams.parent_query_id = queryIdToEdit; + } + } + if (options) { + queryParams.relative_path = options.path; + queryParams.repo_ref = options.repoRef; + if (options.branch) { + queryParams.branch = options.branch; + } + queryParams.line_start = options.lines[0].toString(); + queryParams.line_end = options.lines[1].toString(); + } else { + queryParams.q = query; + } + const fullUrl = url + '?' + new URLSearchParams(queryParams).toString(); + console.log(fullUrl); + const eventSource = new EventSource(fullUrl); + prevEventSource.current = eventSource; + setSelectedLines(null); + let firstResultCame: boolean; + eventSource.onerror = (err) => { + console.log('SSE error', err); + firstResultCame = false; + stopGenerating(); + setConversation((prev) => { + const newConversation = prev.slice(0, -1); + const lastMessage: ChatMessage = { + author: ChatMessageAuthor.Server, + isLoading: false, + error: t( + "We couldn't answer your question. You can try asking again in a few moments, or rephrasing your question.", + ), + loadingSteps: [], + queryId: '', + responseTimestamp: new Date().toISOString(), + }; + if (!options) { + // setInputValue(prev[prev.length - 2]?.text || submittedQuery); + setInputValueImperatively( + (prev[prev.length - 2] as ChatMessageUser)?.parsedQuery || + prev[prev.length - 2]?.text || + submittedQuery.parsed, + ); + } + setSubmittedQuery({ plain: '', parsed: [] }); + return [...newConversation, lastMessage]; + }); + }; + let conversation_id = ''; + setConversation((prev) => [ + ...prev, + { + author: ChatMessageAuthor.Server, + isLoading: true, + loadingSteps: [], + text: '', + conclusion: '', + queryId: '', + responseTimestamp: '', + }, + ]); + eventSource.onmessage = (ev) => { + console.log(ev.data); + if ( + ev.data === '{"Err":"incompatible client"}' || + ev.data === '{"Err":"failed to check compatibility"}' + ) { + eventSource.close(); + prevEventSource.current?.close(); + if (ev.data === '{"Err":"incompatible client"}') { + setDeprecatedModalOpen(true); + } else { + setConversation((prev) => { + const newConversation = prev.slice(0, -1); + const lastMessage: ChatMessage = { + author: ChatMessageAuthor.Server, + isLoading: false, + error: t( + "We couldn't answer your question. You can try asking again in a few moments, or rephrasing your question.", + ), + loadingSteps: [], + queryId: '', + responseTimestamp: new Date().toISOString(), + }; + if (!options) { + // setInputValue(prev[prev.length - 1]?.text || submittedQuery); + setInputValueImperatively( + (prev[prev.length - 1] as ChatMessageUser)?.parsedQuery || + prev[prev.length - 2]?.text || + submittedQuery.parsed, + ); + } + setSubmittedQuery({ plain: '', parsed: [] }); + return [...newConversation, lastMessage]; + }); + } + setLoading(false); + return; + } + try { + const data = JSON.parse(ev.data); + if (data.Ok.ChatEvent) { + const newMessage = data.Ok.ChatEvent; + conversationsCache[conversation_id] = undefined; // clear cache on new answer + setConversation((prev) => { + const newConversation = prev?.slice(0, -1) || []; + const lastMessage = prev?.slice(-1)[0]; + const messageToAdd = { + author: ChatMessageAuthor.Server, + isLoading: true, + loadingSteps: mapLoadingSteps(newMessage.search_steps, t), + text: newMessage.answer, + conclusion: newMessage.conclusion, + queryId: newMessage.id, + responseTimestamp: newMessage.response_timestamp, + explainedFile: newMessage.focused_chunk?.repo_path, + }; + const lastMessages: ChatMessage[] = + lastMessage?.author === ChatMessageAuthor.Server + ? [messageToAdd] + : [...prev.slice(-1), messageToAdd]; + return [...newConversation, ...lastMessages]; + }); + // workaround: sometimes we get [^summary]: before it is removed from response + if (newMessage.answer?.length > 11 && !firstResultCame) { + if (newMessage.focused_chunk?.repo_path) { + openNewTab( + { + type: TabTypesEnum.FILE, + path: newMessage.focused_chunk.repo_path.path, + repoRef: newMessage.focused_chunk.repo_path.repo, + scrollToLine: + newMessage.focused_chunk.start_line > -1 + ? `${newMessage.focused_chunk.start_line}_${newMessage.focused_chunk.end_line}` + : undefined, + }, + side === 'left' ? 'right' : 'left', + ); + } + firstResultCame = true; + } + } else if (data.Ok.StreamEnd) { + const message = data.Ok.StreamEnd; + conversation_id = message.conversation_id; + setThreadId(message.thread_id); + setConversationId(message.conversation_id); + if (conversation.length < 2) { + updateTabProperty( + tabKey, + 'conversationId', + message.conversation_id, + side, + ); + } + eventSource.close(); + prevEventSource.current = null; + setLoading(false); + setConversation((prev) => { + const newConversation = prev.slice(0, -1); + const lastMessage = { + ...prev.slice(-1)[0], + isLoading: false, + }; + return [...newConversation, lastMessage]; + }); + refreshCurrentProjectConversations(); + setTimeout(() => focusInput(), 100); + return; + } else if (data.Err) { + setConversation((prev) => { + const lastMessageIsServer = + prev[prev.length - 1].author === ChatMessageAuthor.Server; + const newConversation = prev.slice( + 0, + lastMessageIsServer ? -2 : -1, + ); + const lastMessage: ChatMessageServer = { + ...(lastMessageIsServer + ? (prev.slice(-1)[0] as ChatMessageServer) + : { + author: ChatMessageAuthor.Server, + loadingSteps: [], + queryId: '', + responseTimestamp: new Date().toISOString(), + }), + isLoading: false, + error: + data.Err === 'request failed 5 times' + ? t( + 'Failed to get a response from OpenAI. Try again in a few moments.', + ) + : t( + "We couldn't answer your question. You can try asking again in a few moments, or rephrasing your question.", + ), + }; + if (!options) { + setInputValueImperatively( + ( + prev[ + prev.length - (lastMessageIsServer ? 2 : 1) + ] as ChatMessageUser + )?.parsedQuery || + prev[prev.length - 2]?.text || + submittedQuery.parsed, + ); + } + setSubmittedQuery({ plain: '', parsed: [] }); + return [...newConversation, lastMessage]; + }); + } + } catch (err) { + console.log('failed to parse response', err); + } + }; + return () => { + eventSource.close(); + }; + }, + [conversationId, t, queryIdToEdit, preferredAnswerSpeed, openNewTab, side], + ); + + useEffect(() => { + if (!submittedQuery.plain) { + return; + } + let userQuery = submittedQuery.plain; + let userQueryParsed = submittedQuery.parsed; + const options = submittedQuery.options; + if (submittedQuery.plain.startsWith('#explain_')) { + const [prefix, ending] = submittedQuery.plain.split(':'); + const [lineStart, lineEnd] = ending.split('-'); + const filePath = prefix.slice(9); + userQuery = t( + `Explain the purpose of the file {{filePath}}, from lines {{lineStart}} - {{lineEnd}}`, + { + lineStart: Number(lineStart) + 1, + lineEnd: Number(lineEnd) + 1, + filePath, + }, + ); + userQueryParsed = [{ type: ParsedQueryTypeEnum.TEXT, text: userQuery }]; + } + setConversation((prev) => { + return prev.length === 1 && submittedQuery.options + ? prev + : [ + ...prev, + { + author: ChatMessageAuthor.User, + text: userQuery, + parsedQuery: userQueryParsed, + isLoading: false, + }, + ]; + }); + makeSearch(userQuery, options); + }, [submittedQuery]); + + useEffect(() => { + if (conversation.length && conversation.length < 3 && !tabTitle) { + console.log('updateTabProperty'); + updateTabProperty( + tabKey, + 'title', + conversation[0].text, + side, + ); + } + }, [conversation, tabKey, side, tabTitle]); + + const stopGenerating = useCallback(() => { + prevEventSource.current?.close(); + setLoading(false); + setConversation((prev) => { + const newConversation = prev.slice(0, -1); + const lastMessage = { + ...prev.slice(-1)[0], + isLoading: false, + }; + return [...newConversation, lastMessage]; + }); + setTimeout(focusInput, 100); + }, []); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], stopGenerating } }; + }); + }, [stopGenerating]); + + const onMessageEdit = useCallback( + (parentQueryId: string, i: number) => { + setQueryIdToEdit(parentQueryId); + if (isLoading) { + stopGenerating(); + } + setHideMessagesFrom(i); + const mes = conversation[i] as ChatMessageUser; + setInputValueImperatively(mes.parsedQuery || mes.text!); + }, + [isLoading, conversation], + ); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], onMessageEdit } }; + }); + }, [onMessageEdit]); + + const onMessageEditCancel = useCallback(() => { + setQueryIdToEdit(''); + setInputValue({ plain: '', parsed: [] }); + setInputImperativeValue(null); + setHideMessagesFrom(null); + }, []); + useEffect(() => { + setChats((prev) => { + return { ...prev, [tabKey]: { ...prev[tabKey], onMessageEditCancel } }; + }); + }, [onMessageEditCancel]); + + useEffect(() => { + // if it was open from history and not updated from sse message + if (convId && project?.id && !conversation.length) { + getConversation(project.id, convId).then((resp) => { + const conv: ChatMessage[] = []; + let hasOpenedTab = false; + resp.exchanges.forEach((m) => { + // @ts-ignore + const userQuery = m.search_steps.find((s) => s.type === 'QUERY'); + const parsedQuery = mapUserQuery(m); + conv.push({ + author: ChatMessageAuthor.User, + text: m.query.raw_query || userQuery?.content?.query || '', + parsedQuery, + isFromHistory: true, + }); + conv.push({ + author: ChatMessageAuthor.Server, + isLoading: false, + loadingSteps: mapLoadingSteps(m.search_steps, t), + text: m.answer, + conclusion: m.conclusion, + queryId: m.id, + responseTimestamp: m.response_timestamp, + explainedFile: m.focused_chunk?.repo_path.path, + }); + if (!hasOpenedTab && m.focused_chunk?.repo_path) { + openNewTab( + { + type: TabTypesEnum.FILE, + path: m.focused_chunk.repo_path.path, + repoRef: m.focused_chunk.repo_path.repo, + scrollToLine: + m.focused_chunk.start_line > -1 + ? `${m.focused_chunk.start_line}_${m.focused_chunk.end_line}` + : undefined, + }, + side === 'left' ? 'right' : 'left', + ); + hasOpenedTab = true; + } + }); + setConversation(conv); + setThreadId(resp.thread_id); + setConversationId(convId); + }); + } + }, [convId, project?.id]); + + return null; +}; + +export default memo(ChatPersistentState); diff --git a/client/src/Project/CurrentTabContent/ChatTab/Conversation.tsx b/client/src/Project/CurrentTabContent/ChatTab/Conversation.tsx new file mode 100644 index 0000000000..38d921d2c7 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Conversation.tsx @@ -0,0 +1,68 @@ +import React, { memo, useContext, useMemo } from 'react'; +import ScrollToBottom from 'react-scroll-to-bottom'; +import { ChatMessageServer } from '../../../types/general'; +import { ProjectContext } from '../../../context/projectContext'; +import { ChatContext, ChatsContext } from '../../../context/chatsContext'; +import Input from './Input'; +import ScrollableContent from './ScrollableContent'; +import DeprecatedClientModal from './DeprecatedClientModal'; + +type Props = { + side: 'left' | 'right'; + tabKey: string; +}; + +const Conversation = ({ side, tabKey }: Props) => { + const { project } = useContext(ProjectContext.Current); + const { chats } = useContext(ChatsContext); + + const chatData: ChatContext | undefined = useMemo( + () => chats[tabKey], + [chats, tabKey], + ); + + return !chatData ? null : ( +
+ + + + + +
+ ); +}; + +export default memo(Conversation); diff --git a/client/src/Project/CurrentTabContent/ChatTab/DeprecatedClientModal.tsx b/client/src/Project/CurrentTabContent/ChatTab/DeprecatedClientModal.tsx new file mode 100644 index 0000000000..1c47a51a7d --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/DeprecatedClientModal.tsx @@ -0,0 +1,68 @@ +import { useContext } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { CloseSignIcon } from '../../../icons'; +import { DeviceContext } from '../../../context/deviceContext'; +import Button from '../../../components/Button'; +import Modal from '../../../components/Modal'; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +const DeprecatedClientModal = ({ isOpen, onClose }: Props) => { + const { t } = useTranslation(); + const { openLink, relaunch } = useContext(DeviceContext); + return ( + +
+
+
+

+ Update Required +

+

+ + We've made some exciting enhancements to bloop! To continue + enjoying the full functionality, including the natural language + search feature, please update your app to the latest version. + +

+

+ + To update your app, please visit our releases page on GitHub and + download the latest version manually. Thank you for using bloop. + +

+
+
+ + +
+
+
+ +
+
+
+ ); +}; + +export default DeprecatedClientModal; diff --git a/client/src/Project/CurrentTabContent/ChatTab/Input/InputCore.tsx b/client/src/Project/CurrentTabContent/ChatTab/Input/InputCore.tsx new file mode 100644 index 0000000000..0a3ee0e196 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Input/InputCore.tsx @@ -0,0 +1,211 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { EditorState, Transaction } from 'prosemirror-state'; +import { Schema } from 'prosemirror-model'; +import { keymap } from 'prosemirror-keymap'; +import { baseKeymap } from 'prosemirror-commands'; +import { + NodeViewComponentProps, + ProseMirror, + react, + ReactNodeViewConstructor, + useNodeViews, +} from '@nytimes/react-prosemirror'; +import { schema as basicSchema } from 'prosemirror-schema-basic'; +import * as icons from 'file-icons-js'; +import { useTranslation } from 'react-i18next'; +import { InputEditorContent, ParsedQueryType } from '../../../../types/general'; +import { getFileExtensionForLang } from '../../../../utils'; +import { blurInput } from '../../../../utils/domUtils'; +import { getMentionsPlugin } from './mentionPlugin'; +import { addMentionNodes, mapEditorContentToInputValue } from './utils'; +import { placeholderPlugin } from './placeholderPlugin'; + +const schema = new Schema({ + nodes: addMentionNodes(basicSchema.spec.nodes), + marks: basicSchema.spec.marks, +}); + +function Paragraph({ children }: NodeViewComponentProps) { + return

{children}

; +} + +const reactNodeViews: Record = { + paragraph: () => ({ + component: Paragraph, + dom: document.createElement('div'), + contentDOM: document.createElement('span'), + }), +}; + +type Props = { + getDataLang: (search: string) => Promise<{ id: string; display: string }[]>; + getDataPath: (search: string) => Promise<{ id: string; display: string }[]>; + getDataRepo: (search: string) => Promise<{ id: string; display: string }[]>; + initialValue?: Record | null; + onChange: (contents: InputEditorContent[]) => void; + onSubmit?: (s: { parsed: ParsedQueryType[]; plain: string }) => void; + placeholder: string; +}; + +const InputCore = ({ + getDataLang, + getDataPath, + getDataRepo, + initialValue, + onChange, + onSubmit, + placeholder, +}: Props) => { + const { t } = useTranslation(); + const mentionPlugin = useMemo( + () => + getMentionsPlugin({ + delay: 10, + getSuggestions: async ( + type: string, + text: string, + done: (s: Record[]) => void, + ) => { + const data = await Promise.all([ + getDataRepo(text), + getDataPath(text), + getDataLang(text), + ]); + done([...data[0], ...data[1], ...data[2]]); + }, + getSuggestionsHTML: (items) => { + return ( + '
' + + items + .map( + (i) => + `
${ + i.isFirst + ? `
+ ${t( + i.type === 'repo' + ? 'Repositories' + : i.type === 'dir' + ? 'Directories' + : i.type === 'lang' + ? 'Languages' + : 'Files', + )} +
` + : '' + }
${ + i.type === 'repo' + ? ` ` + : i.type === 'dir' + ? ` ` + : `` + }${i.display}
`, + ) + .join('') + + '
' + ); + }, + }), + [], + ); + + const plugins = useMemo(() => { + return [ + placeholderPlugin(placeholder), + react(), + mentionPlugin, + keymap({ + ...baseKeymap, + Escape: (state) => { + const key = Object.keys(state).find((k) => + k.startsWith('autosuggestions'), + ); + + // @ts-ignore + if (key && state[key]?.active) { + return true; + } + blurInput(); + return true; + }, + Enter: (state) => { + const key = Object.keys(state).find((k) => + k.startsWith('autosuggestions'), + ); + // @ts-ignore + if (key && state[key]?.active) { + return false; + } + const parts = state.toJSON().doc.content[0]?.content; + // trying to submit with no text + if (!parts) { + return false; + } + onSubmit?.(mapEditorContentToInputValue(parts)); + return true; + }, + 'Ctrl-Enter': baseKeymap.Enter, + 'Cmd-Enter': baseKeymap.Enter, + 'Shift-Enter': baseKeymap.Enter, + }), + ]; + }, [onSubmit]); + + const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); + const [mount, setMount] = useState(null); + const [state, setState] = useState( + EditorState.create({ + doc: initialValue + ? schema.topNodeType.create(null, [schema.nodeFromJSON(initialValue)]) + : undefined, + schema, + plugins, + }), + ); + + useEffect(() => { + if (mount) { + setState( + EditorState.create({ + schema, + plugins, + doc: initialValue + ? schema.topNodeType.create(null, [ + schema.nodeFromJSON(initialValue), + ]) + : undefined, + }), + ); + } + }, [mount, initialValue, plugins]); + + const dispatchTransaction = useCallback( + (tr: Transaction) => setState((oldState) => oldState.apply(tr)), + [], + ); + + useEffect(() => { + const newValue = state.toJSON().doc.content[0]?.content; + onChange(newValue || []); + }, [state]); + + return ( +
+ +
+ {renderNodeViews()} + +
+ ); +}; + +export default memo(InputCore); diff --git a/client/src/Project/CurrentTabContent/ChatTab/Input/index.tsx b/client/src/Project/CurrentTabContent/ChatTab/Input/index.tsx new file mode 100644 index 0000000000..d5181c69be --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Input/index.tsx @@ -0,0 +1,280 @@ +import { + Dispatch, + memo, + SetStateAction, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { EnvContext } from '../../../../context/envContext'; +import { + ChatMessage, + ChatMessageServer, + InputEditorContent, + ParsedQueryType, +} from '../../../../types/general'; +import { getAutocomplete } from '../../../../services/api'; +import { FileResItem, LangItem, RepoItem } from '../../../../types/api'; +import useKeyboardNavigation from '../../../../hooks/useKeyboardNavigation'; +import KeyboardHint from '../../../../components/KeyboardHint'; +import { focusInput } from '../../../../utils/domUtils'; +import InputCore from './InputCore'; +import { mapEditorContentToInputValue } from './utils'; + +type Props = { + value?: { parsed: ParsedQueryType[]; plain: string }; + valueToEdit?: Record | null; + generationInProgress?: boolean; + isStoppable?: boolean; + onStop?: () => void; + setInputValue: Dispatch< + SetStateAction<{ parsed: ParsedQueryType[]; plain: string }> + >; + selectedLines?: [number, number] | null; + setSelectedLines?: (l: [number, number] | null) => void; + queryIdToEdit?: string; + onMessageEditCancel?: () => void; + conversation: ChatMessage[]; + hideMessagesFrom: number | null; + setConversation: Dispatch>; + setSubmittedQuery: Dispatch< + SetStateAction<{ parsed: ParsedQueryType[]; plain: string }> + >; + submittedQuery: { parsed: ParsedQueryType[]; plain: string }; +}; + +type SuggestionType = { + id: string; + display: string; + type: 'file' | 'dir' | 'lang' | 'repo'; + isFirst: boolean; +}; + +const ConversationInput = ({ + value, + valueToEdit, + setInputValue, + generationInProgress, + isStoppable, + onStop, + selectedLines, + setSelectedLines, + queryIdToEdit, + onMessageEditCancel, + conversation, + hideMessagesFrom, + setConversation, + setSubmittedQuery, + submittedQuery, +}: Props) => { + const { t } = useTranslation(); + const { envConfig } = useContext(EnvContext); + const [isInputAtBottom, setIsInputAtBottom] = useState(false); + const [initialValue, setInitialValue] = useState< + Record | null | undefined + >({ + type: 'paragraph', + content: value?.parsed + .filter((pq) => ['path', 'lang', 'text'].includes(pq.type)) + .map((pq) => + pq.type === 'text' + ? { type: 'text', text: pq.text } + : { + type: 'mention', + attrs: { + id: pq.text, + display: pq.text, + type: pq.type, + isFirst: false, + }, + }, + ), + }); + const [hasRendered, setHasRendered] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + setHasRendered(true); + setTimeout(focusInput, 500); + }, []); + + useEffect(() => { + if (hasRendered) { + setInitialValue(valueToEdit); + } + }, [valueToEdit]); + + // useEffect(() => { + // if (containerRef.current) { + // setIsInputAtBottom(containerRef.current) + // } + // }, [conversation]); + + const onSubmit = useCallback( + (value: { parsed: ParsedQueryType[]; plain: string }) => { + if ( + (conversation[conversation.length - 1] as ChatMessageServer) + ?.isLoading || + !value.plain.trim() + ) { + return; + } + if (hideMessagesFrom !== null) { + setConversation((prev) => prev.slice(0, hideMessagesFrom)); + } + setSubmittedQuery(value); + }, + [conversation, submittedQuery, hideMessagesFrom], + ); + + const onChangeInput = useCallback((inputState: InputEditorContent[]) => { + setInputValue(mapEditorContentToInputValue(inputState)); + }, []); + + const onSubmitButtonClicked = useCallback(() => { + if (value && onSubmit) { + onSubmit(value); + } + }, [value, onSubmit]); + const getDataPath = useCallback(async (search: string) => { + const respPath = await getAutocomplete(`path:${search}&content=false`); + const fileResults = respPath.data.filter( + (d): d is FileResItem => d.kind === 'file_result', + ); + const dirResults = fileResults + .filter((d) => d.data.is_dir) + .map((d) => d.data.relative_path.text); + const filesResults = fileResults + .filter((d) => !d.data.is_dir) + .map((d) => d.data.relative_path.text); + const results: SuggestionType[] = []; + filesResults.forEach((fr, i) => { + results.push({ id: fr, display: fr, type: 'file', isFirst: i === 0 }); + }); + dirResults.forEach((fr, i) => { + results.push({ id: fr, display: fr, type: 'dir', isFirst: i === 0 }); + }); + return results; + }, []); + + const getDataLang = useCallback( + async ( + search: string, + // callback: (a: { id: string; display: string }[]) => void, + ) => { + const respLang = await getAutocomplete(`lang:${search}&content=false`); + const langResults = respLang.data + .filter((d): d is LangItem => d.kind === 'lang') + .map((d) => d.data); + const results: SuggestionType[] = []; + langResults.forEach((fr, i) => { + results.push({ id: fr, display: fr, type: 'lang', isFirst: i === 0 }); + }); + return results; + }, + [], + ); + + const getDataRepo = useCallback( + async ( + search: string, + // callback: (a: { id: string; display: string }[]) => void, + ) => { + const respRepo = await getAutocomplete( + `repo:${search}&content=false&path=false&file=false`, + ); + const repoResults = respRepo.data + .filter((d): d is RepoItem => d.kind === 'repository_result') + .map((d) => d.data); + const results: SuggestionType[] = []; + repoResults.forEach((rr, i) => { + results.push({ + id: rr.name.text, + display: rr.name.text.replace('github.com/', ''), + type: 'repo', + isFirst: i === 0, + }); + }); + return results; + }, + [], + ); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if ( + e.key === 'Escape' && + ((onMessageEditCancel && queryIdToEdit) || (isStoppable && onStop)) + ) { + e.preventDefault(); + e.stopPropagation(); + onMessageEditCancel?.(); + onStop?.(); + } + }, + [onMessageEditCancel, isStoppable, onStop], + ); + useKeyboardNavigation(handleKeyEvent, !queryIdToEdit && !isStoppable); + + return ( +
+
+ {t('avatar')} +
+
+

+ You +

+ +
+ {isStoppable && ( + + )} + {!!queryIdToEdit && ( + + )} + +
+
+
+ ); +}; + +export default memo(ConversationInput); diff --git a/client/src/Project/CurrentTabContent/ChatTab/Input/mentionPlugin.ts b/client/src/Project/CurrentTabContent/ChatTab/Input/mentionPlugin.ts new file mode 100644 index 0000000000..9aa90f33c3 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Input/mentionPlugin.ts @@ -0,0 +1,401 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; +import { ResolvedPos } from 'prosemirror-model'; + +export function getRegexp(mentionTrigger: string, allowSpace?: boolean) { + return allowSpace + ? new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+]*\\s?[\\w-\\+.]*)$') + : new RegExp('(^|\\s)' + mentionTrigger + '([\\w-\\+.]*)$'); +} + +const insertAfterSelect = String.fromCharCode(160); + +export function getMatch( + $position: ResolvedPos, + opts: { + mentionTrigger: string; + allowSpace?: boolean; + }, +) { + try { + // take current para text content upto cursor start. + // this makes the regex simpler and parsing the matches easier. + const parastart = $position.before(); + const text = $position.doc.textBetween( + parastart, + $position.pos, + '\n', + '\0', + ); + + const regex = getRegexp(opts.mentionTrigger, opts.allowSpace); + + const match = text.match(regex); + + // if match found, return match with useful information. + if (match) { + // adjust match.index to remove the matched extra space + match.index = + match[0].startsWith(' ') || match[0].startsWith(insertAfterSelect) + ? (match.index || 0) + 1 + : match.index; + match[0] = + match[0].startsWith(' ') || match[0].startsWith(insertAfterSelect) + ? match[0].substring(1, match[0].length) + : match[0]; + + // The absolute position of the match in the document + const from = $position.start() + match.index!; + const to = from + match[0].length; + + const queryText = match[2]; + + return { + range: { from: from, to: to }, + queryText: queryText, + type: 'mention', + }; + } + // else if no match don't return anything. + } catch (e) { + console.log(e); + } +} + +/** + * Util to debounce call to a function. + * >>> debounce(function(){}, 1000, this) + */ +export const debounce = (function () { + let timeoutId: number; + return function (func: () => void, timeout: number, context: any): number { + // @ts-ignore + context = context || this; + clearTimeout(timeoutId); + timeoutId = window.setTimeout(function () { + // @ts-ignore + func.apply(context, arguments); + }, timeout); + + return timeoutId; + }; +})(); + +type State = { + active: boolean; + range: { + from: number; + to: number; + }; + type: string; + text: string; + suggestions: Record[]; + index: number; +}; + +const getNewState = function () { + return { + active: false, + range: { + from: 0, + to: 0, + }, + type: '', + text: '', + suggestions: [], + index: 0, // current active suggestion index + }; +}; + +type Options = { + mentionTrigger: string; + allowSpace?: boolean; + activeClass: string; + suggestionTextClass?: string; + getSuggestions: ( + type: string, + text: string, + done: (s: Record[]) => void, + ) => void; + delay: number; + getSuggestionsHTML: (items: Record[], type: string) => string; +}; + +export function getMentionsPlugin(opts: Partial) { + // default options + const defaultOpts = { + mentionTrigger: '@', + allowSpace: false, + getSuggestions: ( + type: string, + text: string, + cb: (s: { name: string }[]) => void, + ) => { + cb([]); + }, + getSuggestionsHTML: (items: { name: string }[]) => + '
' + + items + .map((i) => '
' + i.name + '
') + .join('') + + '
', + activeClass: 'suggestion-item-active', + suggestionTextClass: 'prosemirror-suggestion', + maxNoOfSuggestions: 10, + delay: 500, + }; + + const options = Object.assign({}, defaultOpts, opts) as Options; + + // timeoutId for clearing debounced calls + let showListTimeoutId: number; + + // dropdown element + const el = document.createElement('div'); + + const showList = function ( + view: EditorView, + state: State, + suggestions: Record[], + opts: Options, + ) { + try { + el.innerHTML = opts.getSuggestionsHTML(suggestions, state.type); + + // attach new item event handlers + el.querySelectorAll('.suggestion-item').forEach( + function (itemNode, index) { + itemNode.addEventListener('click', function () { + select(view, state, opts); + view.focus(); + }); + // TODO: setIndex() needlessly queries. + // We already have the itemNode. SHOULD OPTIMIZE. + itemNode.addEventListener('mouseover', function () { + setIndex(index, state, opts); + }); + itemNode.addEventListener('mouseout', function () { + setIndex(index, state, opts); + }); + }, + ); + + // highlight first element by default - like Facebook. + addClassAtIndex(state.index, opts.activeClass); + + // TODO: knock off domAtPos usage. It's not documented and is not officially a public API. + // It's used currently, only to optimize the the query for textDOM + const node = view.domAtPos(view.state.selection.$from.pos); + const paraDOM = node.node; + const textDOM = (paraDOM as HTMLElement).querySelector( + '.' + opts.suggestionTextClass, + ); + + const offset = textDOM?.getBoundingClientRect(); + + document.body.appendChild(el); + el.classList.add('suggestion-item-container'); + el.style.position = 'fixed'; + el.style.left = -9999 + 'px'; + const offsetLeft = offset?.left || 0; + const offsetTop = offset?.top || 0; + setTimeout(() => { + el.style.left = + offsetLeft + el.clientWidth < window.innerWidth + ? offsetLeft + 'px' + : offsetLeft + + (window.innerWidth - (offsetLeft + el.clientWidth) - 10) + + 'px'; + el.style.bottom = + window.innerHeight - offsetTop + el.clientHeight > window.innerHeight + ? window.innerHeight - offsetTop - el.clientHeight - 20 + 'px' + : window.innerHeight - offsetTop + 'px'; + }, 10); + + el.style.display = 'block'; + el.style.zIndex = '80'; + } catch (e) { + console.log(e); + } + }; + + const hideList = function () { + el.style.display = 'none'; + }; + + const removeClassAtIndex = function (index: number, className: string) { + const itemList = el.querySelector('.suggestion-item-list')?.childNodes; + const prevItem = itemList?.[index]; + (prevItem as HTMLElement)?.classList.remove(className); + }; + + const addClassAtIndex = function (index: number, className: string) { + const itemList = el.querySelector('.suggestion-item-list')?.childNodes; + const prevItem = itemList?.[index]; + (prevItem as HTMLElement)?.classList.add(className); + return prevItem as HTMLElement | undefined; + }; + + const setIndex = function (index: number, state: State, opts: Options) { + removeClassAtIndex(state.index, opts.activeClass); + state.index = index; + addClassAtIndex(state.index, opts.activeClass); + }; + + const goNext = function (view: EditorView, state: State, opts: Options) { + removeClassAtIndex(state.index, opts.activeClass); + state.index++; + state.index = state.index === state.suggestions.length ? 0 : state.index; + const el = addClassAtIndex(state.index, opts.activeClass); + el?.scrollIntoView({ block: 'nearest' }); + }; + + const goPrev = function (view: EditorView, state: State, opts: Options) { + removeClassAtIndex(state.index, opts.activeClass); + state.index--; + state.index = + state.index === -1 ? state.suggestions.length - 1 : state.index; + const el = addClassAtIndex(state.index, opts.activeClass); + el?.scrollIntoView({ block: 'nearest' }); + }; + + const select = function (view: EditorView, state: State, opts: Options) { + const item = state.suggestions[state.index]; + const attrs = { + ...item, + }; + const node = view.state.schema.nodes[state.type].create(attrs); + const spaceNode = view.state.schema.text(insertAfterSelect); + + const tr = view.state.tr.replaceWith(state.range.from, state.range.to, [ + node, + spaceNode, + ]); + + //var newState = view.state.apply(tr); + //view.updateState(newState); + view.dispatch(tr); + }; + + return new Plugin({ + key: new PluginKey('autosuggestions'), + + // we will need state to track if suggestion dropdown is currently active or not + state: { + init() { + return getNewState(); + }, + + apply(tr, state) { + try { + // compute state.active for current transaction and return + const newState = getNewState(); + const selection = tr.selection; + if (selection.from !== selection.to) { + return newState; + } + + const $position = selection.$from; + const match = getMatch($position, options); + + // if match found update state + if (match) { + newState.active = true; + newState.range = match.range; + newState.type = match.type!; + newState.text = match.queryText; + } + + return newState; + } catch (e) { + console.log(e); + return state; + } + }, + }, + + // We'll need props to hi-jack keydown/keyup & enter events when suggestion dropdown + // is active. + props: { + handleKeyDown(view, e) { + const state = this.getState(view.state); + + if (!state?.active && !state?.suggestions.length) { + return false; + } + + if (e.key === 'ArrowDown') { + e.stopPropagation(); + goNext(view, state, options); + return true; + } else if (e.key === 'ArrowUp') { + e.stopPropagation(); + goPrev(view, state, options); + return true; + } else if (e.key === 'Enter') { + e.stopPropagation(); + select(view, state, options); + return true; + } else if (e.key === 'Escape') { + e.stopPropagation(); + clearTimeout(showListTimeoutId); + hideList(); + // @ts-ignore + this.state = getNewState(); + return true; + } else { + // didn't handle. handover to prosemirror for handling. + return false; + } + }, + + // to decorate the currently active @mention text in ui + decorations(editorState) { + const { active, range } = this.getState(editorState) || {}; + + if (!active || !range) return null; + + return DecorationSet.create(editorState.doc, [ + Decoration.inline(range.from, range.to, { + nodeName: 'span', + class: options.suggestionTextClass, + }), + ]); + }, + }, + + // To track down state mutations and add dropdown reactions + view() { + return { + update: (view) => { + const state = this.key?.getState(view.state); + if (!state.active) { + hideList(); + clearTimeout(showListTimeoutId); + return; + } + // debounce the call to avoid multiple requests + showListTimeoutId = debounce( + function () { + // get suggestions and set new state + options.getSuggestions( + state.type, + state.text, + function (suggestions) { + // update `state` argument with suggestions + state.suggestions = suggestions; + showList(view, state, suggestions, options); + }, + ); + }, + options.delay, + this, + ); + }, + destroy: () => { + hideList(); + }, + }; + }, + }); +} diff --git a/client/src/Project/CurrentTabContent/ChatTab/Input/nodes.ts b/client/src/Project/CurrentTabContent/ChatTab/Input/nodes.ts new file mode 100644 index 0000000000..b78a04715a --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Input/nodes.ts @@ -0,0 +1,101 @@ +import * as icons from 'file-icons-js'; +import { type AttributeSpec, type NodeSpec } from 'prosemirror-model'; +import { getFileExtensionForLang, splitPath } from '../../../../utils'; + +export const mentionNode: NodeSpec = { + group: 'inline', + inline: true, + atom: true, + + attrs: { + id: '' as AttributeSpec, + display: '' as AttributeSpec, + type: 'lang' as AttributeSpec, + isFirst: '' as AttributeSpec, + }, + + selectable: false, + draggable: false, + + toDOM: (node) => { + const isDir = + node.attrs.type === 'dir' || + node.attrs.display.endsWith('/') || + node.attrs.display.endsWith('\\'); + const folderIcon = document.createElement('span'); + folderIcon.innerHTML = ` + + `; + folderIcon.className = 'w-4 h-4 flex-shrink-0'; + + const repoIcon = document.createElement('span'); + repoIcon.innerHTML = ` + + `; + repoIcon.className = 'w-4 h-4 flex-shrink-0'; + + return [ + 'span', + { + 'data-type': node.attrs.type, + 'data-id': node.attrs.id, + 'data-first': node.attrs.isFirst, + 'data-display': node.attrs.display, + class: + 'prosemirror-tag-node inline-flex gap-1 h-[22px] items-center align-bottom bg-bg-base border border-bg-border rounded px-1', + }, + isDir + ? folderIcon + : node.attrs.type === 'repo' + ? repoIcon + : [ + 'span', + { + class: `text-left w-4 h-4 file-icon flex-shrink-0 inline-flex items-center ${ + icons.getClassWithColor( + (node.attrs.type === 'lang' + ? node.attrs.display.includes(' ') + ? '.txt' + : getFileExtensionForLang(node.attrs.display, true) + : node.attrs.display) || '.txt', + ) || icons.getClassWithColor('index.txt') + }`, + }, + '', + ], + node.attrs.type === 'lang' + ? node.attrs.display + : isDir + ? splitPath(node.attrs.display).slice(-2)[0] + : splitPath(node.attrs.display).pop(), + ]; + }, + + parseDOM: [ + { + // match tag with following CSS Selector + tag: 'span[data-type][data-id][data-first][data-display]', + + getAttrs: (dom) => { + const id = (dom as HTMLElement).getAttribute('data-id'); + const type = (dom as HTMLElement).getAttribute('data-type'); + const isFirst = (dom as HTMLElement).getAttribute('data-first'); + const display = (dom as HTMLElement).getAttribute('data-display'); + return { + id, + type, + isFirst, + display, + }; + }, + }, + ], +}; diff --git a/client/src/Project/CurrentTabContent/ChatTab/Input/placeholderPlugin.ts b/client/src/Project/CurrentTabContent/ChatTab/Input/placeholderPlugin.ts new file mode 100644 index 0000000000..5910bf246e --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Input/placeholderPlugin.ts @@ -0,0 +1,20 @@ +import { Plugin } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; + +export const placeholderPlugin = (text: string) => { + const update = (view: EditorView) => { + if (view.state.doc.content.size > 2) { + view.dom.removeAttribute('data-placeholder'); + } else { + view.dom.setAttribute('data-placeholder', text); + } + }; + + return new Plugin({ + view(view) { + update(view); + + return { update }; + }, + }); +}; diff --git a/client/src/Project/CurrentTabContent/ChatTab/Input/utils.ts b/client/src/Project/CurrentTabContent/ChatTab/Input/utils.ts new file mode 100644 index 0000000000..1d3667045a --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Input/utils.ts @@ -0,0 +1,46 @@ +import OrderedMap from 'orderedmap'; +import { type NodeSpec } from 'prosemirror-model'; +import { + InputEditorContent, + ParsedQueryTypeEnum, +} from '../../../../types/general'; +import { mentionNode } from './nodes'; + +export function addMentionNodes(nodes: OrderedMap) { + return nodes.append({ + mention: mentionNode, + }); +} + +export const mapEditorContentToInputValue = ( + inputState: InputEditorContent[], +) => { + const getType = (type: string) => + type === 'lang' || type === 'repo' ? type : 'path'; + const newValue = inputState + .map((s) => + s.type === 'mention' + ? `${getType(s.attrs.type)}:${s.attrs.id}` + : s.type === 'text' + ? s.text?.replace(new RegExp(String.fromCharCode(160), 'g'), ' ') + : '', + ) + .join(''); + const newValueParsed = inputState.map((s) => + s.type === 'mention' + ? { + type: + s.attrs.type === 'lang' + ? ParsedQueryTypeEnum.LANG + : s.attrs.type === 'repo' + ? ParsedQueryTypeEnum.REPO + : ParsedQueryTypeEnum.PATH, + text: s.attrs.id, + } + : { type: ParsedQueryTypeEnum.TEXT, text: s.text }, + ); + return { + plain: newValue, + parsed: newValueParsed, + }; +}; diff --git a/client/src/Project/CurrentTabContent/ChatTab/Message/LoadingStep.tsx b/client/src/Project/CurrentTabContent/ChatTab/Message/LoadingStep.tsx new file mode 100644 index 0000000000..67e2468a04 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Message/LoadingStep.tsx @@ -0,0 +1,43 @@ +import { memo, useCallback, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import FileChip from '../../../../components/Chips/FileChip'; +import { ChatLoadingStep, TabTypesEnum } from '../../../../types/general'; +import { TabsContext } from '../../../../context/tabsContext'; + +type Props = ChatLoadingStep & { + side: 'left' | 'right'; + repo?: string; +}; + +const LoadingStep = ({ type, path, displayText, side, repo }: Props) => { + const { t } = useTranslation(); + const { openNewTab } = useContext(TabsContext.Handlers); + + const handleClickFile = useCallback(() => { + if (type === 'proc' && repo && path) { + openNewTab( + { + type: TabTypesEnum.FILE, + repoRef: repo, + path, + }, + side === 'left' ? 'right' : 'left', + ); + } + }, [path, repo, side]); + + return ( +
+ {type === 'proc' ? t('Reading ') : displayText} + {type === 'proc' ? ( + + ) : null} +
+ ); +}; + +export default memo(LoadingStep); diff --git a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/LangChip.tsx b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/LangChip.tsx similarity index 76% rename from client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/LangChip.tsx rename to client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/LangChip.tsx index 9890d58315..102ded050a 100644 --- a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/LangChip.tsx +++ b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/LangChip.tsx @@ -1,5 +1,5 @@ -import FileIcon from '../../../../FileIcon'; import { getFileExtensionForLang } from '../../../../../utils'; +import FileIcon from '../../../../../components/FileIcon'; type Props = { lang: string; @@ -8,7 +8,7 @@ type Props = { const LangChip = ({ lang }: Props) => { return ( diff --git a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/PathChip.tsx b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/PathChip.tsx similarity index 71% rename from client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/PathChip.tsx rename to client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/PathChip.tsx index f34ccfe25e..ff337ccea4 100644 --- a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/PathChip.tsx +++ b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/PathChip.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { FolderClosed, ArrowOut } from '../../../../../icons'; -import FileIcon from '../../../../FileIcon'; +import { FolderIcon } from '../../../../../icons'; +import FileIcon from '../../../../../components/FileIcon'; import { splitPath } from '../../../../../utils'; type Props = { @@ -11,12 +11,12 @@ const PathChip = ({ path }: Props) => { const isFolder = useMemo(() => path.endsWith('/'), [path]); return ( {isFolder ? ( - + ) : ( )} diff --git a/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/RepoChip.tsx b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/RepoChip.tsx new file mode 100644 index 0000000000..01fb16764e --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/RepoChip.tsx @@ -0,0 +1,22 @@ +import { RepositoryIcon } from '../../../../../icons'; +import { splitPath } from '../../../../../utils'; + +type Props = { + name: string; +}; + +const RepoChip = ({ name }: Props) => { + return ( + + + + {splitPath(name).pop()} + + + ); +}; + +export default RepoChip; diff --git a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/index.tsx b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/index.tsx similarity index 81% rename from client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/index.tsx rename to client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/index.tsx index 17a671b16d..20355a8ffd 100644 --- a/client/src/components/Chat/ChatBody/ConversationMessage/UserParsedQuery/index.tsx +++ b/client/src/Project/CurrentTabContent/ChatTab/Message/UserParsedQuery/index.tsx @@ -5,6 +5,7 @@ import { } from '../../../../../types/general'; import PathChip from './PathChip'; import LangChip from './LangChip'; +import RepoChip from './RepoChip'; type Props = { textQuery: string; @@ -13,7 +14,7 @@ type Props = { const UserParsedQuery = ({ textQuery, parsedQuery }: Props) => { return ( -
+ {parsedQuery ? parsedQuery.map((p, i) => p.type === ParsedQueryTypeEnum.TEXT ? ( @@ -22,10 +23,12 @@ const UserParsedQuery = ({ textQuery, parsedQuery }: Props) => { ) : p.type === ParsedQueryTypeEnum.LANG ? ( + ) : p.type === ParsedQueryTypeEnum.REPO ? ( + ) : null, ) : textQuery} -
+
); }; diff --git a/client/src/Project/CurrentTabContent/ChatTab/Message/index.tsx b/client/src/Project/CurrentTabContent/ChatTab/Message/index.tsx new file mode 100644 index 0000000000..5df2e3aae2 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/Message/index.tsx @@ -0,0 +1,259 @@ +import { memo, useCallback, useContext, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { format } from 'date-fns'; +import { + ChatLoadingStep, + ChatMessageAuthor, + ParsedQueryType, +} from '../../../../types/general'; +import { EnvContext } from '../../../../context/envContext'; +import MarkdownWithCode from '../../../../components/MarkdownWithCode'; +import Button from '../../../../components/Button'; +import { + CheckListIcon, + LikeIcon, + PencilIcon, + UnlikeIcon, + WarningSignIcon, +} from '../../../../icons'; +import { getDateFnsLocale } from '../../../../utils'; +import SpinLoaderContainer from '../../../../components/Loaders/SpinnerLoader'; +import { + getPlainFromStorage, + LOADING_STEPS_SHOWN_KEY, + savePlainToStorage, +} from '../../../../services/storage'; +import { LocaleContext } from '../../../../context/localeContext'; +import { upvoteAnswer } from '../../../../services/api'; +import CopyButton from '../../../../components/MarkdownWithCode/CopyButton'; +import UserParsedQuery from './UserParsedQuery'; +import LoadingStep from './LoadingStep'; + +type Props = { + author: ChatMessageAuthor; + text: string; + parsedQuery?: ParsedQueryType[]; + error?: string; + threadId: string; + queryId: string; + responseTimestamp: string | null; + showInlineFeedback: boolean; + isLoading?: boolean; + loadingSteps?: ChatLoadingStep[]; + i: number; + onMessageEdit: (queryId: string, i: number) => void; + singleFileExplanation?: boolean; + side: 'left' | 'right'; + projectId: string; +}; + +const ConversationMessage = ({ + author, + text, + parsedQuery, + i, + queryId, + onMessageEdit, + singleFileExplanation, + threadId, + isLoading, + loadingSteps, + showInlineFeedback, + responseTimestamp, + error, + side, + projectId, +}: Props) => { + const { t } = useTranslation(); + const { envConfig } = useContext(EnvContext); + const { locale } = useContext(LocaleContext); + const [isUpvote, setIsUpvote] = useState(false); + const [isDownvote, setIsDownvote] = useState(false); + const [isLoadingStepsShown, setLoadingStepsShown] = useState( + getPlainFromStorage(LOADING_STEPS_SHOWN_KEY) + ? !!Number(getPlainFromStorage(LOADING_STEPS_SHOWN_KEY)) + : true, + ); + + useEffect(() => { + savePlainToStorage( + LOADING_STEPS_SHOWN_KEY, + isLoadingStepsShown ? '1' : '0', + ); + }, [isLoadingStepsShown]); + + const toggleStepsShown = useCallback(() => { + setLoadingStepsShown((prev) => !prev); + }, []); + + const handleEdit = useCallback(() => { + onMessageEdit(queryId, i); + }, [onMessageEdit, queryId, i]); + + const handleUpvote = useCallback(() => { + setIsUpvote(true); + setIsDownvote(false); + return upvoteAnswer(projectId, threadId, queryId, { type: 'positive' }); + }, [showInlineFeedback, envConfig.tracking_id, threadId, queryId, projectId]); + + const handleDownvote = useCallback(() => { + setIsUpvote(false); + setIsDownvote(true); + return upvoteAnswer(projectId, threadId, queryId, { + type: 'negative', + feedback: '', + }); + }, [showInlineFeedback, envConfig.tracking_id, threadId, queryId, projectId]); + + return ( +
+ {error ? ( +
+
+ +
+

{error}

+
+ ) : ( + <> +
+
+ {author === ChatMessageAuthor.User ? ( + {t('avatar')} + ) : isLoading ? ( + + ) : ( + bloop + )} +
+ {(isUpvote || isDownvote) && ( +
+ {isUpvote ? ( + + ) : ( + + )} +
+ )} +
+
+
+ {author === ChatMessageAuthor.User ? You : 'bloop'} + {author === ChatMessageAuthor.Server && ( +

+ ·{' '} + {isLoading ? ( + Streaming response... + ) : responseTimestamp ? ( + format( + new Date(responseTimestamp), + 'hh:mm aa', + getDateFnsLocale(locale), + ) + ) : null} +

+ )} + {author === ChatMessageAuthor.Server && ( + + )} +
+ {!!loadingSteps?.length && ( +
+ {loadingSteps.map((s, i) => ( + + ))} +
+ )} +
+ {author === ChatMessageAuthor.Server ? ( + + ) : ( + + )} +
+
+
+ {author === ChatMessageAuthor.User ? ( + + ) : ( + !isLoading && ( + <> + + + + ) + )} + +
+ + )} +
+ ); +}; + +export default memo(ConversationMessage); diff --git a/client/src/Project/CurrentTabContent/ChatTab/ScrollableContent.tsx b/client/src/Project/CurrentTabContent/ChatTab/ScrollableContent.tsx new file mode 100644 index 0000000000..433584f181 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/ScrollableContent.tsx @@ -0,0 +1,90 @@ +import { Fragment, memo, useEffect } from 'react'; +import { Trans } from 'react-i18next'; +import { useScrollToBottom } from 'react-scroll-to-bottom'; +import { ChatMessageAuthor, ChatMessageServer } from '../../../types/general'; +import { WarningSignIcon } from '../../../icons'; +import { ChatContext } from '../../../context/chatsContext'; +import StarterMessage from './StarterMessage'; +import Message from './Message'; + +type Props = { + chatData: ChatContext; + side: 'left' | 'right'; + projectId: string; +}; + +const ScrollableContent = ({ chatData, side, projectId }: Props) => { + const scroll = useScrollToBottom(); + + useEffect(() => { + if (chatData.submittedQuery.plain) { + scroll({ behavior: 'smooth' }); + } + }, [chatData.submittedQuery]); + + return ( + + + {(chatData.hideMessagesFrom === null + ? chatData.conversation + : chatData.conversation.slice(0, chatData.hideMessagesFrom + 1) + ).map((m, i) => ( + + ))} + {chatData.hideMessagesFrom !== null && ( +
+
+ +
+

+ + Editing previously submitted questions will discard all answers + and questions following it + +

+
+ )} +
+ ); +}; + +export default memo(ScrollableContent); diff --git a/client/src/Project/CurrentTabContent/ChatTab/StarterMessage.tsx b/client/src/Project/CurrentTabContent/ChatTab/StarterMessage.tsx new file mode 100644 index 0000000000..f1cb45b8bb --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/StarterMessage.tsx @@ -0,0 +1,100 @@ +import { memo, useCallback, useContext, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { ChatBubblesIcon } from '../../../icons'; +import { TutorialQuestionType } from '../../../types/api'; +import { getTutorialQuestions } from '../../../services/api'; +import { ProjectContext } from '../../../context/projectContext'; + +type Props = { + isEmptyConversation: boolean; + setInputValueImperatively: (v: string) => void; +}; + +const StarterMessage = ({ + isEmptyConversation, + setInputValueImperatively, +}: Props) => { + useTranslation(); + const [tutorials, setTutorials] = useState([]); + const { project } = useContext(ProjectContext.Current); + + const getDiverseTutorials = useCallback(async () => { + if (project?.repos.length) { + const tutorials = []; + let tutorialsPerRepo = Math.floor(10 / project.repos.length); + let remainingTutorials = 10; + + for (const repo of project.repos) { + const repoTutorials = await getTutorialQuestions(repo.repo.ref); + + const tutorialsToAdd = Math.min( + tutorialsPerRepo, + repoTutorials.questions.length, + remainingTutorials, + ); + + tutorials.push(...repoTutorials.questions.slice(0, tutorialsToAdd)); + + remainingTutorials -= tutorialsToAdd; + + if (remainingTutorials <= 0) { + break; + } + } + + setTutorials(tutorials); + } + }, [project?.repos]); + + useEffect(() => { + getDiverseTutorials(); + }, [getDiverseTutorials]); + + return ( +
+
+ bloop +
+
+

bloop

+

+ + Hi, I am bloop! In{' '} + + + Chat mode + {' '} + I can answer any questions related to any of your repositories. + +

+ {isEmptyConversation && !!tutorials.length && ( +

+ + Below are a few suggestions you can ask me to get started: + +

+ )} + {isEmptyConversation && !!tutorials.length && ( +
+ {tutorials.map((t, i) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default memo(StarterMessage); diff --git a/client/src/Project/CurrentTabContent/ChatTab/index.tsx b/client/src/Project/CurrentTabContent/ChatTab/index.tsx new file mode 100644 index 0000000000..3a5eae4aa7 --- /dev/null +++ b/client/src/Project/CurrentTabContent/ChatTab/index.tsx @@ -0,0 +1,134 @@ +import React, { + memo, + useCallback, + useContext, + useEffect, + useMemo, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import Button from '../../../components/Button'; +import { + ChatBubblesIcon, + MoreHorizontalIcon, + SplitViewIcon, +} from '../../../icons'; +import Dropdown from '../../../components/Dropdown'; +import { checkEventKeys } from '../../../utils/keyboardUtils'; +import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; +import { TabsContext } from '../../../context/tabsContext'; +import { ChatTabType } from '../../../types/general'; +import { ProjectContext } from '../../../context/projectContext'; +import { CommandBarContext } from '../../../context/commandBarContext'; +import { openInSplitViewShortcut } from '../../../consts/commandBar'; +import Conversation from './Conversation'; +import ActionsDropdown from './ActionsDropdown'; + +type Props = ChatTabType & { + noBorder?: boolean; + side: 'left' | 'right'; + tabKey: string; + handleMoveToAnotherSide: () => void; +}; + +const ChatTab = ({ + noBorder, + side, + title, + conversationId, + tabKey, + handleMoveToAnotherSide, +}: Props) => { + const { t } = useTranslation(); + const { focusedPanel } = useContext(TabsContext.All); + const { closeTab } = useContext(TabsContext.Handlers); + const { setFocusedTabItems } = useContext(CommandBarContext.Handlers); + const { project, refreshCurrentProjectConversations } = useContext( + ProjectContext.Current, + ); + + const dropdownComponentProps = useMemo(() => { + return { + handleMoveToAnotherSide, + conversationId, + projectId: project?.id, + tabKey, + closeTab, + refreshCurrentProjectConversations, + side, + }; + }, [ + handleMoveToAnotherSide, + conversationId, + closeTab, + project?.id, + tabKey, + refreshCurrentProjectConversations, + side, + ]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (checkEventKeys(e, ['cmd', ']'])) { + handleMoveToAnotherSide(); + } + }, + [handleMoveToAnotherSide], + ); + useKeyboardNavigation(handleKeyEvent, focusedPanel !== side); + + useEffect(() => { + if (focusedPanel === side) { + setFocusedTabItems([ + { + label: t('Open in split view'), + Icon: SplitViewIcon, + id: 'split_view', + key: 'split_view', + onClick: handleMoveToAnotherSide, + closeOnClick: true, + shortcut: openInSplitViewShortcut, + footerHint: '', + footerBtns: [{ label: t('Move'), shortcut: ['entr'] }], + }, + ]); + } + }, [focusedPanel, side, handleMoveToAnotherSide]); + + return ( +
+
+
+ + {title || t('New chat')} +
+ + + +
+
+ +
+
+ ); +}; + +export default memo(ChatTab); diff --git a/client/src/Project/CurrentTabContent/DropTarget.tsx b/client/src/Project/CurrentTabContent/DropTarget.tsx new file mode 100644 index 0000000000..230d849c44 --- /dev/null +++ b/client/src/Project/CurrentTabContent/DropTarget.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react'; +import { useDrop } from 'react-dnd'; +import { Trans, useTranslation } from 'react-i18next'; +import { TabType } from '../../types/general'; +import { SplitViewIcon } from '../../icons'; + +type Props = { + onDrop: (t: TabType) => void; +}; + +const DropTarget = ({ onDrop }: Props) => { + useTranslation(); + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: 'tab-left', + drop: (item: { t: TabType }, monitor) => { + console.log('drop', item); + onDrop(item.t); + }, + collect: (monitor) => { + return { + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }; + }, + }), + [onDrop], + ); + + return ( +
+ {isOver && canDrop && ( +
+
+
+
+ +

+ Release to open in split view +

+
+
+
+ )} +
+ ); +}; + +export default memo(DropTarget); diff --git a/client/src/Project/CurrentTabContent/EmptyTab.tsx b/client/src/Project/CurrentTabContent/EmptyTab.tsx new file mode 100644 index 0000000000..526967038c --- /dev/null +++ b/client/src/Project/CurrentTabContent/EmptyTab.tsx @@ -0,0 +1,37 @@ +import { memo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import useShortcuts from '../../hooks/useShortcuts'; + +type Props = {}; + +const EmptyTab = ({}: Props) => { + useTranslation(); + const shortcut = useShortcuts(['cmd']); + return ( +
+
+ bloop +
+
+

+ No file selected +

+

+ Select a file or open a new tab to display it here.{' '} + + Press{' '} + + cmdKey + {' '} + + K + {' '} + on your keyboard to open the Command bar. + +

+
+
+ ); +}; + +export default memo(EmptyTab); diff --git a/client/src/Project/CurrentTabContent/FileTab/ActionsDropdown.tsx b/client/src/Project/CurrentTabContent/FileTab/ActionsDropdown.tsx new file mode 100644 index 0000000000..d885f0a85a --- /dev/null +++ b/client/src/Project/CurrentTabContent/FileTab/ActionsDropdown.tsx @@ -0,0 +1,39 @@ +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import DropdownSection from '../../../components/Dropdown/Section'; +import SectionItem from '../../../components/Dropdown/Section/SectionItem'; +import { SplitViewIcon, FileWithSparksIcon } from '../../../icons'; +import { openInSplitViewShortcut } from '../../../consts/commandBar'; +import { explainFileShortcut } from './index'; + +type Props = { + handleExplain: () => void; + handleMoveToAnotherSide: () => void; +}; + +const ActionsDropdown = ({ handleExplain, handleMoveToAnotherSide }: Props) => { + const { t } = useTranslation(); + + return ( +
+ + } + /> + } + /> + +
+ ); +}; + +export default memo(ActionsDropdown); diff --git a/client/src/Project/CurrentTabContent/FileTab/index.tsx b/client/src/Project/CurrentTabContent/FileTab/index.tsx new file mode 100644 index 0000000000..28812c3b27 --- /dev/null +++ b/client/src/Project/CurrentTabContent/FileTab/index.tsx @@ -0,0 +1,332 @@ +import React, { + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { + forceFileToBeIndexed, + getFileContent, + getHoverables, +} from '../../../services/api'; +import FileIcon from '../../../components/FileIcon'; +import Button from '../../../components/Button'; +import { + EyeCutIcon, + FileWithSparksIcon, + MoreHorizontalIcon, + SplitViewIcon, +} from '../../../icons'; +import { FileResponse } from '../../../types/api'; +import { mapRanges } from '../../../mappers/results'; +import { Range } from '../../../types/results'; +import CodeFull from '../../../components/Code/CodeFull'; +import IpynbRenderer from '../../../components/IpynbRenderer'; +import SpinLoaderContainer from '../../../components/Loaders/SpinnerLoader'; +import { FileTabType, SyncStatus, TabTypesEnum } from '../../../types/general'; +import { DeviceContext } from '../../../context/deviceContext'; +import { ProjectContext } from '../../../context/projectContext'; +import { FileHighlightsContext } from '../../../context/fileHighlightsContext'; +import Dropdown from '../../../components/Dropdown'; +import { TabsContext } from '../../../context/tabsContext'; +import { checkEventKeys } from '../../../utils/keyboardUtils'; +import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; +import { CommandBarContext } from '../../../context/commandBarContext'; +import { openInSplitViewShortcut } from '../../../consts/commandBar'; +import BreadcrumbsPathContainer from '../../../components/Breadcrumbs/PathContainer'; +import ActionsDropdown from './ActionsDropdown'; + +type Props = { + tabKey: string; + repoRef: string; + path: string; + scrollToLine?: string; + tokenRange?: string; + noBorder?: boolean; + branch?: string | null; + side: 'left' | 'right'; + handleMoveToAnotherSide: () => void; +}; + +export const explainFileShortcut = ['cmd', 'E']; + +const FileTab = ({ + path, + noBorder, + repoRef, + scrollToLine, + branch, + side, + tokenRange, + handleMoveToAnotherSide, + tabKey, +}: Props) => { + const { t } = useTranslation(); + const [file, setFile] = useState(null); + const [hoverableRanges, setHoverableRanges] = useState< + Record | undefined + >(undefined); + const [indexRequested, setIndexRequested] = useState(false); + const [isFetched, setIsFetched] = useState(false); + const { apiUrl } = useContext(DeviceContext); + const { setFocusedTabItems } = useContext(CommandBarContext.Handlers); + const { refreshCurrentProjectRepos } = useContext(ProjectContext.Current); + const eventSourceRef = useRef(null); + const [isPending, startTransition] = useTransition(); + const { openNewTab, updateTabProperty } = useContext(TabsContext.Handlers); + const { focusedPanel } = useContext(TabsContext.All); + const { fileHighlights, hoveredLines } = useContext( + FileHighlightsContext.Values, + ); + const highlights = useMemo(() => { + return fileHighlights[path]?.sort((a, b) => + a && b && a?.lines?.[1] - a?.lines?.[0] < b?.lines?.[1] - b?.lines?.[0] + ? -1 + : 1, + ); + }, [path, fileHighlights]); + + useEffect(() => { + setIndexRequested(false); + setIsFetched(false); + }, [path, repoRef]); + + const refetchFile = useCallback(async () => { + try { + const resp = await getFileContent(repoRef, path, branch); + if (!resp) { + setIsFetched(true); + return; + } + startTransition(() => { + setFile(resp); + setIsFetched(true); + }); + // if (item.indexed) { + const data = await getHoverables(path, repoRef, branch); + setHoverableRanges(mapRanges(data.ranges)); + // } + } catch (err) { + setIsFetched(true); + } + }, [repoRef, path, branch]); + + useEffect(() => { + refetchFile(); + }, [refetchFile]); + + const startEventSource = useCallback(() => { + eventSourceRef.current = new EventSource( + `${apiUrl.replace('https:', '')}/repos/status`, + ); + eventSourceRef.current.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + if (data.ev?.status_change && data.ref === repoRef) { + if (data.ev?.status_change === SyncStatus.Done) { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + refreshCurrentProjectRepos(); + setTimeout(refetchFile, 2000); + } + } + } catch { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + } + }; + eventSourceRef.current.onerror = () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, [repoRef]); + + useEffect(() => { + return () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, []); + + const onIndexRequested = useCallback(async () => { + if (path) { + setIndexRequested(true); + await forceFileToBeIndexed(repoRef, path); + startEventSource(); + setTimeout(() => refetchFile(), 1000); + } + }, [repoRef, path]); + + const handleClick = useCallback(() => { + updateTabProperty(tabKey, 'isTemp', false, side); + }, [updateTabProperty, tabKey, side]); + + const linesNumber = useMemo(() => { + return file?.contents?.split(/\n(?!$)/g).length || 0; + }, [file?.contents]); + + const handleExplain = useCallback(() => { + openNewTab( + { + type: TabTypesEnum.CHAT, + initialQuery: { + path, + repoRef, + branch, + lines: [0, linesNumber - 1], + }, + }, + side === 'left' ? 'right' : 'left', + ); + }, [path, repoRef, branch, linesNumber, side, openNewTab]); + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (checkEventKeys(e, explainFileShortcut)) { + handleExplain(); + } else if (checkEventKeys(e, openInSplitViewShortcut)) { + handleMoveToAnotherSide(); + } + }, + [handleExplain, handleMoveToAnotherSide], + ); + useKeyboardNavigation( + handleKeyEvent, + !file?.contents || focusedPanel !== side, + ); + + useEffect(() => { + if (focusedPanel === side && file?.contents) { + setFocusedTabItems([ + { + label: t('Explain file'), + Icon: FileWithSparksIcon, + id: 'explain_file', + key: 'explain_file', + onClick: handleExplain, + closeOnClick: true, + shortcut: explainFileShortcut, + footerHint: '', + footerBtns: [{ label: t('Explain'), shortcut: ['entr'] }], + }, + { + label: t('Open in split view'), + Icon: SplitViewIcon, + id: 'split_view', + key: 'split_view', + onClick: handleMoveToAnotherSide, + closeOnClick: true, + shortcut: openInSplitViewShortcut, + footerHint: '', + footerBtns: [{ label: t('Move'), shortcut: ['entr'] }], + }, + ]); + } + }, [ + focusedPanel, + side, + file?.contents, + handleExplain, + handleMoveToAnotherSide, + ]); + + const dropdownComponentProps = useMemo(() => { + return { + handleExplain, + handleMoveToAnotherSide, + }; + }, [handleExplain, handleMoveToAnotherSide]); + + return ( +
+
+
+ + +
+ + + +
+
+ {file?.lang === 'jupyter notebook' ? ( + + ) : file ? ( + + {({ width, height }) => ( + + )} + + ) : isFetched && !file ? ( +
+
+ +
+
+

+ File not indexed +

+

+ + This might be because the file is too big or it has one of + bloop's excluded file types. + +

+
+ {!indexRequested ? ( + + ) : ( +
+ +
+ )} +
+ ) : null} +
+
+ ); +}; + +export default memo(FileTab); diff --git a/client/src/Project/CurrentTabContent/Header/AddTabButton.tsx b/client/src/Project/CurrentTabContent/Header/AddTabButton.tsx new file mode 100644 index 0000000000..f78e183073 --- /dev/null +++ b/client/src/Project/CurrentTabContent/Header/AddTabButton.tsx @@ -0,0 +1,64 @@ +import React, { memo, useCallback, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PlusSignIcon } from '../../../icons'; +import Button from '../../../components/Button'; +import { checkEventKeys } from '../../../utils/keyboardUtils'; +import { TabTypesEnum } from '../../../types/general'; +import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; +import { TabsContext } from '../../../context/tabsContext'; + +type Props = { + tabsLength: number; + side: 'left' | 'right'; + focusedPanel: 'left' | 'right'; +}; + +const newTabShortcut = ['option', 'N']; + +const AddTabButton = ({ side, focusedPanel }: Props) => { + const { t } = useTranslation(); + const { openNewTab } = useContext(TabsContext.Handlers); + + // const dropdownComponentProps = useMemo(() => { + // return { side }; + // }, [side]); + + const openChatTab = useCallback(() => { + openNewTab({ type: TabTypesEnum.CHAT }, side); + }, [openNewTab, side]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (checkEventKeys(e, newTabShortcut)) { + e.stopPropagation(); + e.preventDefault(); + openChatTab(); + } + }, + [openNewTab], + ); + useKeyboardNavigation(handleKeyEvent, side !== focusedPanel); + + return ( + // 1 ? 'bottom-end' : 'bottom-start'} + // > + + // + ); +}; + +export default memo(AddTabButton); diff --git a/client/src/Project/CurrentTabContent/Header/AddTabDropdown.tsx b/client/src/Project/CurrentTabContent/Header/AddTabDropdown.tsx new file mode 100644 index 0000000000..eaf05b698b --- /dev/null +++ b/client/src/Project/CurrentTabContent/Header/AddTabDropdown.tsx @@ -0,0 +1,78 @@ +import React, { + memo, + useCallback, + useContext, + MouseEvent, + useMemo, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import SectionItem from '../../../components/Dropdown/Section/SectionItem'; +import { ChatBubblesIcon, CodeStudioIcon } from '../../../icons'; +import { TabsContext } from '../../../context/tabsContext'; +import { TabTypesEnum } from '../../../types/general'; +import { checkEventKeys } from '../../../utils/keyboardUtils'; +import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; + +type Props = { + side: 'left' | 'right'; +}; + +const AddTabDropdown = ({ side }: Props) => { + const { t } = useTranslation(); + const { openNewTab } = useContext(TabsContext.Handlers); + + const openChatTab = useCallback(() => { + openNewTab({ type: TabTypesEnum.CHAT }, side); + }, [openNewTab, side]); + + const shortcuts = useMemo(() => { + return { newChat: ['option', 'N'], newStudio: ['option', 'shift', 'N'] }; + }, []); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (checkEventKeys(e, shortcuts.newChat)) { + e.stopPropagation(); + e.preventDefault(); + openNewTab({ type: TabTypesEnum.CHAT }); + } + }, + [openNewTab], + ); + useKeyboardNavigation(handleKeyEvent, side !== 'left'); + + const noPropagate = useCallback((e?: MouseEvent) => { + e?.stopPropagation(); + }, []); + + return ( +
+
+ + } + label={t('New Chat')} + shortcut={shortcuts.newChat} + onClick={openChatTab} + /> + + } + label={t('New Code Studio')} + shortcut={shortcuts.newStudio} + onClick={noPropagate} + /> +
+
+ ); +}; + +export default memo(AddTabDropdown); diff --git a/client/src/Project/CurrentTabContent/Header/TabButton.tsx b/client/src/Project/CurrentTabContent/Header/TabButton.tsx new file mode 100644 index 0000000000..caee41fd65 --- /dev/null +++ b/client/src/Project/CurrentTabContent/Header/TabButton.tsx @@ -0,0 +1,211 @@ +import React, { + memo, + MouseEvent, + useCallback, + useContext, + useRef, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDrag, useDrop } from 'react-dnd'; +import { + DraggableTabItem, + TabType, + TabTypesEnum, +} from '../../../types/general'; +import FileIcon from '../../../components/FileIcon'; +import { splitPath } from '../../../utils'; +import Button from '../../../components/Button'; +import { ChatBubblesIcon, CloseSignIcon } from '../../../icons'; +import { TabsContext } from '../../../context/tabsContext'; + +type Props = TabType & { + tabKey: string; + isActive: boolean; + side: 'left' | 'right'; + isOnlyTab: boolean; + moveTab: (i: number, j: number) => void; + i: number; + repoRef?: string; + path?: string; + title?: string; + branch?: string | null; + scrollToLine?: string; + tokenRange?: string; + focusedPanel: 'left' | 'right'; + isTemp?: boolean; +}; + +const closeTabShortcut = ['cmd', 'W']; + +const TabButton = ({ + isActive, + tabKey, + repoRef, + path, + type, + title, + side, + moveTab, + isOnlyTab, + i, + branch, + scrollToLine, + tokenRange, + focusedPanel, + isTemp, +}: Props) => { + const { t } = useTranslation(); + const { closeTab, setActiveLeftTab, setActiveRightTab, setFocusedPanel } = + useContext(TabsContext.Handlers); + const ref = useRef(null); + const [{ handlerId }, drop] = useDrop({ + accept: `tab-${side}`, + canDrop: (item: DraggableTabItem) => item.side === side, + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + }; + }, + hover(item: DraggableTabItem, monitor) { + if (!ref.current || item.side !== side) { + return; + } + const dragIndex = item.index; + const hoverIndex = i; + // Don't replace items with themselves + if (dragIndex === hoverIndex) { + return; + } + // Determine rectangle on screen + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + // Get vertical middle + const hoverMiddleX = + (hoverBoundingRect.right - hoverBoundingRect.left) / 2; + // Determine mouse position + const clientOffset = monitor.getClientOffset(); + // Get pixels to the top + const hoverClientX = (clientOffset?.x || 0) - hoverBoundingRect.left; + // Only perform the move when the mouse has crossed half of the items height + // When dragging downwards, only move when the cursor is below 50% + // When dragging upwards, only move when the cursor is above 50% + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientX < hoverMiddleX) { + return; + } + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientX > hoverMiddleX) { + return; + } + // Time to actually perform the action + moveTab(dragIndex, hoverIndex); + // Note: we're mutating the monitor item here! + // Generally it's better to avoid mutations, + // but it's good here for the sake of performance + // to avoid expensive index searches. + item.index = hoverIndex; + }, + }); + const [{ isDragging }, drag] = useDrag({ + type: `tab-${side}`, + canDrag: side !== 'left' || !isOnlyTab, + item: (): DraggableTabItem => { + return { + id: tabKey, + index: i, + // @ts-ignore + t: { + key: tabKey, + repoRef: repoRef!, + path: path!, + type, + title, + branch, + scrollToLine, + tokenRange, + }, + side, + }; + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + drag(drop(ref)); + + const handleClose = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + closeTab(tabKey, side); + }, + [tabKey, side], + ); + + const handleClick = useCallback(() => { + const setAction = side === 'left' ? setActiveLeftTab : setActiveRightTab; + // @ts-ignore + setAction({ + path, + repoRef, + key: tabKey, + type, + title, + branch, + scrollToLine, + tokenRange, + }); + setFocusedPanel(side); + }, [path, repoRef, tabKey, side, branch, scrollToLine, tokenRange, title]); + + return ( +
+ {type === TabTypesEnum.FILE ? ( + + ) : ( + + )} +

+ {type === TabTypesEnum.FILE + ? splitPath(path).pop() + : title || t('New chat')} +

+ +
+ ); +}; + +export default memo(TabButton); diff --git a/client/src/Project/CurrentTabContent/Header/index.tsx b/client/src/Project/CurrentTabContent/Header/index.tsx new file mode 100644 index 0000000000..c1c648c33e --- /dev/null +++ b/client/src/Project/CurrentTabContent/Header/index.tsx @@ -0,0 +1,81 @@ +import React, { memo, useCallback, useContext, useMemo } from 'react'; +import HeaderRightPart from '../../../components/Header/HeaderRightPart'; +import { TabsContext } from '../../../context/tabsContext'; +import { TabTypesEnum } from '../../../types/general'; +import AddTabButton from './AddTabButton'; +import TabButton from './TabButton'; + +type Props = { + side: 'left' | 'right'; +}; + +const ProjectHeader = ({ side }: Props) => { + const { leftTabs, rightTabs, focusedPanel } = useContext(TabsContext.All); + const { tab } = useContext( + TabsContext[side === 'left' ? 'CurrentLeft' : 'CurrentRight'], + ); + const { setLeftTabs, setRightTabs } = useContext(TabsContext.Handlers); + const tabs = useMemo(() => { + return side === 'left' ? leftTabs : rightTabs; + }, [side, rightTabs, leftTabs]); + + const moveTab = useCallback( + (dragIndex: number, hoverIndex: number) => { + const action = side === 'left' ? setLeftTabs : setRightTabs; + action((prevTabs) => { + const newTabs = JSON.parse(JSON.stringify(prevTabs)); + newTabs.splice(dragIndex, 1); + const newTab = prevTabs[dragIndex]; + newTabs.splice( + hoverIndex, + 0, + newTab.type === TabTypesEnum.FILE && newTab.isTemp + ? { ...newTab, isTemp: false } + : newTab, + ); + return newTabs; + }); + }, + [side], + ); + + return ( +
+
+ {tabs.map(({ key, ...t }, i) => ( + + ))} + {!!tabs.length &&
} + +
+ {(side === 'right' || !rightTabs.length) && ( +
+ +
+ )} +
+ ); +}; + +export default memo(ProjectHeader); diff --git a/client/src/Project/CurrentTabContent/index.tsx b/client/src/Project/CurrentTabContent/index.tsx new file mode 100644 index 0000000000..c118a12910 --- /dev/null +++ b/client/src/Project/CurrentTabContent/index.tsx @@ -0,0 +1,109 @@ +import React, { memo, useCallback, useContext } from 'react'; +import { useDrop } from 'react-dnd'; +import { Trans } from 'react-i18next'; +import { TabsContext } from '../../context/tabsContext'; +import { DraggableTabItem, TabType, TabTypesEnum } from '../../types/general'; +import { SplitViewIcon } from '../../icons'; +import EmptyTab from './EmptyTab'; +import FileTab from './FileTab'; +import Header from './Header'; +import ChatTab from './ChatTab'; + +type Props = { + side: 'left' | 'right'; + onDrop: (t: TabType) => void; + moveToAnotherSide: (t: TabType) => void; + shouldStretch?: boolean; +}; + +const CurrentTabContent = ({ + side, + onDrop, + shouldStretch, + moveToAnotherSide, +}: Props) => { + const { tab } = useContext( + TabsContext[side === 'left' ? 'CurrentLeft' : 'CurrentRight'], + ); + const { setFocusedPanel } = useContext(TabsContext.Handlers); + + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: side === 'right' ? 'tab-left' : 'tab-right', + canDrop: (i: DraggableTabItem) => i.side !== side, + drop: (item: DraggableTabItem) => { + onDrop(item.t); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [onDrop], + ); + + const focusPanel = useCallback(() => { + setFocusedPanel(side); + }, [side]); + + const handleMoveToAnotherSide = useCallback(() => { + if (tab) { + moveToAnotherSide(tab); + } + }, [moveToAnotherSide, tab]); + + return ( +
+
+
+ {tab?.type === TabTypesEnum.FILE ? ( + + ) : tab?.type === TabTypesEnum.CHAT ? ( + + ) : ( + + )} + {isOver && canDrop && ( +
+
+
+
+
+ +

+ Release to open in split view +

+
+
+
+
+ )} +
+
+ ); +}; + +export default memo(CurrentTabContent); diff --git a/client/src/Project/EmptyProject.tsx b/client/src/Project/EmptyProject.tsx new file mode 100644 index 0000000000..2e8eeeb6e6 --- /dev/null +++ b/client/src/Project/EmptyProject.tsx @@ -0,0 +1,60 @@ +import React, { memo, useCallback, useContext } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import Header from '../components/Header'; +import { PlusSignIcon, ShapesIcon } from '../icons'; +import Button from '../components/Button'; +import { CommandBarContext } from '../context/commandBarContext'; +import { CommandBarStepEnum } from '../types/general'; +import useShortcuts from '../hooks/useShortcuts'; + +type Props = {}; + +const EmptyProject = ({}: Props) => { + useTranslation(); + const shortcut = useShortcuts(['cmd']); + const { setIsVisible, setChosenStep } = useContext( + CommandBarContext.Handlers, + ); + + const openCommandBar = useCallback(() => { + setChosenStep({ id: CommandBarStepEnum.MANAGE_REPOS }); + setIsVisible(true); + }, []); + + return ( +
+
+
+
+
+
+ +
+
+

+ This project is empty +

+

+ + Press{' '} + + cmdKey + {' '} + + K + {' '} + on your keyboard to open the Command bar and add a repository. + +

+
+ +
+
+
+
+ ); +}; + +export default memo(EmptyProject); diff --git a/client/src/Project/LeftSidebar/NavPanel/Conversations.tsx b/client/src/Project/LeftSidebar/NavPanel/Conversations.tsx new file mode 100644 index 0000000000..175a1f9dcd --- /dev/null +++ b/client/src/Project/LeftSidebar/NavPanel/Conversations.tsx @@ -0,0 +1,111 @@ +import React, { + Dispatch, + memo, + SetStateAction, + useCallback, + useEffect, + useRef, + MouseEvent, + useContext, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import Dropdown from '../../../components/Dropdown'; +import { + ArrowTriangleBottomIcon, + ChatBubblesIcon, + MoreHorizontalIcon, +} from '../../../icons'; +import Button from '../../../components/Button'; +import { ProjectContext } from '../../../context/projectContext'; +import ConversationsDropdown from './ConversationsDropdown'; +import ConversationEntry from './CoversationEntry'; + +type Props = { + setExpanded: Dispatch>; + isExpanded: boolean; +}; + +const reactRoot = document.getElementById('root')!; + +const ConversationsNav = ({ isExpanded, setExpanded }: Props) => { + const { t } = useTranslation(); + const { project } = useContext(ProjectContext.Current); + const containerRef = useRef(null); + + const toggleExpanded = useCallback(() => { + setExpanded((prev) => (prev === 0 ? -1 : 0)); + }, []); + + useEffect(() => { + if (isExpanded) { + // containerRef.current?.scrollIntoView({ block: 'nearest' }); + } + }, [isExpanded]); + + const noPropagate = useCallback((e?: MouseEvent) => { + e?.stopPropagation(); + }, []); + + return ( +
+ + +

+ + Chat conversations + + {isExpanded && ( + + )} +

+ {isExpanded && ( +
+ + + +
+ )} +
+
+ {project?.conversations.map((c) => ( + + ))} +
+
+ ); +}; + +export default memo(ConversationsNav); diff --git a/client/src/Project/LeftSidebar/NavPanel/ConversationsDropdown.tsx b/client/src/Project/LeftSidebar/NavPanel/ConversationsDropdown.tsx new file mode 100644 index 0000000000..f6e0cd7061 --- /dev/null +++ b/client/src/Project/LeftSidebar/NavPanel/ConversationsDropdown.tsx @@ -0,0 +1,39 @@ +import { memo, useCallback, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import DropdownSection from '../../../components/Dropdown/Section'; +import SectionItem from '../../../components/Dropdown/Section/SectionItem'; +import { TrashCanIcon } from '../../../icons'; +import { deleteConversation } from '../../../services/api'; +import { ProjectContext } from '../../../context/projectContext'; + +type Props = {}; + +const ConversationsDropdown = ({}: Props) => { + const { t } = useTranslation(); + const { project, refreshCurrentProjectConversations } = useContext( + ProjectContext.Current, + ); + + const handleRemoveAllConversations = useCallback(async () => { + if (project?.id && project.conversations.length) { + await Promise.allSettled( + project.conversations.map((c) => deleteConversation(project.id, c.id)), + ); + refreshCurrentProjectConversations(); + } + }, [project?.id, project?.conversations]); + + return ( +
+ + } + /> + +
+ ); +}; + +export default memo(ConversationsDropdown); diff --git a/client/src/Project/LeftSidebar/NavPanel/CoversationEntry.tsx b/client/src/Project/LeftSidebar/NavPanel/CoversationEntry.tsx new file mode 100644 index 0000000000..6056e9b3c3 --- /dev/null +++ b/client/src/Project/LeftSidebar/NavPanel/CoversationEntry.tsx @@ -0,0 +1,28 @@ +import { memo, useCallback, useContext } from 'react'; +import { ConversationShortType } from '../../../types/api'; +import { TabsContext } from '../../../context/tabsContext'; +import { TabTypesEnum } from '../../../types/general'; + +type Props = ConversationShortType & {}; + +const ConversationEntry = ({ title, id }: Props) => { + const { openNewTab } = useContext(TabsContext.Handlers); + + const handleClick = useCallback(() => { + openNewTab({ type: TabTypesEnum.CHAT, conversationId: id, title }); + }, [openNewTab, id, title]); + + return ( + + {title} + + ); +}; + +export default memo(ConversationEntry); diff --git a/client/src/Project/LeftSidebar/NavPanel/Repo.tsx b/client/src/Project/LeftSidebar/NavPanel/Repo.tsx new file mode 100644 index 0000000000..9613523ae6 --- /dev/null +++ b/client/src/Project/LeftSidebar/NavPanel/Repo.tsx @@ -0,0 +1,212 @@ +import React, { + Dispatch, + memo, + SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, + MouseEvent, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { DirectoryEntry } from '../../../types/api'; +import { getFolderContent } from '../../../services/api'; +import { splitPath } from '../../../utils'; +import GitHubIcon from '../../../icons/GitHubIcon'; +import Dropdown from '../../../components/Dropdown'; +import { + ArrowTriangleBottomIcon, + HardDriveIcon, + MoreHorizontalIcon, +} from '../../../icons'; +import Button from '../../../components/Button'; +import { SyncStatus } from '../../../types/general'; +import SpinLoaderContainer from '../../../components/Loaders/SpinnerLoader'; +import Tooltip from '../../../components/Tooltip'; +import { repoStatusMap } from '../../../consts/general'; +import RepoEntry from './RepoEntry'; +import RepoDropdown from './RepoDropdown'; + +type Props = { + repoRef: string; + setExpanded: Dispatch>; + isExpanded: boolean; + i: number; + projectId: string; + lastIndex: string; + currentPath?: string; + branch: string; + allBranches: { name: string; last_commit_unix_secs: number }[]; + indexedBranches: string[]; + indexingData?: { status: SyncStatus; percentage?: string; branch?: string }; +}; + +const reactRoot = document.getElementById('root')!; + +const RepoNav = ({ + repoRef, + i, + isExpanded, + setExpanded, + branch, + indexedBranches, + allBranches, + projectId, + lastIndex, + currentPath, + indexingData, +}: Props) => { + const { t } = useTranslation(); + const [files, setFiles] = useState([]); + const containerRef = useRef(null); + + const fetchFiles = useCallback( + async (path?: string) => { + const resp = await getFolderContent(repoRef, path, branch); + if (!resp.entries) { + return []; + } + return resp?.entries.sort((a, b) => { + if ((a.entry_data === 'Directory') === (b.entry_data === 'Directory')) { + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + } else { + return a.entry_data === 'Directory' ? -1 : 1; + } + }); + }, + [repoRef, branch], + ); + + const refetchParentFolder = useCallback(() => { + fetchFiles().then(setFiles); + }, [fetchFiles]); + + useEffect(() => { + refetchParentFolder(); + }, [refetchParentFolder]); + + const toggleExpanded = useCallback(() => { + setExpanded((prev) => (prev === i ? -1 : i)); + }, [i]); + + useEffect(() => { + if (isExpanded) { + // containerRef.current?.scrollIntoView({ block: 'nearest' }); + } + }, [isExpanded]); + + const dropdownComponentProps = useMemo(() => { + return { + key: repoRef, + projectId, + repoRef, + selectedBranch: branch, + indexedBranches, + allBranches, + }; + }, [projectId, repoRef, branch, indexedBranches, allBranches]); + + const noPropagate = useCallback((e?: MouseEvent) => { + e?.stopPropagation(); + }, []); + + const isIndexing = useMemo(() => { + if (!indexingData) { + return false; + } + return [ + SyncStatus.Indexing, + SyncStatus.Syncing, + SyncStatus.Queued, + ].includes(indexingData.status); + }, [indexingData]); + + return ( +
+ + {isIndexing && indexingData ? ( + + + + ) : repoRef.startsWith('github.com') ? ( + + ) : ( + + )} +

+ {splitPath(repoRef).pop()} + {isExpanded && ( + <> + / + + {branch?.replace(/^origin\//, '')}{' '} + + + + )} +

+ {isExpanded && ( +
+ + + +
+ )} +
+
+ {files.map((f) => ( + + ))} +
+
+ ); +}; + +export default memo(RepoNav); diff --git a/client/src/Project/LeftSidebar/NavPanel/RepoDropdown.tsx b/client/src/Project/LeftSidebar/NavPanel/RepoDropdown.tsx new file mode 100644 index 0000000000..0d53808877 --- /dev/null +++ b/client/src/Project/LeftSidebar/NavPanel/RepoDropdown.tsx @@ -0,0 +1,314 @@ +import { + memo, + useCallback, + useContext, + useRef, + useState, + MouseEvent, + ChangeEvent, + useMemo, + useEffect, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import DropdownSection from '../../../components/Dropdown/Section'; +import SectionItem from '../../../components/Dropdown/Section/SectionItem'; +import { + ArrowTriangleBottomIcon, + BranchIcon, + RefreshIcon, + TrashCanIcon, +} from '../../../icons'; +import { + changeRepoBranch, + indexRepoBranch, + removeRepoFromProject, + syncRepo, +} from '../../../services/api'; +import { SyncStatus } from '../../../types/general'; +import { DeviceContext } from '../../../context/deviceContext'; +import SpinLoaderContainer from '../../../components/Loaders/SpinnerLoader'; +import SectionLabel from '../../../components/Dropdown/Section/SectionLabel'; +import Button from '../../../components/Button'; +import { ProjectContext } from '../../../context/projectContext'; +import { PersonalQuotaContext } from '../../../context/personalQuotaContext'; + +type Props = { + repoRef: string; + projectId: string; + selectedBranch?: string | null; + allBranches: { name: string; last_commit_unix_secs: number }[]; + indexedBranches: string[]; +}; + +const RepoDropdown = ({ + repoRef, + selectedBranch, + indexedBranches, + allBranches, + projectId, +}: Props) => { + const { t } = useTranslation(); + const eventSourceRef = useRef(null); + const [isBranchesOpen, setIsBranchesOpen] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [search, setSearch] = useState(''); + const [branchesToSync, setBranchesToSync] = useState([]); + const [indexing, setIndexing] = useState({ branch: '', percentage: 0 }); + const { apiUrl, isSelfServe } = useContext(DeviceContext); + const { refreshCurrentProjectRepos } = useContext(ProjectContext.Current); + const { isSubscribed } = useContext(PersonalQuotaContext.Values); + + const startEventSource = useCallback(() => { + eventSourceRef.current?.close(); + eventSourceRef.current = new EventSource( + `${apiUrl.replace('https:', '')}/repos/status`, + ); + eventSourceRef.current.onmessage = (ev) => { + try { + const data = JSON.parse(ev.data); + console.log('data', data); + if (data.ev?.status_change && data.ref === repoRef) { + if (data.ev?.status_change === SyncStatus.Done) { + setIsSyncing(false); + refreshCurrentProjectRepos(); + } + } + if (data.ev?.index_percent && data.b?.select[0]) { + setIndexing(() => ({ + branch: data.b?.select[0], + percentage: data.ev?.index_percent || 1, + })); + } + } catch { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + } + }; + eventSourceRef.current.onerror = () => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, [repoRef]); + + useEffect(() => { + if (!branchesToSync.length) { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + } + }, [branchesToSync]); + + const onRepoSync = useCallback( + async (e?: MouseEvent) => { + e?.stopPropagation(); + await syncRepo(repoRef); + setIsSyncing(true); + startEventSource(); + }, + [repoRef], + ); + + const toggleBranches = useCallback((e?: MouseEvent) => { + e?.stopPropagation(); + setIsBranchesOpen((prev) => !prev); + }, []); + + const handleInputChange = useCallback((e: ChangeEvent) => { + setSearch(e.target.value); + }, []); + + const noPropagate = useCallback((e?: MouseEvent) => { + e?.stopPropagation(); + }, []); + + useEffect(() => { + setBranchesToSync((prevState) => + prevState.filter((p) => !indexedBranches.includes(p)), + ); + }, [indexedBranches]); + + const notSyncedBranches = useMemo(() => { + return [...allBranches] + .reverse() + .filter( + (b) => + !indexedBranches.includes(b.name) && !branchesToSync.includes(b.name), + ) + .map((b) => b.name); + }, [indexedBranches, allBranches, branchesToSync]); + + const handleRemoveFromProject = useCallback(async () => { + if (projectId) { + await removeRepoFromProject(projectId, repoRef); + refreshCurrentProjectRepos(); + } + }, [projectId, repoRef]); + + const indexedBranchesToShow = useMemo(() => { + if (!search) { + return indexedBranches; + } + return indexedBranches.filter((b) => + b + .replace(/^origin\//, '') + .toLowerCase() + .includes(search.toLowerCase()), + ); + }, [indexedBranches, search]); + + const indexingBranchesToShow = useMemo(() => { + if (!search) { + return branchesToSync; + } + return branchesToSync.filter((b) => + b + .replace(/^origin\//, '') + .toLowerCase() + .includes(search.toLowerCase()), + ); + }, [branchesToSync, search]); + + const notIndexedBranchesToShow = useMemo(() => { + if (!search) { + return notSyncedBranches; + } + return notSyncedBranches.filter((b) => + b + .replace(/^origin\//, '') + .toLowerCase() + .includes(search.toLowerCase()), + ); + }, [notSyncedBranches, search]); + + const switchToBranch = useCallback( + async (branch: string, e?: MouseEvent) => { + e?.stopPropagation(); + await changeRepoBranch(projectId, repoRef, branch); + refreshCurrentProjectRepos(); + }, + [projectId, repoRef], + ); + + return ( +
+ + + ) : ( + + ) + } + /> + {(isSelfServe || isSubscribed) && ( + } + customRightElement={ + + {selectedBranch} + + + } + /> + )} + +
+ + + + {!!indexedBranchesToShow.length && ( + + + {indexedBranchesToShow.map((b) => ( + switchToBranch(b, e)} + label={b.replace(/^origin\//, '')} + icon={} + key={b} + isSelected={selectedBranch === b} + /> + ))} + + )} + {!!indexingBranchesToShow.length && ( + + + {indexingBranchesToShow.map((b) => ( + } + label={b.replace(/^origin\//, '')} + key={b} + customRightElement={ + + {indexing.branch === b + ? indexing.percentage + '%' + : t('Queued...')} + + } + /> + ))} + + )} + {!!notIndexedBranchesToShow.length && ( + + + {notIndexedBranchesToShow.map((b) => ( + } + customRightElement={ + + } + /> + ))} + + )} +
+ + } + /> + +
+ ); +}; + +export default memo(RepoDropdown); diff --git a/client/src/Project/LeftSidebar/NavPanel/RepoEntry.tsx b/client/src/Project/LeftSidebar/NavPanel/RepoEntry.tsx new file mode 100644 index 0000000000..d5f9b44e0f --- /dev/null +++ b/client/src/Project/LeftSidebar/NavPanel/RepoEntry.tsx @@ -0,0 +1,184 @@ +import React, { + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { ChevronRightIcon, EyeCutIcon, FolderIcon } from '../../../icons'; +import FileIcon from '../../../components/FileIcon'; +import { DirectoryEntry } from '../../../types/api'; +import { TabsContext } from '../../../context/tabsContext'; +import { TabTypesEnum } from '../../../types/general'; + +type Props = { + name: string; + isDirectory: boolean; + level: number; + fullPath: string; + fetchFiles: (path: string) => Promise; + defaultOpen?: boolean; + indexed: boolean; + repoRef: string; + lastIndex: string; + currentPath?: string; + branch?: string | null; +}; + +const RepoEntry = ({ + name, + level, + isDirectory, + currentPath, + fullPath, + fetchFiles, + defaultOpen, + indexed, + repoRef, + lastIndex, + branch, +}: Props) => { + const { openNewTab } = useContext(TabsContext.Handlers); + const [isOpen, setOpen] = useState( + defaultOpen || (currentPath && currentPath.startsWith(fullPath)), + ); + const [subItems, setSubItems] = useState(null); + const ref = useRef(null); + const [isMounted, setIsMounted] = useState(false); + + const refetchFolderFiles = useCallback(() => { + fetchFiles(fullPath).then(setSubItems); + }, [fullPath, fetchFiles]); + + useEffect(() => { + if (currentPath && currentPath.startsWith(fullPath)) { + setOpen(true); + } + }, [currentPath, fullPath]); + + useEffect(() => { + if ( + subItems?.length && + subItems.find( + (si) => si.entry_data !== 'Directory' && !si.entry_data.File.indexed, + ) && + isMounted + ) { + refetchFolderFiles(); + } else { + setIsMounted(true); + } + }, [lastIndex]); + + useEffect(() => { + if (isDirectory && isOpen) { + refetchFolderFiles(); + } + }, [isOpen, isDirectory, refetchFolderFiles]); + + const handleClick = useCallback(() => { + if (isDirectory) { + setOpen((prev) => !prev); + } else { + openNewTab({ + type: TabTypesEnum.FILE, + path: fullPath, + repoRef, + branch, + }); + } + }, [isDirectory, fullPath, openNewTab, repoRef, branch]); + + return ( +
+ + {isDirectory ? ( +
+ +
+ ) : null} + {isDirectory ? ( + + ) : !indexed ? ( + + ) : ( + + )} + {isDirectory ? name.slice(0, -1) : name} + {/*{!indexed && !indexRequested && (*/} + {/* */} + {/* Index*/} + {/* */} + {/*)}*/} + {/*{!indexed && indexRequested && isIndexing && (*/} + {/*
*/} + {/* */} + {/*
*/} + {/*)}*/} +
+ {subItems?.length ? ( +
+
+ {subItems.map((si) => ( + + ))} +
+ ) : null} +
+ ); +}; + +export default memo(RepoEntry); diff --git a/client/src/Project/LeftSidebar/NavPanel/index.tsx b/client/src/Project/LeftSidebar/NavPanel/index.tsx new file mode 100644 index 0000000000..3163ed21ae --- /dev/null +++ b/client/src/Project/LeftSidebar/NavPanel/index.tsx @@ -0,0 +1,158 @@ +import { + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { ProjectContext } from '../../../context/projectContext'; +import { SyncStatus, TabTypesEnum } from '../../../types/general'; +import { TabsContext } from '../../../context/tabsContext'; +import { DeviceContext } from '../../../context/deviceContext'; +import { getIndexedRepos } from '../../../services/api'; +import RepoNav from './Repo'; +import ConversationsNav from './Conversations'; + +type Props = {}; + +const NavPanel = ({}: Props) => { + const [expanded, setExpanded] = useState(-1); + const { project, refreshCurrentProjectRepos } = useContext( + ProjectContext.Current, + ); + const { focusedPanel } = useContext(TabsContext.All); + const { tab: leftTab } = useContext(TabsContext.CurrentLeft); + const { tab: rightTab } = useContext(TabsContext.CurrentRight); + const { apiUrl } = useContext(DeviceContext); + const [indexingRepos, setIndexingRepos] = useState< + Record< + string, + { + status: SyncStatus; + percentage?: string; + branch?: string; + } + > + >({}); + const eventSourceRef = useRef(null); + + const startEventSource = useCallback(() => { + eventSourceRef.current?.close(); + eventSourceRef.current = new EventSource( + `${apiUrl.replace('https:', '')}/repos/status`, + ); + eventSourceRef.current.onmessage = (ev) => { + const data = JSON.parse(ev.data); + if (data.ev?.status_change) { + if (data.ev?.status_change === SyncStatus.Done) { + refreshCurrentProjectRepos(); + } + setIndexingRepos((prev) => ({ + ...prev, + [data.ref]: { + ...(prev[data.ref] ? prev[data.ref] : {}), + status: data.ev?.status_change, + }, + })); + } + if (data.ev?.index_percent) { + setIndexingRepos((prev) => ({ + ...prev, + [data.ref]: { + ...(prev[data.ref] ? prev[data.ref] : {}), + percentage: data.ev?.index_percent, + branch: data.b?.select[0], + }, + })); + } + }; + }, []); + + useEffect(() => { + startEventSource(); + getIndexedRepos().then((repos) => { + const reposInProgress = repos.list.filter( + (r) => r.sync_status !== SyncStatus.Done, + ); + setIndexingRepos((prev) => { + const newRepos = JSON.parse(JSON.stringify(prev)); + reposInProgress.forEach((r) => { + newRepos[r.ref] = { + ...(newRepos[r.ref] || {}), + status: r.sync_status, + }; + }); + return newRepos; + }); + }); + const intervalId = window.setInterval(startEventSource, 10 * 60 * 1000); + + return () => { + clearInterval(intervalId); + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }; + }, []); + + const currentlyFocusedTab = useMemo(() => { + const focusedTab = focusedPanel === 'left' ? leftTab : rightTab; + if (focusedTab?.type === TabTypesEnum.FILE) { + return focusedTab; + } + return null; + }, [focusedPanel, leftTab, rightTab]); + + useEffect(() => { + if (project?.repos.length === 1) { + setExpanded(!!project?.conversations.length ? 1 : 0); + } + }, [project?.repos]); + + useEffect(() => { + if (currentlyFocusedTab?.repoRef) { + const repoIndex = project?.repos.findIndex( + (r) => r.repo.ref === currentlyFocusedTab.repoRef, + ); + if (repoIndex !== undefined && repoIndex > -1) { + setExpanded(repoIndex + (!!project?.conversations ? 1 : 0)); + } + } + }, [currentlyFocusedTab]); + + return ( +
+ {!!project?.conversations.length && ( + + )} + {project?.repos.map((r, i) => ( + + ))} +
+ ); +}; + +export default memo(NavPanel); diff --git a/client/src/components/SearchInput/AutocompleteMenu.tsx b/client/src/Project/LeftSidebar/RegexSearchPanel/AutocompleteMenu.tsx similarity index 50% rename from client/src/components/SearchInput/AutocompleteMenu.tsx rename to client/src/Project/LeftSidebar/RegexSearchPanel/AutocompleteMenu.tsx index 223867ed37..4fd3ff464a 100644 --- a/client/src/components/SearchInput/AutocompleteMenu.tsx +++ b/client/src/Project/LeftSidebar/RegexSearchPanel/AutocompleteMenu.tsx @@ -1,6 +1,6 @@ -import React, { useMemo, useState } from 'react'; +import React, { memo, useMemo } from 'react'; import { Trans } from 'react-i18next'; -import { ResultItemType, SuggestionType } from '../../types/results'; +import { ResultItemType, SuggestionType } from '../../../types/results'; import AutocompleteMenuItem from './AutocompleteMenuItem'; type Props = { @@ -14,6 +14,7 @@ type Props = { }) => any; isOpen: boolean; options: SuggestionType[]; + highlightedIndex: number; }; const AutocompleteMenu = ({ @@ -21,9 +22,8 @@ const AutocompleteMenu = ({ isOpen, options, getItemProps, + highlightedIndex, }: Props) => { - const [allResultsShown, setAllResultsShown] = useState(false); - const queryOptions = useMemo( () => options.filter( @@ -42,17 +42,17 @@ const AutocompleteMenu = ({ return (
    - {isOpen ? ( + {isOpen && ( <> {queryOptions.length ? ( - + Query suggestions ) : null} @@ -61,39 +61,31 @@ const AutocompleteMenu = ({ key={`${item}${index}`} item={item} index={index} + isFocused={highlightedIndex === index} getItemProps={getItemProps} + isFirst={index === 0} /> ))} {resultOptions.length ? ( - + Result suggestions ) : null} - {resultOptions - .slice(0, allResultsShown ? undefined : 2) - .map((item, index) => ( - - ))} - {resultOptions.length > 2 ? ( - - ) : null} + {resultOptions.map((item, index) => ( + + ))} - ) : null} + )}
); }; -export default AutocompleteMenu; +export default memo(AutocompleteMenu); diff --git a/client/src/components/SearchInput/AutocompleteMenuItem.tsx b/client/src/Project/LeftSidebar/RegexSearchPanel/AutocompleteMenuItem.tsx similarity index 57% rename from client/src/components/SearchInput/AutocompleteMenuItem.tsx rename to client/src/Project/LeftSidebar/RegexSearchPanel/AutocompleteMenuItem.tsx index 12c18eb79e..45d35695ca 100644 --- a/client/src/components/SearchInput/AutocompleteMenuItem.tsx +++ b/client/src/Project/LeftSidebar/RegexSearchPanel/AutocompleteMenuItem.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { memo, useEffect, useMemo, useRef } from 'react'; import { Trans } from 'react-i18next'; -import CodeBlockSearch from '../CodeBlock/Search'; -import { ResultItemType, SuggestionType } from '../../types/results'; +import { ResultItemType, SuggestionType } from '../../../types/results'; +import CodeBlockSearch from '../../../components/Code/CodeBlockSearch'; type Props = { item: SuggestionType; @@ -13,34 +13,59 @@ type Props = { item: SuggestionType; index: number; }) => any; + isFocused: boolean; + isFirst: boolean; }; -const AutocompleteMenuItem = ({ getItemProps, item, index }: Props) => { +const AutocompleteMenuItem = ({ + getItemProps, + item, + index, + isFocused, + isFirst, +}: Props) => { + const ref = useRef(null); + + useEffect(() => { + if (isFocused) { + ref.current?.scrollIntoView({ block: 'nearest' }); + } + }, [isFocused]); + + const snippets = useMemo(() => { + if (item.type === ResultItemType.CODE) { + return item.snippets?.slice(0, 1).map((s) => ({ + ...s, + code: s.code.split('\n').slice(0, 5).join('\n'), // don't render big snippets that have over 5 lines + })); + } + return []; + }, [item]); + return (
  • {item.type === ResultItemType.FLAG || item.type === ResultItemType.LANG ? ( {item.data} ) : item.type === ResultItemType.CODE ? ( ({ - ...s, - code: s.code.split('\n').slice(0, 5).join('\n'), // don't render big snippets that have over 5 lines - }))} + snippets={snippets} language={item.language} filePath={item.relativePath} - repoName={item.repoName} - branch={item.branch} collapsed={false} - onClick={() => {}} - repoPath={item.repoPath} + repoRef={item.repoRef} /> ) : item.type === ResultItemType.FILE ? ( <> @@ -51,7 +76,7 @@ const AutocompleteMenuItem = ({ getItemProps, item, index }: Props) => { ) : item.type === ResultItemType.REPO ? ( <> - {item.repository} + {item.repoName} Repository @@ -61,4 +86,4 @@ const AutocompleteMenuItem = ({ getItemProps, item, index }: Props) => { ); }; -export default AutocompleteMenuItem; +export default memo(AutocompleteMenuItem); diff --git a/client/src/components/CodeBlock/Code/index.tsx b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/CodeLine.tsx similarity index 54% rename from client/src/components/CodeBlock/Code/index.tsx rename to client/src/Project/LeftSidebar/RegexSearchPanel/Results/CodeLine.tsx index b5ba0625f0..8468ad0808 100644 --- a/client/src/components/CodeBlock/Code/index.tsx +++ b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/CodeLine.tsx @@ -1,45 +1,44 @@ -import React, { useEffect, useMemo } from 'react'; -import { getPrismLanguage, tokenizeCode } from '../../../utils/prism'; -import { - HighlightMap, - Range, - SnippetSymbol, - TokensLine, -} from '../../../types/results'; -import { Token } from '../../../types/prism'; -import CodeContainer from './CodeContainer'; +import React, { memo, useCallback, useContext, useMemo } from 'react'; +import { TabTypesEnum } from '../../../../types/general'; +import { CodeIcon } from '../../../../icons'; +import { TabsContext } from '../../../../context/tabsContext'; +import { getPrismLanguage, tokenizeCode } from '../../../../utils/prism'; +import { HighlightMap, Range, TokensLine } from '../../../../types/results'; +import { Token } from '../../../../types/prism'; +import CodeToken from '../../../../components/Code/CodeToken'; type Props = { + path: string; + repoRef: string; + lineStart: number; + lineEnd: number; code: string; language: string; - lineStart?: number; - highlights?: Range[]; - showLines?: boolean; - symbols?: SnippetSymbol[]; - onlySymbolLines?: boolean; - removePaddings?: boolean; - lineHoverEffect?: boolean; - isDiff?: boolean; - canWrap?: boolean; - highlightColor?: string; - onTokensLoaded?: () => void; + highlights: Range[]; }; -const Code = ({ +const noOp = () => {}; + +const CodeLine = ({ + path, + repoRef, + lineStart, + lineEnd, code, language, - lineStart = 0, - showLines = true, highlights, - symbols, - onlySymbolLines, - removePaddings, - lineHoverEffect, - highlightColor, - isDiff, - onTokensLoaded, - canWrap, }: Props) => { + const { openNewTab } = useContext(TabsContext.Handlers); + + const onClick = useCallback(() => { + openNewTab({ + type: TabTypesEnum.FILE, + path, + repoRef, + scrollToLine: `${lineStart}_${lineEnd}`, + }); + }, [path, lineEnd, lineStart, repoRef, openNewTab]); + const lang = useMemo( () => getPrismLanguage(language) || 'plaintext', [language], @@ -58,7 +57,7 @@ const Code = ({ const getMap = (tokens: Token[]): HighlightMap[] => { const highlightMaps: HighlightMap[] = []; - tokens.forEach((token, index) => { + tokens.forEach((token) => { highlightMaps.push(...getToken(token)); }); @@ -120,41 +119,41 @@ const Code = ({ .map((l): TokensLine => ({ tokens: l, lineNumber: null })); let currentLine = lineStart; for (let i = 0; i < lines.length; i++) { - if ( - isDiff && - (lines[i].tokens[0]?.token.content === '-' || - lines[i].tokens[1]?.token.content === '-') - ) { - continue; - } lines[i].lineNumber = currentLine + 1; currentLine++; } return lines; - }, [tokens, lineStart, isDiff]); + }, [tokens, lineStart]); - useEffect(() => { - if (tokensMap.length && onTokensLoaded) { - onTokensLoaded(); + const lineToRender = useMemo(() => { + const ltr = tokensMap.find((l) => !!l.tokens.find((t) => t.highlight)); + const firstHighlightIndex = ltr?.tokens.findIndex((t) => t.highlight) || 0; + if (ltr) { + ltr.tokens = ltr.tokens.slice(Math.max(firstHighlightIndex - 2, 0)); } + return ltr; }, [tokensMap]); return ( - +
  • + +

    + {(lineToRender || tokensMap[0]).tokens.map((token, index) => ( + + ))} +

    +
  • ); }; -export default Code; +export default memo(CodeLine); diff --git a/client/src/Project/LeftSidebar/RegexSearchPanel/Results/CodeResult.tsx b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/CodeResult.tsx new file mode 100644 index 0000000000..154ec07d4b --- /dev/null +++ b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/CodeResult.tsx @@ -0,0 +1,120 @@ +import { + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { SnippetItem } from '../../../../types/api'; +import { ChevronRightIcon } from '../../../../icons'; +import FileIcon from '../../../../components/FileIcon'; +import { TabsContext } from '../../../../context/tabsContext'; +import { TabTypesEnum } from '../../../../types/general'; +import useKeyboardNavigation from '../../../../hooks/useKeyboardNavigation'; +import { isFocusInInput } from '../../../../utils/domUtils'; +import CodeLine from './CodeLine'; + +type Props = { + relative_path: string; + repo_ref: string; + lang: string; + snippets: SnippetItem[]; + isFocused: boolean; + isFirst: boolean; +}; + +const CodeResult = ({ + relative_path, + repo_ref, + lang, + snippets, + isFocused, + isFirst, +}: Props) => { + const { openNewTab } = useContext(TabsContext.Handlers); + const ref = useRef(null); + const [isExpanded, setIsExpanded] = useState(true); + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + useEffect(() => { + if (isFocused) { + ref.current?.scrollIntoView({ block: 'nearest' }); + } + }, [isFocused]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if ( + e.key === 'Enter' && + (!isFocusInInput() || document.activeElement?.id === 'regex-search') + ) { + e.stopPropagation(); + e.preventDefault(); + openNewTab({ + type: TabTypesEnum.FILE, + path: relative_path, + repoRef: repo_ref, + scrollToLine: `${snippets[0].line_range.start}_${snippets[0].line_range.end}`, + }); + } + }, + [repo_ref, relative_path, openNewTab], + ); + useKeyboardNavigation(handleKeyEvent, !isFocused); + + const handleClick = useCallback(() => { + openNewTab({ + type: TabTypesEnum.FILE, + path: relative_path, + repoRef: repo_ref, + }); + }, [repo_ref, relative_path, openNewTab]); + + return ( +
    + +
    + + + {/**/} +
    {relative_path}
    +
    +
      + {isExpanded + ? snippets.map((s, i) => ( + + )) + : null} +
    +
    + ); +}; + +export default memo(CodeResult); diff --git a/client/src/Project/LeftSidebar/RegexSearchPanel/Results/FileResult.tsx b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/FileResult.tsx new file mode 100644 index 0000000000..0d14c1b91e --- /dev/null +++ b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/FileResult.tsx @@ -0,0 +1,83 @@ +import { memo, useCallback, useContext, useEffect, useRef } from 'react'; +import FileIcon from '../../../../components/FileIcon'; +import { TabTypesEnum } from '../../../../types/general'; +import { TabsContext } from '../../../../context/tabsContext'; +import { RepoFileNameItem } from '../../../../types/api'; +import { FolderIcon } from '../../../../icons'; +import useKeyboardNavigation from '../../../../hooks/useKeyboardNavigation'; + +type Props = { + relative_path: RepoFileNameItem; + repo_ref: string; + is_dir: boolean; + isFocused: boolean; + isFirst: boolean; +}; + +const FileResult = ({ + relative_path, + repo_ref, + is_dir, + isFocused, + isFirst, +}: Props) => { + const { openNewTab } = useContext(TabsContext.Handlers); + const ref = useRef(null); + + useEffect(() => { + if (isFocused) { + ref.current?.scrollIntoView({ block: 'nearest' }); + } + }, [isFocused]); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + if (!is_dir) { + openNewTab({ + type: TabTypesEnum.FILE, + path: relative_path.text, + repoRef: repo_ref, + }); + } + } + }, + [repo_ref, relative_path, is_dir, openNewTab], + ); + useKeyboardNavigation(handleKeyEvent, !isFocused); + + const handleClick = useCallback(() => { + if (is_dir) { + return; + } + openNewTab({ + type: TabTypesEnum.FILE, + path: relative_path.text, + repoRef: repo_ref, + }); + }, [relative_path, repo_ref, is_dir, openNewTab]); + return ( +
    + {is_dir ? ( + + ) : ( + + )} + {/**/} +
    {relative_path.text}
    +
    + ); +}; + +export default memo(FileResult); diff --git a/client/src/Project/LeftSidebar/RegexSearchPanel/Results/RepoResult.tsx b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/RepoResult.tsx new file mode 100644 index 0000000000..63799d7f25 --- /dev/null +++ b/client/src/Project/LeftSidebar/RegexSearchPanel/Results/RepoResult.tsx @@ -0,0 +1,9 @@ +import { memo } from 'react'; + +type Props = {}; + +const RepoResult = ({}: Props) => { + return
    repo
    ; +}; + +export default memo(RepoResult); diff --git a/client/src/Project/LeftSidebar/RegexSearchPanel/index.tsx b/client/src/Project/LeftSidebar/RegexSearchPanel/index.tsx new file mode 100644 index 0000000000..62c9fc41ec --- /dev/null +++ b/client/src/Project/LeftSidebar/RegexSearchPanel/index.tsx @@ -0,0 +1,278 @@ +import { + ChangeEvent, + FormEvent, + memo, + useCallback, + useContext, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +// import throttle from 'lodash.throttle'; +// import { useCombobox } from 'downshift'; +import { CloseSignIcon, HardDriveIcon, RegexSearchIcon } from '../../../icons'; +import Button from '../../../components/Button'; +import useKeyboardNavigation from '../../../hooks/useKeyboardNavigation'; +import { search } from '../../../services/api'; +import { + CodeItem, + DirectoryItem, + FileItem, + FileResItem, + RepoItem, +} from '../../../types/api'; +import GitHubIcon from '../../../icons/GitHubIcon'; +import { splitPath } from '../../../utils'; +import { ProjectContext } from '../../../context/projectContext'; +import { CommandBarContext } from '../../../context/commandBarContext'; +import { regexToggleShortcut } from '../../../consts/shortcuts'; +import CodeResult from './Results/CodeResult'; +import RepoResult from './Results/RepoResult'; +import FileResult from './Results/FileResult'; + +type Props = { + projectId?: string; + isRegexEnabled?: boolean; +}; + +// const getAutocompleteThrottled = throttle( +// async ( +// query: string, +// setOptions: (o: SuggestionType[]) => void, +// ): Promise => { +// const newOptions = await getAutocomplete(query); +// setOptions(mapResults(newOptions)); +// }, +// 100, +// { trailing: true, leading: false }, +// ); + +type ResultType = CodeItem | RepoItem | FileResItem | DirectoryItem | FileItem; + +const RegexSearchPanel = ({ projectId, isRegexEnabled }: Props) => { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(''); + // const [options, setOptions] = useState([]); + const [results, setResults] = useState>({}); + const [resultsRaw, setResultsRaw] = useState([]); + const inputRef = useRef(null); + const { setIsRegexSearchEnabled } = useContext(ProjectContext.RegexSearch); + const { isVisible } = useContext(CommandBarContext.General); + const [focusedIndex, setFocusedIndex] = useState(-1); + + const onChange = useCallback((e: ChangeEvent) => { + setInputValue(e.target.value); + setFocusedIndex(-1); + }, []); + + const onClear = useCallback(() => { + setInputValue(''); + if (!inputValue) { + setIsRegexSearchEnabled(false); + } + }, [inputValue]); + + // const { + // isOpen, + // getMenuProps, + // getInputProps, + // getItemProps, + // closeMenu, + // highlightedIndex, + // } = useCombobox({ + // inputValue, + // onStateChange: async (state) => { + // if ( + // state.type === useCombobox.stateChangeTypes.ItemClick || + // state.type === useCombobox.stateChangeTypes.InputKeyDownEnter + // ) { + // if (state.selectedItem?.type === ResultItemType.FLAG) { + // const words = inputValue.split(' '); + // words[words.length - 1] = + // state.selectedItem?.data || words[words.length - 1]; + // const newInputValue = words.join(' ') + ':'; + // setInputValue(newInputValue); + // } else if (state.selectedItem?.type === ResultItemType.LANG) { + // setInputValue( + // (prev) => + // prev.split(':').slice(0, -1).join(':') + + // ':' + + // (state.selectedItem as LangResult)?.data, + // ); + // } else { + // if ( + // state.selectedItem?.type === ResultItemType.FILE || + // state.selectedItem?.type === ResultItemType.CODE + // ) { + // openNewTab({ + // type: TabTypesEnum.FILE, + // branch: null, + // repoRef: state.selectedItem.repoRef, + // path: state.selectedItem.relativePath, + // }); + // } + // } + // inputRef.current?.focus(); + // } else if (state.type === useCombobox.stateChangeTypes.InputChange) { + // if (state.inputValue === '') { + // setInputValue(state.inputValue); + // setOptions([]); + // return; + // } + // if (!state.inputValue) { + // return; + // } + // let autocompleteQuery = state.inputValue; + // getAutocompleteThrottled(autocompleteQuery, setOptions); + // } + // }, + // items: options, + // itemToString(item) { + // return ( + // (item?.type === ResultItemType.FLAG || + // item?.type === ResultItemType.LANG + // ? item?.data + // : '') || '' + // ); + // }, + // }); + + const onSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + if (projectId) { + const data = await search(projectId, inputValue); + const newResults: Record = {}; + data.data.forEach((d) => { + if (!newResults[d.data.repo_ref]) { + newResults[d.data.repo_ref] = [d]; + } else { + newResults[d.data.repo_ref].push(d); + } + }); + setResults(newResults); + setResultsRaw(data.data); + // closeMenu(); + } + }, + [inputValue], + ); + + const handleKeyEvent = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + inputRef.current?.blur(); + } else if (e.key === 'ArrowDown' && resultsRaw.length) { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev < resultsRaw.length - 1 ? prev + 1 : -1, + ); + } else if (e.key === 'ArrowUp' && resultsRaw.length) { + e.preventDefault(); + e.stopPropagation(); + setFocusedIndex((prev) => + prev > -1 ? prev - 1 : resultsRaw.length - 1, + ); + } + }, + [resultsRaw], + ); + useKeyboardNavigation(handleKeyEvent, isVisible || !isRegexEnabled); + + return !isRegexEnabled ? null : ( +
    + + + + + + {/**/} + {!!Object.keys(results).length && ( +
      + {Object.keys(results).map((repoRef, repoIndex, array) => ( +
    • + + + {repoRef.startsWith('github.com/') ? ( + + ) : ( + + )} + {splitPath(repoRef) + .slice(repoRef.startsWith('github.com/') ? -2 : -1) + .join('/')} + +
        + {results[repoRef].map((r, i) => ( +
      • + {r.kind === 'snippets' ? ( + + ) : r.kind === 'repository_result' ? ( + + ) : r.kind === 'file_result' ? ( + + ) : ( + r.kind + )} +
      • + ))} +
      +
    • + ))} +
    + )} +
    + ); +}; + +export default memo(RegexSearchPanel); diff --git a/client/src/Project/LeftSidebar/index.tsx b/client/src/Project/LeftSidebar/index.tsx new file mode 100644 index 0000000000..c1057df6ea --- /dev/null +++ b/client/src/Project/LeftSidebar/index.tsx @@ -0,0 +1,60 @@ +import React, { memo, useContext } from 'react'; +import useResizeableWidth from '../../hooks/useResizeableWidth'; +import { LEFT_SIDEBAR_WIDTH_KEY } from '../../services/storage'; +import ProjectsDropdown from '../../components/Header/ProjectsDropdown'; +import { ChevronDownIcon } from '../../icons'; +import Dropdown from '../../components/Dropdown'; +import { DeviceContext } from '../../context/deviceContext'; +import { ProjectContext } from '../../context/projectContext'; +import NavPanel from './NavPanel'; +import RegexSearchPanel from './RegexSearchPanel'; + +type Props = {}; + +const LeftSidebar = ({}: Props) => { + const { os } = useContext(DeviceContext); + const { project } = useContext(ProjectContext.Current); + const { isRegexSearchEnabled } = useContext(ProjectContext.RegexSearch); + const { panelRef, dividerRef } = useResizeableWidth( + true, + LEFT_SIDEBAR_WIDTH_KEY, + 20, + 40, + ); + return ( +
    +
    + {os.type === 'Darwin' ? : ''} + +
    +

    + {project?.name || 'Default project'} +

    + +
    +
    +
    + + {!isRegexSearchEnabled && } +
    +
    +
    +
    + ); +}; + +export default memo(LeftSidebar); diff --git a/client/src/Project/RightTab.tsx b/client/src/Project/RightTab.tsx new file mode 100644 index 0000000000..b6ba7ed0e5 --- /dev/null +++ b/client/src/Project/RightTab.tsx @@ -0,0 +1,38 @@ +import React, { memo } from 'react'; +import useResizeableWidth from '../hooks/useResizeableWidth'; +import { RIGHT_SIDEBAR_WIDTH_KEY } from '../services/storage'; +import { TabType } from '../types/general'; +import CurrentTabContent from './CurrentTabContent'; + +type Props = { + onDropToRight: (tab: TabType) => void; + moveToAnotherSide: (tab: TabType) => void; +}; + +const RightTab = ({ onDropToRight, moveToAnotherSide }: Props) => { + const { panelRef, dividerRef } = useResizeableWidth( + false, + RIGHT_SIDEBAR_WIDTH_KEY, + 40, + 60, + 15, + ); + + return ( +
    +
    +
    +
    + +
    + ); +}; + +export default memo(RightTab); diff --git a/client/src/Project/index.tsx b/client/src/Project/index.tsx new file mode 100644 index 0000000000..b09bf900c9 --- /dev/null +++ b/client/src/Project/index.tsx @@ -0,0 +1,112 @@ +import React, { memo, useCallback, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDragLayer } from 'react-dnd'; +import { ProjectContext } from '../context/projectContext'; +import { TabsContext } from '../context/tabsContext'; +import { TabType, TabTypesEnum } from '../types/general'; +import ChatsContextProvider from '../context/providers/ChatsContextProvider'; +import LeftSidebar from './LeftSidebar'; +import CurrentTabContent from './CurrentTabContent'; +import EmptyProject from './EmptyProject'; +import DropTarget from './CurrentTabContent/DropTarget'; +import RightTab from './RightTab'; +import ChatPersistentState from './CurrentTabContent/ChatTab/ChatPersistentState'; + +type Props = {}; + +const Project = ({}: Props) => { + useTranslation(); + const { project } = useContext(ProjectContext.Current); + const { rightTabs, leftTabs } = useContext(TabsContext.All); + const { + setActiveRightTab, + setActiveLeftTab, + setLeftTabs, + setFocusedPanel, + setRightTabs, + } = useContext(TabsContext.Handlers); + const { isDragging } = useDragLayer((monitor) => ({ + isDragging: monitor.isDragging(), + })); + + const onDropToRight = useCallback((tab: TabType) => { + setRightTabs((prev) => + prev.find((t) => t.key === tab.key) + ? prev + : [ + ...prev, + tab.type === TabTypesEnum.FILE && tab.isTemp + ? { ...tab, isTemp: false } + : tab, + ], + ); + setLeftTabs((prev) => { + const newTabs = prev.filter((s) => s.key !== tab.key); + setActiveLeftTab(newTabs[newTabs.length - 1]); + return newTabs; + }); + setActiveRightTab(tab); + setFocusedPanel('right'); + }, []); + + const onDropToLeft = useCallback((tab: TabType) => { + setLeftTabs((prev) => + prev.find((t) => t.key === tab.key) + ? prev + : [ + ...prev, + tab.type === TabTypesEnum.FILE && tab.isTemp + ? { ...tab, isTemp: false } + : tab, + ], + ); + setRightTabs((prev) => { + const newTabs = prev.filter((s) => s.key !== tab.key); + setActiveRightTab(newTabs[newTabs.length - 1]); + return newTabs; + }); + setActiveLeftTab(tab); + setFocusedPanel('left'); + }, []); + + return !project?.repos?.length ? ( + + ) : ( +
    + + +
    + + {!rightTabs.length && isDragging ? ( + + ) : null} +
    + {!!rightTabs.length && ( + + )} + {[...leftTabs, ...rightTabs].map((t, i) => + t.type === TabTypesEnum.CHAT ? ( + + ) : null, + )} +
    +
    + ); +}; + +export default memo(Project); diff --git a/client/src/ProjectSettings/AnswerSpeedDropdown.tsx b/client/src/ProjectSettings/AnswerSpeedDropdown.tsx new file mode 100644 index 0000000000..bf86ed2535 --- /dev/null +++ b/client/src/ProjectSettings/AnswerSpeedDropdown.tsx @@ -0,0 +1,39 @@ +import { memo, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import DropdownSection from '../components/Dropdown/Section'; +import SectionItem from '../components/Dropdown/Section/SectionItem'; +import { ProjectContext } from '../context/projectContext'; +import { RunIcon, WalkIcon } from '../icons'; + +type Props = {}; + +const AnswerSpeedDropdown = ({}: Props) => { + const { t } = useTranslation(); + const { preferredAnswerSpeed, setPreferredAnswerSpeed } = useContext( + ProjectContext.AnswerSpeed, + ); + return ( +
    + + setPreferredAnswerSpeed('normal')} + icon={} + description={t('Recommended: The classic response type')} + /> + + + setPreferredAnswerSpeed('fast')} + icon={} + description={t('Experimental: Faster but less accurate')} + /> + +
    + ); +}; + +export default memo(AnswerSpeedDropdown); diff --git a/client/src/ProjectSettings/General.tsx b/client/src/ProjectSettings/General.tsx new file mode 100644 index 0000000000..bec750648b --- /dev/null +++ b/client/src/ProjectSettings/General.tsx @@ -0,0 +1,124 @@ +import React, { + ChangeEvent, + memo, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import TextInput from '../components/TextInput'; +import { ProjectContext } from '../context/projectContext'; +import Button from '../components/Button'; +import { deleteProject, updateProject } from '../services/api'; +import { UIContext } from '../context/uiContext'; +import Dropdown from '../components/Dropdown'; +import { ChevronDownIcon, RunIcon, WalkIcon } from '../icons'; +import AnswerSpeedDropdown from './AnswerSpeedDropdown'; + +type Props = {}; + +const General = ({}: Props) => { + const { t } = useTranslation(); + const { project, refreshCurrentProject, setCurrentProjectId } = useContext( + ProjectContext.Current, + ); + const { refreshAllProjects, projects } = useContext(ProjectContext.All); + const { preferredAnswerSpeed } = useContext(ProjectContext.AnswerSpeed); + const { setProjectSettingsOpen } = useContext(UIContext.ProjectSettings); + const [name, setName] = useState(project?.name || ''); + + useEffect(() => { + setName(project?.name || ''); + }, [project?.name]); + + const handleChange = useCallback((e: ChangeEvent) => { + setName(e.target.value); + }, []); + + const handleSubmit = useCallback(async () => { + if (project?.id && name) { + await updateProject(project?.id, { name }); + refreshCurrentProject(); + } + }, [project?.id, name, refreshCurrentProject]); + + const handleDelete = useCallback(async () => { + if (project?.id) { + await deleteProject(project?.id); + if (projects.length > 1) { + setCurrentProjectId( + projects.find((p) => p.id !== project.id)?.id || '', + ); + } + refreshAllProjects(); + refreshCurrentProject(); + setProjectSettingsOpen(false); + } + }, [project?.id, projects, refreshCurrentProject]); + + return ( +
    +
    +

    + General +

    +

    + Manage your general project settings +

    +
    +
    +
    + +
    +
    +
    +
    +

    + Answer speed +

    +

    + How fast or precise bloop's answers will be. +

    +
    + + + +
    +
    +
    +

    + Permanently delete{' '} + {project?.name} and + remove all the data associated to it. Repositories will remain + accessible in your GitHub account. +

    + +
    +
    + ); +}; + +export default memo(General); diff --git a/client/src/ProjectSettings/index.tsx b/client/src/ProjectSettings/index.tsx new file mode 100644 index 0000000000..ac0404547c --- /dev/null +++ b/client/src/ProjectSettings/index.tsx @@ -0,0 +1,60 @@ +import React, { memo, useCallback, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UIContext } from '../context/uiContext'; +import Header from '../components/Header'; +import useKeyboardNavigation from '../hooks/useKeyboardNavigation'; +import { ProjectSettingSections } from '../types/general'; +import SectionsNav from '../components/SectionsNav'; +import { ShapesIcon } from '../icons'; +import General from './General'; + +type Props = {}; + +const ProjectSettings = ({}: Props) => { + const { t } = useTranslation(); + const { + isProjectSettingsOpen, + setProjectSettingsOpen, + projectSettingsSection, + setProjectSettingsSection, + } = useContext(UIContext.ProjectSettings); + + const handleKeyEvent = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setProjectSettingsOpen(false); + } + }, []); + useKeyboardNavigation(handleKeyEvent, !isProjectSettingsOpen); + + return isProjectSettingsOpen ? ( +
    +
    +
    + + activeItem={projectSettingsSection} + sections={[ + { + title: t('Project'), + Icon: ShapesIcon, + items: [ + { + type: ProjectSettingSections.GENERAL, + onClick: setProjectSettingsSection, + label: t('General'), + }, + ], + }, + ]} + /> + {projectSettingsSection === ProjectSettingSections.GENERAL ? ( + + ) : null} +
    +
    +
    + ) : null; +}; + +export default memo(ProjectSettings); diff --git a/client/src/Settings/General.tsx b/client/src/Settings/General.tsx new file mode 100644 index 0000000000..751cbae189 --- /dev/null +++ b/client/src/Settings/General.tsx @@ -0,0 +1,98 @@ +import React, { + ChangeEvent, + memo, + useCallback, + useMemo, + useState, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import TextInput from '../components/TextInput'; +import { + getJsonFromStorage, + saveJsonToStorage, + USER_DATA_FORM, +} from '../services/storage'; +import { EMAIL_REGEX } from '../consts/validations'; + +type Props = {}; + +type Form = { + firstName: string; + lastName: string; + email: string; + emailError?: string; +}; + +const GeneralSettings = ({}: Props) => { + const { t } = useTranslation(); + const savedForm: Form | null = useMemo( + () => getJsonFromStorage(USER_DATA_FORM), + [], + ); + const [form, setForm] = useState
    ({ + firstName: savedForm?.firstName || '', + lastName: savedForm?.lastName || '', + email: savedForm?.email || '', + emailError: '', + }); + + const onChange = useCallback((e: ChangeEvent) => { + setForm((prev) => { + const newForm = { + ...prev, + [e.target.name]: e.target.value, + emailError: e.target.name === 'email' ? '' : prev.emailError, + }; + saveJsonToStorage(USER_DATA_FORM, newForm); + return newForm; + }); + }, []); + + return ( +
    +
    +

    + General +

    +

    + Manage your general account settings +

    +
    +
    +
    + + +
    +
    +
    + { + if (!EMAIL_REGEX.test(form.email)) { + setForm((prev) => ({ + ...prev, + emailError: t('Email is not valid'), + })); + } + }} + error={form.emailError} + /> +
    +
    + ); +}; + +export default memo(GeneralSettings); diff --git a/client/src/Settings/Preferences/LanguageDropdown.tsx b/client/src/Settings/Preferences/LanguageDropdown.tsx new file mode 100644 index 0000000000..6994a68536 --- /dev/null +++ b/client/src/Settings/Preferences/LanguageDropdown.tsx @@ -0,0 +1,32 @@ +import { memo, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import SectionItem from '../../components/Dropdown/Section/SectionItem'; +import { MacintoshIcon } from '../../icons'; +import { LocaleContext } from '../../context/localeContext'; +import { localesMap } from '../../consts/general'; +import { LocaleType } from '../../types/general'; + +type Props = {}; + +const LanguageDropdown = ({}: Props) => { + const { t } = useTranslation(); + const { locale, setLocale } = useContext(LocaleContext); + + return ( +
    +
    + {(Object.keys(localesMap) as LocaleType[]).map((k) => ( + setLocale(k)} + label={localesMap[k].name} + icon={{localesMap[k].icon}} + /> + ))} +
    +
    + ); +}; + +export default memo(LanguageDropdown); diff --git a/client/src/Settings/Preferences/ThemeDropdown.tsx b/client/src/Settings/Preferences/ThemeDropdown.tsx new file mode 100644 index 0000000000..988ec25b1b --- /dev/null +++ b/client/src/Settings/Preferences/ThemeDropdown.tsx @@ -0,0 +1,52 @@ +import { memo, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import SectionItem from '../../components/Dropdown/Section/SectionItem'; +import { + MacintoshIcon, + ThemeBlackIcon, + ThemeDarkIcon, + ThemeLightIcon, +} from '../../icons'; +import { UIContext } from '../../context/uiContext'; + +type Props = {}; + +const ThemeDropdown = ({}: Props) => { + const { t } = useTranslation(); + const { theme, setTheme } = useContext(UIContext.Theme); + + return ( +
    +
    + setTheme('system')} + label={t('System preferences')} + icon={} + /> +
    +
    + setTheme('light')} + label={t('Light')} + icon={} + /> + setTheme('dark')} + label={t('Dark')} + icon={} + /> + setTheme('black')} + label={t('Black')} + icon={} + /> +
    +
    + ); +}; + +export default memo(ThemeDropdown); diff --git a/client/src/Settings/Preferences/index.tsx b/client/src/Settings/Preferences/index.tsx new file mode 100644 index 0000000000..2d2f221efd --- /dev/null +++ b/client/src/Settings/Preferences/index.tsx @@ -0,0 +1,89 @@ +import React, { memo, useContext } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import Dropdown from '../../components/Dropdown'; +import Button from '../../components/Button'; +import { + ChevronDownIcon, + MacintoshIcon, + ThemeBlackIcon, + ThemeDarkIcon, + ThemeLightIcon, +} from '../../icons'; +import { UIContext } from '../../context/uiContext'; +import { localesMap, themesMap } from '../../consts/general'; +import { LocaleContext } from '../../context/localeContext'; +import ThemeDropdown from './ThemeDropdown'; +import LanguageDropdown from './LanguageDropdown'; + +type Props = {}; + +export const themeIconsMap = { + light: , + dark: , + black: , + system: , +}; +const Preferences = ({}: Props) => { + useTranslation(); + const { theme } = useContext(UIContext.Theme); + const { locale } = useContext(LocaleContext); + + return ( +
    +
    +

    + Preferences +

    +

    + Manage your preferences +

    +
    +
    +
    +
    +

    + Theme +

    +

    + Select the interface colour scheme +

    +
    + + + +
    +
    +
    +
    +

    + Language +

    +

    + Select the interface language +

    +
    + + + +
    +
    + ); +}; + +export default memo(Preferences); diff --git a/client/src/Settings/Subscription/BenefitItem.tsx b/client/src/Settings/Subscription/BenefitItem.tsx new file mode 100644 index 0000000000..40df778c5d --- /dev/null +++ b/client/src/Settings/Subscription/BenefitItem.tsx @@ -0,0 +1,17 @@ +import { memo } from 'react'; +import { CheckIcon } from '../../icons'; + +type Props = { + text: string; +}; + +const BenefitItem = ({ text }: Props) => { + return ( +
    + +

    {text}

    +
    + ); +}; + +export default memo(BenefitItem); diff --git a/client/src/Settings/Subscription/CardFree.tsx b/client/src/Settings/Subscription/CardFree.tsx new file mode 100644 index 0000000000..400ab4b646 --- /dev/null +++ b/client/src/Settings/Subscription/CardFree.tsx @@ -0,0 +1,50 @@ +import React, { memo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import Badge from '../../components/Badge'; +import Button from '../../components/Button'; +import LiteLoaderContainer from '../../components/Loaders/LiteLoader'; +import BenefitItem from './BenefitItem'; + +type Props = { + isActive: boolean; + isFetchingLink: boolean; + onManage: () => void; +}; + +const CardFree = ({ isActive, onManage, isFetchingLink }: Props) => { + const { t } = useTranslation(); + return ( +
    +
    +

    + Individual +

    +

    + Free +

    +
    +
    + + + + + +
    + {isActive ? ( +
    + +
    + ) : ( + + )} +
    + ); +}; + +export default memo(CardFree); diff --git a/client/src/Settings/Subscription/CardPaid.tsx b/client/src/Settings/Subscription/CardPaid.tsx new file mode 100644 index 0000000000..b467eb8663 --- /dev/null +++ b/client/src/Settings/Subscription/CardPaid.tsx @@ -0,0 +1,76 @@ +import React, { + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import Button from '../../components/Button'; +import Badge from '../../components/Badge'; +import { getSubscriptionLink } from '../../services/api'; +import { polling } from '../../utils/requestUtils'; +import { DeviceContext } from '../../context/deviceContext'; +import { UIContext } from '../../context/uiContext'; +import { PersonalQuotaContext } from '../../context/personalQuotaContext'; +import LiteLoaderContainer from '../../components/Loaders/LiteLoader'; +import BenefitItem from './BenefitItem'; +import Confetti from './Confetti'; + +type Props = { + isActive: boolean; + onUpgrade: () => void; + hasUpgraded: boolean; + isFetchingLink: boolean; +}; + +const CardPaid = ({ + isActive, + hasUpgraded, + onUpgrade, + isFetchingLink, +}: Props) => { + const { t } = useTranslation(); + + return ( +
    + {hasUpgraded && } +
    +

    + Personal +

    +

    + $20{' '} + + / billed monthly + +

    +
    +
    + + + + + + + +
    + {isActive ? ( +
    + +
    + ) : ( + + )} +
    + ); +}; + +export default memo(CardPaid); diff --git a/client/src/Settings/Subscription/Confetti.tsx b/client/src/Settings/Subscription/Confetti.tsx new file mode 100644 index 0000000000..0802489683 --- /dev/null +++ b/client/src/Settings/Subscription/Confetti.tsx @@ -0,0 +1,154 @@ +import { memo } from 'react'; + +type Props = {}; + +const Confetti = ({}: Props) => { + return ( +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + ); +}; + +export default memo(Confetti); diff --git a/client/src/Settings/Subscription/index.tsx b/client/src/Settings/Subscription/index.tsx new file mode 100644 index 0000000000..50c338a16d --- /dev/null +++ b/client/src/Settings/Subscription/index.tsx @@ -0,0 +1,201 @@ +import React, { + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { PersonalQuotaContext } from '../../context/personalQuotaContext'; +import { getSubscriptionLink } from '../../services/api'; +import { DeviceContext } from '../../context/deviceContext'; +import { UIContext } from '../../context/uiContext'; +import LiteLoaderContainer from '../../components/Loaders/LiteLoader'; +import { polling } from '../../utils/requestUtils'; +import Button from '../../components/Button'; +import Badge from '../../components/Badge'; +import SpinLoaderContainer from '../../components/Loaders/SpinnerLoader'; +import CardFree from './CardFree'; +import CardPaid from './CardPaid'; + +type Props = {}; + +const SubscriptionSettings = ({}: Props) => { + useTranslation(); + const [isFetchingLink, setIsFetchingLink] = useState(false); + const [isUpgradeRequested, setIsUpgradeRequested] = useState(false); + const { isSubscribed } = useContext(PersonalQuotaContext.Values); + const { refetchQuota } = useContext(PersonalQuotaContext.Handlers); + const { openLink } = useContext(DeviceContext); + const { setBugReportModalOpen } = useContext(UIContext.BugReport); + const [hasUpgraded, setHasUpgraded] = useState(false); + const [hasChecked, setHasChecked] = useState(false); + const intervalId = useRef(0); + + const handleUpgrade = useCallback(() => { + setIsFetchingLink(true); + getSubscriptionLink() + .then((resp) => { + if (resp.url) { + openLink(resp.url); + clearInterval(intervalId.current); + if (!isSubscribed) { + setIsUpgradeRequested(true); + intervalId.current = polling(() => refetchQuota(), 2000); + setTimeout(() => clearInterval(intervalId.current), 10 * 60 * 1000); + } + } else { + setBugReportModalOpen(true); + } + }) + .catch(() => { + setBugReportModalOpen(true); + }) + .finally(() => setIsFetchingLink(false)); + }, [openLink, isSubscribed]); + + useEffect(() => { + if (!hasUpgraded && isSubscribed && isUpgradeRequested) { + clearInterval(intervalId.current); + setHasUpgraded(true); + setIsUpgradeRequested(false); + } + }, [isSubscribed, hasUpgraded, isUpgradeRequested]); + + const handleCancel = useCallback(() => { + setIsUpgradeRequested(false); + clearInterval(intervalId.current); + }, []); + + const onManualOpenClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + getSubscriptionLink() + .then((resp) => { + if (resp.url) { + openLink(resp.url); + } else { + setBugReportModalOpen(true); + } + }) + .catch(() => { + setBugReportModalOpen(true); + }); + }, + [openLink], + ); + + const checkStatus = useCallback(() => { + refetchQuota().then(() => setHasChecked(true)); + }, []); + + return ( +
    +
    +
    +

    + {isUpgradeRequested ? ( + Upgrade to Personal plan + ) : ( + Plans + )} +

    +

    + {isUpgradeRequested ? ( + $20 / billed monthly + ) : ( + Manage your subscription plan + )} +

    +
    + {isUpgradeRequested && ( + + )} +
    +
    + {isUpgradeRequested ? ( +
    + +
    +

    + Complete your transaction in Stripe... +

    +

    + + We've redirected you to Stripe to complete your + transaction.{' '} + + Launch manually + {' '} + if it didn't work. + +

    +
    + {hasChecked && ( + + )} + +
    + ) : ( + <> +
    + + +
    +
    +
    +
    + Stripe +
    +

    + + All payments, invoices and billing information are managed in + Stripe. + +

    + {isSubscribed && ( + + )} +
    + + )} +
    + ); +}; + +export default memo(SubscriptionSettings); diff --git a/client/src/Settings/index.tsx b/client/src/Settings/index.tsx new file mode 100644 index 0000000000..9098e72f98 --- /dev/null +++ b/client/src/Settings/index.tsx @@ -0,0 +1,80 @@ +import React, { memo, useCallback, useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { UIContext } from '../context/uiContext'; +import Header from '../components/Header'; +import useKeyboardNavigation from '../hooks/useKeyboardNavigation'; +import { SettingSections } from '../types/general'; +import SectionsNav from '../components/SectionsNav'; +import { CogIcon } from '../icons'; +import General from './General'; +import Preferences from './Preferences'; +import SubscriptionSettings from './Subscription'; + +type Props = {}; + +const Settings = ({}: Props) => { + const { t } = useTranslation(); + const { + isSettingsOpen, + setSettingsOpen, + settingsSection, + setSettingsSection, + } = useContext(UIContext.Settings); + + const handleKeyEvent = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setSettingsOpen(false); + } + }, []); + useKeyboardNavigation(handleKeyEvent, !isSettingsOpen); + + const settingsSections = useMemo(() => { + return [ + { + title: t('Account settings'), + Icon: CogIcon, + items: [ + { + type: SettingSections.GENERAL, + label: t('General'), + onClick: setSettingsSection, + }, + { + type: SettingSections.PREFERENCES, + label: t('Preferences'), + onClick: setSettingsSection, + }, + { + type: SettingSections.SUBSCRIPTION, + label: t('Subscription'), + onClick: setSettingsSection, + }, + ], + }, + ]; + }, [t]); + + return isSettingsOpen ? ( +
    +
    +
    + + sections={settingsSections} + activeItem={settingsSection} + /> + {settingsSection === SettingSections.GENERAL ? ( + + ) : settingsSection === SettingSections.PREFERENCES ? ( + + ) : ( + + )} +
    +
    +
    + ) : null; +}; + +export default memo(Settings); diff --git a/client/src/circleProgress.css b/client/src/circleProgress.css deleted file mode 100644 index b5c1e1b6cf..0000000000 --- a/client/src/circleProgress.css +++ /dev/null @@ -1,234 +0,0 @@ -.progress-circle { - font-size: 4px; - position: relative; /* so that children can be absolutely positioned */ - padding: 0; - width: 5em; - height: 5em; - background-color: var(--bg-shade); - border-radius: 50%; - line-height: 5em; -} - -.progress-circle:after{ - border: none; - position: absolute; - top: 0.35em; - left: 0.35em; - text-align: center; - display: block; - border-radius: 50%; - width: 4.3em; - height: 4.3em; - background-color: var(--bg-sub); - content: " "; -} -/* Text inside the control */ -.progress-circle span { - position: absolute; - line-height: 5em; - width: 5em; - text-align: center; - display: block; - color: var(--label-base); - z-index: 2; -} -.left-half-clipper { - /* a round circle */ - border-radius: 50%; - width: 5em; - height: 5em; - position: absolute; /* needed for clipping */ - clip: rect(0, 5em, 5em, 2.5em); /* clips the whole left half*/ -} -/* when p>50, don't clip left half*/ -.progress-circle.over50 .left-half-clipper { - clip: rect(auto,auto,auto,auto); -} -.value-bar { - /*This is an overlayed square, that is made round with the border radius, - then it is cut to display only the left half, then rotated clockwise - to escape the outer clipping path.*/ - position: absolute; /*needed for clipping*/ - clip: rect(0, 2.5em, 5em, 0); - width: 5em; - height: 5em; - border-radius: 50%; - border: 0.45em solid var(--label-base); /*The border is 0.35 but making it larger removes visual artifacts */ - /*background-color: #4D642D;*/ /* for debug */ - box-sizing: border-box; - -} -/* Progress bar filling the whole right half for values above 50% */ -.progress-circle.over50 .first50-bar { - /*Progress bar for the first 50%, filling the whole right half*/ - position: absolute; /*needed for clipping*/ - clip: rect(0, 5em, 5em, 2.5em); - background-color: var(--label-base); - border-radius: 50%; - width: 5em; - height: 5em; -} -.progress-circle:not(.over50) .first50-bar{ display: none; } - - -/* Progress bar rotation position */ -.progress-circle.p0 .value-bar { display: none; } -.progress-circle.p1 .value-bar { transform: rotate(4deg); } -.progress-circle.p2 .value-bar { transform: rotate(7deg); } -.progress-circle.p3 .value-bar { transform: rotate(11deg); } -.progress-circle.p4 .value-bar { transform: rotate(14deg); } -.progress-circle.p5 .value-bar { transform: rotate(18deg); } -.progress-circle.p6 .value-bar { transform: rotate(22deg); } -.progress-circle.p7 .value-bar { transform: rotate(25deg); } -.progress-circle.p8 .value-bar { transform: rotate(29deg); } -.progress-circle.p9 .value-bar { transform: rotate(32deg); } -.progress-circle.p10 .value-bar { transform: rotate(36deg); } -.progress-circle.p11 .value-bar { transform: rotate(40deg); } -.progress-circle.p12 .value-bar { transform: rotate(43deg); } -.progress-circle.p13 .value-bar { transform: rotate(47deg); } -.progress-circle.p14 .value-bar { transform: rotate(50deg); } -.progress-circle.p15 .value-bar { transform: rotate(54deg); } -.progress-circle.p16 .value-bar { transform: rotate(58deg); } -.progress-circle.p17 .value-bar { transform: rotate(61deg); } -.progress-circle.p18 .value-bar { transform: rotate(65deg); } -.progress-circle.p19 .value-bar { transform: rotate(68deg); } -.progress-circle.p20 .value-bar { transform: rotate(72deg); } -.progress-circle.p21 .value-bar { transform: rotate(76deg); } -.progress-circle.p22 .value-bar { transform: rotate(79deg); } -.progress-circle.p23 .value-bar { transform: rotate(83deg); } -.progress-circle.p24 .value-bar { transform: rotate(86deg); } -.progress-circle.p25 .value-bar { transform: rotate(90deg); } -.progress-circle.p26 .value-bar { transform: rotate(94deg); } -.progress-circle.p27 .value-bar { transform: rotate(97deg); } -.progress-circle.p28 .value-bar { transform: rotate(101deg); } -.progress-circle.p29 .value-bar { transform: rotate(104deg); } -.progress-circle.p30 .value-bar { transform: rotate(108deg); } -.progress-circle.p31 .value-bar { transform: rotate(112deg); } -.progress-circle.p32 .value-bar { transform: rotate(115deg); } -.progress-circle.p33 .value-bar { transform: rotate(119deg); } -.progress-circle.p34 .value-bar { transform: rotate(122deg); } -.progress-circle.p35 .value-bar { transform: rotate(126deg); } -.progress-circle.p36 .value-bar { transform: rotate(130deg); } -.progress-circle.p37 .value-bar { transform: rotate(133deg); } -.progress-circle.p38 .value-bar { transform: rotate(137deg); } -.progress-circle.p39 .value-bar { transform: rotate(140deg); } -.progress-circle.p40 .value-bar { transform: rotate(144deg); } -.progress-circle.p41 .value-bar { transform: rotate(148deg); } -.progress-circle.p42 .value-bar { transform: rotate(151deg); } -.progress-circle.p43 .value-bar { transform: rotate(155deg); } -.progress-circle.p44 .value-bar { transform: rotate(158deg); } -.progress-circle.p45 .value-bar { transform: rotate(162deg); } -.progress-circle.p46 .value-bar { transform: rotate(166deg); } -.progress-circle.p47 .value-bar { transform: rotate(169deg); } -.progress-circle.p48 .value-bar { transform: rotate(173deg); } -.progress-circle.p49 .value-bar { transform: rotate(176deg); } -.progress-circle.p50 .value-bar { transform: rotate(180deg); } -.progress-circle.p51 .value-bar { transform: rotate(184deg); } -.progress-circle.p52 .value-bar { transform: rotate(187deg); } -.progress-circle.p53 .value-bar { transform: rotate(191deg); } -.progress-circle.p54 .value-bar { transform: rotate(194deg); } -.progress-circle.p55 .value-bar { transform: rotate(198deg); } -.progress-circle.p56 .value-bar { transform: rotate(202deg); } -.progress-circle.p57 .value-bar { transform: rotate(205deg); } -.progress-circle.p58 .value-bar { transform: rotate(209deg); } -.progress-circle.p59 .value-bar { transform: rotate(212deg); } -.progress-circle.p60 .value-bar { transform: rotate(216deg); } -.progress-circle.p61 .value-bar { transform: rotate(220deg); } -.progress-circle.p62 .value-bar { transform: rotate(223deg); } -.progress-circle.p63 .value-bar { transform: rotate(227deg); } -.progress-circle.p64 .value-bar { transform: rotate(230deg); } -.progress-circle.p65 .value-bar { transform: rotate(234deg); } -.progress-circle.p66 .value-bar { transform: rotate(238deg); } -.progress-circle.p67 .value-bar { transform: rotate(241deg); } -.progress-circle.p68 .value-bar { transform: rotate(245deg); } -.progress-circle.p69 .value-bar { transform: rotate(248deg); } -.progress-circle.p70 .value-bar { transform: rotate(252deg); } -.progress-circle.p71 .value-bar { transform: rotate(256deg); } -.progress-circle.p72 .value-bar { transform: rotate(259deg); } -.progress-circle.p73 .value-bar { transform: rotate(263deg); } -.progress-circle.p74 .value-bar { transform: rotate(266deg); } -.progress-circle.p75 .value-bar { transform: rotate(270deg); } -.progress-circle.p76 .value-bar { transform: rotate(274deg); } -.progress-circle.p77 .value-bar { transform: rotate(277deg); } -.progress-circle.p78 .value-bar { transform: rotate(281deg); } -.progress-circle.p79 .value-bar { transform: rotate(284deg); } -.progress-circle.p80 .value-bar { transform: rotate(288deg); } -.progress-circle.p81 .value-bar { transform: rotate(292deg); } -.progress-circle.p82 .value-bar { transform: rotate(295deg); } -.progress-circle.p83 .value-bar { transform: rotate(299deg); } -.progress-circle.p84 .value-bar { transform: rotate(302deg); } -.progress-circle.p85 .value-bar { transform: rotate(306deg); } -.progress-circle.p86 .value-bar { transform: rotate(310deg); } -.progress-circle.p87 .value-bar { transform: rotate(313deg); } -.progress-circle.p88 .value-bar { transform: rotate(317deg); } -.progress-circle.p89 .value-bar { transform: rotate(320deg); } -.progress-circle.p90 .value-bar { transform: rotate(324deg); } -.progress-circle.p91 .value-bar { transform: rotate(328deg); } -.progress-circle.p92 .value-bar { transform: rotate(331deg); } -.progress-circle.p93 .value-bar { transform: rotate(335deg); } -.progress-circle.p94 .value-bar { transform: rotate(338deg); } -.progress-circle.p95 .value-bar { transform: rotate(342deg); } -.progress-circle.p96 .value-bar { transform: rotate(346deg); } -.progress-circle.p97 .value-bar { transform: rotate(349deg); } -.progress-circle.p98 .value-bar { transform: rotate(353deg); } -.progress-circle.p99 .value-bar { transform: rotate(356deg); } -.progress-circle.p100 .value-bar { transform: rotate(360deg); } - - -/* Three dots loader */ -.three-dots-loader, -.three-dots-loader:before, -.three-dots-loader:after { - border-radius: 50%; - width: 0.5em; - height: 0.5em; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; - -webkit-animation: load7 1.8s infinite ease-in-out; - animation: load7 1.8s infinite ease-in-out; -} -.three-dots-loader { - color: currentColor; - margin: 0 0.7em; - position: relative; - text-indent: -9999em; - -webkit-transform: translateZ(0); - -ms-transform: translateZ(0); - transform: translateZ(0); - -webkit-animation-delay: -0.16s; - animation-delay: -0.16s; -} -.three-dots-loader:before, -.three-dots-loader:after { - content: ''; - position: absolute; - top: 0; -} -.three-dots-loader:before { - left: -0.7em; - -webkit-animation-delay: -0.32s; - animation-delay: -0.32s; -} -.three-dots-loader:after { - left: 0.7em; -} -@-webkit-keyframes load7 { - 0%, - 80%, - 100% { - box-shadow: 0 0.5em 0 -0.3em; - } - 40% { - box-shadow: 0 0.5em 0 0; - } -} -@keyframes load7 { - 0%, - 80%, - 100% { - box-shadow: 0 0.5em 0 -0.3em; - } - 40% { - box-shadow: 0 0.5em 0 0; - } -} diff --git a/client/src/components/Accordion/Accordion.stories.tsx b/client/src/components/Accordion/Accordion.stories.tsx deleted file mode 100644 index 41c6539d3d..0000000000 --- a/client/src/components/Accordion/Accordion.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Papers } from '../../icons'; -import Accordion from './index'; - -export default { - title: 'components/Accordion', - component: Accordion, -}; - -export const Default = () => { - return ( - } - headerItems={['one', 'two', 'three'].map((i) => ( - {i} - ))} - > -

    Item 1

    -

    Item 2

    -
    - ); -}; diff --git a/client/src/components/Accordion/index.tsx b/client/src/components/Accordion/index.tsx deleted file mode 100644 index c459629230..0000000000 --- a/client/src/components/Accordion/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React, { memo, useEffect, useState } from 'react'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Trans } from 'react-i18next'; -import { ChevronDownFilled, ChevronUpFilled } from '../../icons'; -import { ACCORDION_CHILDREN_ANIMATION } from '../../consts/animations'; -import Button from '../Button'; - -type Props = { - title: string | React.ReactNode; - icon: React.ReactElement; - headerItems?: React.ReactNode; - children: React.ReactNode; - shownItems?: React.ReactNode; - defaultExpanded?: boolean; - onToggle?: (b: boolean) => void; -}; - -const zeroHeight = { height: 0 }; -const autoHeight = { height: 'auto' }; - -const Accordion = ({ - title, - icon, - children, - headerItems, - shownItems, - defaultExpanded = true, - onToggle, -}: Props) => { - const [expanded, setExpanded] = useState(defaultExpanded); - useEffect(() => { - setExpanded(defaultExpanded); - }, [defaultExpanded]); - return ( -
    - { - if (!document.getSelection()?.toString()) { - setExpanded(!expanded); - onToggle?.(!expanded); - } - }} - > - - {icon} - {title} - - - {headerItems} - - - {expanded ? : } - - - - - {!!shownItems && ( -
    - {shownItems} -
    - -
    -
    - )} - - {expanded && ( - - {children} - - )} - -
    - ); -}; - -export default memo(Accordion); diff --git a/client/src/components/AddStudioContext/index.tsx b/client/src/components/AddStudioContext/index.tsx deleted file mode 100644 index 6d5cf08872..0000000000 --- a/client/src/components/AddStudioContext/index.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, { - memo, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { CloseSign, PlusSignInCircle } from '../../icons'; -import SeparateOnboardingStep from '../SeparateOnboardingStep'; -import DialogText from '../SeparateOnboardingStep/DialogText'; -import SearchableRepoList from '../RepoList/SearchableRepoList'; -import Button from '../Button'; -import { - getCodeStudio, - getCodeStudios, - importCodeStudio, - patchCodeStudio, - postCodeStudio, -} from '../../services/api'; -import Tooltip from '../Tooltip'; -import { UIContext } from '../../context/uiContext'; -import { SearchContext } from '../../context/searchContext'; -import { TabsContext } from '../../context/tabsContext'; -import { CodeStudioShortType } from '../../types/general'; - -type Props = - | { - filePath: string; - threadId?: never; - name?: never; - } - | { threadId: string; filePath?: never; name: string }; - -const AddStudioContext = ({ filePath, threadId, name }: Props) => { - const { t } = useTranslation(); - const { tab } = useContext(UIContext.Tab); - const { handleAddStudioTab } = useContext(TabsContext); - const { selectedBranch } = useContext(SearchContext.SelectedBranch); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isOpen, setIsOpen] = useState(false); - const [isLoading, setLoading] = useState(true); - const [studios, setStudios] = useState([]); - - const refetchStudios = useCallback(() => { - return getCodeStudios().then((list) => { - setStudios(list.sort((a, b) => (a.modified_at > b.modified_at ? -1 : 1))); - }); - }, []); - - useEffect(() => { - refetchStudios().finally(() => setLoading(false)); - }, [isOpen]); - - const onSubmit = useCallback( - async (studioId?: string) => { - setIsSubmitting(true); - if (filePath) { - if (studioId) { - try { - const studio = await getCodeStudio(studioId); - const exists = studio.context.find( - (f) => - f.path === filePath && - f.repo === tab.repoRef && - f.branch === selectedBranch, - ); - if (!exists) { - await patchCodeStudio(studioId, { - context: [ - ...studio.context, - { - path: filePath, - repo: tab.repoRef, - branch: selectedBranch, - ranges: [], - hidden: false, - }, - ], - }); - } - handleAddStudioTab(studio.name, studioId); - } catch (err) { - console.log(err); - } - } else { - const id = await postCodeStudio(); - await patchCodeStudio(id, { - context: [ - { - path: filePath, - repo: tab.repoRef, - branch: selectedBranch, - ranges: [], - hidden: false, - }, - ], - }); - handleAddStudioTab('New Studio', id); - refetchStudios(); - } - } else if (threadId) { - const id = await importCodeStudio(threadId, studioId); - let tabName = name; - if (studioId) { - const studio = studios.find((s) => s.id === studioId); - if (studio?.name) { - tabName = studio.name; - } - } - handleAddStudioTab(tabName, id); - refetchStudios(); - } - setIsSubmitting(false); - setIsOpen(false); - }, - [filePath, tab.repoRef, selectedBranch, studios, threadId, name], - ); - - return ( -
    - - - - setIsOpen(false)} - noWrapper - > -
    -
    - -
    - - {!!studios.length && ( -
    - -
    - )} - -
    -
    -
    - ); -}; - -export default memo(AddStudioContext); diff --git a/client/src/components/Badge/index.tsx b/client/src/components/Badge/index.tsx index eb0aea0c03..01dc031045 100644 --- a/client/src/components/Badge/index.tsx +++ b/client/src/components/Badge/index.tsx @@ -1,15 +1,57 @@ +import { memo } from 'react'; + type Props = { + type?: + | 'outlined' + | 'filled' + | 'green' + | 'green-subtle' + | 'red' + | 'red-subtle' + | 'yellow' + | 'yellow-subtle' + | 'blue' + | 'blue-subtle' + | 'studio'; + size?: 'large' | 'small' | 'mini'; + Icon?: (props: { + raw?: boolean | undefined; + sizeClassName?: string | undefined; + className?: string | undefined; + }) => JSX.Element; text: string; - active?: boolean; }; -const Badge = ({ text, active }: Props) => { +const typeMap = { + outlined: 'border border-bg-border text-label-base', + filled: 'bg-bg-border text-label-base', + green: 'bg-bg-green text-label-control', + 'green-subtle': 'bg-green-subtle text-green', + red: 'bg-bg-red text-label-control', + 'red-subtle': 'bg-red-subtle text-red', + yellow: 'bg-bg-yellow text-label-control', + 'yellow-subtle': 'bg-yellow-subtle text-yellow', + blue: 'bg-bg-blue text-label-control', + 'blue-subtle': 'bg-blue-subtle text-blue', + studio: + 'bg-[linear-gradient(110deg,#D92037_1.23%,#D9009D_77.32%)] text-label-control', +}; + +const sizeMap = { + large: { pill: 'h-7 px-2.5 gap-1.5 body-s', icon: 'w-4 h-4' }, + small: { pill: 'h-6 px-2 gap-1 body-mini', icon: 'w-3.5 h-3.5' }, + mini: { pill: 'h-5 px-1.5 gap-1 body-tiny', icon: 'w-3 h-3' }, +}; + +const Badge = ({ type = 'outlined', size = 'small', Icon, text }: Props) => { return ( - - {text} - + {!!Icon && } +

    {text}

    +
    ); }; -export default Badge; + +export default memo(Badge); diff --git a/client/src/components/Breadcrumbs/BreadcrumbSection.tsx b/client/src/components/Breadcrumbs/BreadcrumbSection.tsx index aa159ad025..2a62f08b09 100644 --- a/client/src/components/Breadcrumbs/BreadcrumbSection.tsx +++ b/client/src/components/Breadcrumbs/BreadcrumbSection.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, ReactElement } from 'react'; +import { memo, MouseEvent, ReactElement, useCallback } from 'react'; import { Range } from '../../types/results'; type HighlightedString = { @@ -44,7 +44,7 @@ const BreadcrumbSection = ({ limitSectionWidth, nonInteractive, }: Props) => { - const getHighlight = () => { + const getHighlight = useCallback(() => { if (highlight) { const left = label.substring(0, highlight.start); const search = label.substring(highlight.start, highlight.end + 1); @@ -60,7 +60,7 @@ const BreadcrumbSection = ({ ); } return label; - }; + }, [highlight, label]); return ( - + ); }; -export default BreadcrumbsCollapsed; +export default memo(BreadcrumbsCollapsed); diff --git a/client/src/components/BreadcrumbsPath/index.tsx b/client/src/components/Breadcrumbs/PathContainer.tsx similarity index 70% rename from client/src/components/BreadcrumbsPath/index.tsx rename to client/src/components/Breadcrumbs/PathContainer.tsx index d76677b8a7..8c9fdf082a 100644 --- a/client/src/components/BreadcrumbsPath/index.tsx +++ b/client/src/components/Breadcrumbs/PathContainer.tsx @@ -1,18 +1,18 @@ -import React, { useMemo } from 'react'; -import Breadcrumbs, { PathParts } from '../Breadcrumbs'; +import React, { memo, useMemo } from 'react'; import { breadcrumbsItemPath, isWindowsPath, + splitPath, splitPathForBreadcrumbs, } from '../../utils'; import { FileTreeFileType } from '../../types'; -import useAppNavigation from '../../hooks/useAppNavigation'; +import Breadcrumbs, { PathParts } from './index'; type BProps = React.ComponentProps; type Props = { path: string; - repo: string; + repoRef?: string; onClick?: (path: string, fileType?: FileTreeFileType) => void; shouldGoToFile?: boolean; nonInteractive?: boolean; @@ -20,20 +20,19 @@ type Props = { scrollContainerRef?: React.MutableRefObject; } & Omit; -const BreadcrumbsPath = ({ +const BreadcrumbsPathContainer = ({ path, onClick, - repo, shouldGoToFile, allowOverflow, scrollContainerRef, + repoRef, ...rest }: Props) => { - const { navigateRepoPath, navigateFullResult } = useAppNavigation(); const pathParts: PathParts[] = useMemo(() => { - return splitPathForBreadcrumbs(path, (e, item, index, pParts) => { + const pieces = splitPathForBreadcrumbs(path, (e, item, index, pParts) => { if (onClick) { - e.stopPropagation(); + e?.stopPropagation(); } const isLastPart = index === pParts.length - 1; const newPath = breadcrumbsItemPath( @@ -47,13 +46,17 @@ const BreadcrumbsPath = ({ isLastPart ? FileTreeFileType.FILE : FileTreeFileType.DIR, ); if (!isLastPart) { - navigateRepoPath(repo, newPath); + // navigateRepoPath(repo, newPath); } if (shouldGoToFile && isLastPart) { - navigateFullResult(path); + // navigateFullResult(path); } }); - }, [path, shouldGoToFile, onClick, repo]); + if (repoRef) { + pieces.unshift({ label: splitPath(repoRef).pop() || '' }); + } + return pieces; + }, [path, shouldGoToFile, onClick, repoRef]); return (
    ); }; -export default BreadcrumbsPath; + +export default memo(BreadcrumbsPathContainer); diff --git a/client/src/components/Breadcrumbs/index.tsx b/client/src/components/Breadcrumbs/index.tsx index f7e49f4cee..f5206bd31b 100644 --- a/client/src/components/Breadcrumbs/index.tsx +++ b/client/src/components/Breadcrumbs/index.tsx @@ -7,6 +7,7 @@ import React, { useRef, useState, ClipboardEvent, + memo, } from 'react'; import { Range } from '../../types/results'; import { copyToClipboard, isWindowsPath } from '../../utils'; @@ -26,7 +27,7 @@ type ItemElement = { export type PathParts = { icon?: ReactElement; link?: string; - onClick?: (e: MouseEvent) => void; + onClick?: (e?: MouseEvent) => void; underline?: boolean; } & (HighlightedString | ItemElement); @@ -170,4 +171,4 @@ const Breadcrumbs = ({ ); }; -export default Breadcrumbs; +export default memo(Breadcrumbs); diff --git a/client/src/components/Button/Button.stories.tsx b/client/src/components/Button/Button.stories.tsx deleted file mode 100644 index 9e521e4eae..0000000000 --- a/client/src/components/Button/Button.stories.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { Fragment } from 'react'; -import { MailIcon } from '../../icons'; -import Button from './index'; - -export default { - title: 'components/Button', - component: Button, -}; - -const sizes = ['large', 'medium', 'small', 'tiny'] as const; - -export const Primary = () => { - return ( -
    - {sizes.map((s) => ( - - {s} - - - - - ))} - {sizes.map((s) => ( - - {s} - - - - - ))} -
    - ); -}; - -export const Secondary = () => { - return ( -
    - {sizes.map((s) => ( - - {s} - - - - - ))} - {sizes.map((s) => ( - - {s} - - - - - ))} -
    - ); -}; - -export const Tertiary = () => { - return ( -
    - {sizes.map((s) => ( - - {s} - - - - - ))} - {sizes.map((s) => ( - - {s} - - - - - ))} -
    - ); -}; - -export const TertiaryOutlined = () => { - return ( -
    - {sizes.map((s) => ( - - {s} - - - - - ))} - {sizes.map((s) => ( - - {s} - - - - - ))} -
    - ); -}; diff --git a/client/src/components/Button/index.tsx b/client/src/components/Button/index.tsx index 873cfa2f10..e7c4cabdcd 100644 --- a/client/src/components/Button/index.tsx +++ b/client/src/components/Button/index.tsx @@ -12,20 +12,22 @@ import Tooltip from '../Tooltip'; type Props = { children: ReactNode; variant?: + | 'brand-default' | 'primary' | 'secondary' | 'tertiary' - | 'tertiary-outlined' - | 'tertiary-disabled' | 'tertiary-active' + | 'ghost' + | 'studio' | 'danger'; - size?: 'tiny' | 'small' | 'medium' | 'large'; + size?: 'mini' | 'small' | 'medium' | 'large'; className?: string; } & (OnlyIconProps | TextBtnProps); type OnlyIconProps = { onlyIcon: true; title: string; + shortcut?: string[]; tooltipPlacement?: TippyProps['placement']; }; @@ -33,40 +35,66 @@ type TextBtnProps = { onlyIcon?: false; tooltipPlacement?: never; title?: string; + shortcut?: string[]; }; const variantStylesMap = { + 'brand-default': + 'text-label-control border border-brand-default bg-brand-default shadow-low ' + + 'hover:bg-brand-default-hover ' + + 'focus:bg-brand-default-hover focus:shadow-rings-blue ' + + 'disabled:bg-bg-base disabled:border-none disabled:text-label-faint disabled:shadow-none ' + + 'disabled:hover:bg-bg-base', primary: - 'text-label-control bg-bg-main hover:bg-bg-main-hover focus:bg-bg-main-hover active:bg-bg-main active:shadow-rings-blue focus:shadow-rings-blue disabled:bg-bg-base disabled:text-label-muted disabled:hover:border-none disabled:hover:bg-bg-base disabled:active:shadow-none disabled:border-none', + 'text-label-contrast border border-bg-contrast bg-bg-contrast shadow-low ' + + 'hover:bg-bg-contrast-hover hover:border-bg-contrast-hover ' + + 'disabled:bg-bg-base disabled:border-none disabled:text-label-faint disabled:shadow-none ' + + 'disabled:hover:bg-bg-base', secondary: - 'text-label-title bg-bg-base border border-bg-border hover:border-bg-border-hover focus:border-bg-border-hover hover:bg-bg-base-hover focus:bg-bg-base-hover active:bg-bg-base disabled:bg-bg-base disabled:border-none disabled:text-label-muted shadow-low hover:shadow-none focus:shadow-none active:shadow-rings-gray disabled:shadow-none', + 'text-label-base border border-bg-border bg-bg-base shadow-low ' + + 'hover:text-label-title hover:bg-bg-base-hover hover:border-bg-border-hover ' + + 'disabled:text-label-faint disabled:shadow-none disabled:border-transparent' + + 'disabled:hover:text-label-faint disabled:hover:bg-bg-base disabled:hover:border-transparent', tertiary: - 'text-label-muted bg-transparent hover:text-label-title focus:text-label-title hover:bg-bg-base-hover focus:bg-bg-base-hover active:text-label-title active:bg-transparent disabled:bg-bg-base disabled:text-label-muted', - 'tertiary-active': 'text-label-title bg-bg-base-hover', - 'tertiary-outlined': - 'text-label-muted bg-transparent border border-bg-border hover:bg-bg-base-hover focus:bg-bg-base-hover active:bg-transparent hover:text-label-title focus:text-label-title active:text-label-title disabled:bg-bg-base disabled:text-label-muted disabled:border-transparent disabled:hover:border-transparent', - 'tertiary-disabled': - 'text-label-muted bg-transparent hover:text-label-title focus:text-label-title hover:bg-bg-base-hover focus:bg-bg-base-hover active:text-label-title active:bg-transparent disabled:opacity-50 disabled:text-label-muted disabled:hover:text-label-muted disabled:hover:bg-transparent', + 'text-label-muted bg-transparent ' + + 'hover:text-label-title hover:bg-bg-base-hover ' + + 'disabled:text-label-faint ' + + 'disabled:hover:text-label-faint disabled:hover:bg-transparent', + 'tertiary-active': + 'text-label-title bg-bg-base-hover disabled:text-label-faint', danger: - 'text-label-control bg-bg-danger hover:bg-bg-danger-hover focus:bg-bg-danger-hover active:bg-bg-danger active:shadow-low disabled:bg-bg-base disabled:text-label-muted disabled:hover:border-none disabled:hover:bg-bg-base disabled:active:shadow-none disabled:border-none', + 'text-red border border-bg-border bg-bg-base shadow-low ' + + 'hover:bg-bg-base-hover hover:border-bg-border-hover ' + + 'disabled:text-label-faint disabled:shadow-none disabled:bg-bg-base disabled:border-transparent' + + 'disabled:hover:text-label-faint disabled:hover:bg-bg-base disabled:hover:border-transparent', + ghost: + 'text-label-muted bg-transparent ' + + 'hover:text-label-title ' + + 'disabled:text-label-faint ' + + 'disabled:hover:text-label-faint', + studio: + 'text-label-control bg-brand-studio border border-brand-studio shadow-low ' + + 'hover:bg-brand-studio-hover ' + + 'disabled:text-label-faint disabled:bg-bg-base disabled:bg-transparent disabled:shadow-none' + + 'disabled:hover:bg-bg-base', }; const sizeMap = { - tiny: { - default: 'h-6 px-1 gap-1 caption-strong', - square: 'h-6 w-6 justify-center p-0', + mini: { + default: 'h-6 px-1.5 gap-1 body-mini-b rounded', + square: 'h-6 w-6 rounded', }, small: { - default: 'h-8 px-2 gap-1 caption-strong min-w-[70px]', - square: 'h-8 w-8 justify-center p-0', + default: 'h-7 px-2 gap-1 body-mini-b rounded', + square: 'h-7 w-8 rounded', }, medium: { - default: 'h-10 px-2.5 gap-2 callout min-w-[84px]', - square: 'h-10 w-10 justify-center p-0', + default: 'h-8 px-2.5 gap-1.5 body-s-b rounded-6', + square: 'h-8 w-10 rounded-6', }, large: { - default: 'h-11.5 px-3.5 gap-2 callout min-w-[84px]', - square: 'h-11.5 w-11.5 justify-center p-0', + default: 'h-9 px-3 gap-2 body-base-b rounded-6', + square: 'h-9 w-9 rounded-6', }, }; @@ -91,23 +119,22 @@ const Button = forwardRef< title, tooltipPlacement, type = 'button', + shortcut, ...rest }, ref, ) => { const buttonClassName = useMemo( () => - `py-0 rounded-4 focus:outline-none outline-none outline-0 flex items-center flex-grow-0 flex-shrink-0 ${ + `py-0 focus:outline-none outline-none outline-0 flex items-center justify-center flex-grow-0 flex-shrink-0 ${ variantStylesMap[variant] } ${onlyIcon ? sizeMap[size].square : sizeMap[size].default} ${ className || '' - } ${ - onlyIcon ? '' : 'justify-center' - } transition-all duration-300 ease-in-bounce select-none`, + } select-none transition-all duration-150 ease-in-out`, [variant, className, size, onlyIcon], ); return (onlyIcon && !rest.disabled) || title ? ( - + diff --git a/client/src/components/Chat/ChatBody/AllCoversations/ConversationListItem.tsx b/client/src/components/Chat/ChatBody/AllCoversations/ConversationListItem.tsx deleted file mode 100644 index 0f8286024a..0000000000 --- a/client/src/components/Chat/ChatBody/AllCoversations/ConversationListItem.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import { Trans } from 'react-i18next'; -import { QuillIcon } from '../../../../icons'; -import Button from '../../../Button'; - -type Props = { - onClick: () => void; - title: string; - subtitle: string; - onDelete: () => void; -}; - -const ConversationListItem = ({ - onClick, - title, - subtitle, - onDelete, -}: Props) => { - return ( -
    - - -
    -
    - {title} -
    -
    - {subtitle} -
    -
    -
    - -
    -
    -
    - ); -}; - -export default ConversationListItem; diff --git a/client/src/components/Chat/ChatBody/AllCoversations/index.tsx b/client/src/components/Chat/ChatBody/AllCoversations/index.tsx deleted file mode 100644 index e3b0a3cfb1..0000000000 --- a/client/src/components/Chat/ChatBody/AllCoversations/index.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import React, { - Dispatch, - SetStateAction, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import { format } from 'date-fns'; -import { useTranslation } from 'react-i18next'; -import { - deleteConversation, - getAllConversations, - getConversation, -} from '../../../../services/api'; -import { AllConversationsResponse } from '../../../../types/api'; -import Conversation from '../Conversation'; -import { - ChatMessage, - ChatMessageAuthor, - OpenChatHistoryItem, -} from '../../../../types/general'; -import { conversationsCache } from '../../../../services/cache'; -import { - mapLoadingSteps, - mapUserQuery, -} from '../../../../mappers/conversation'; -import { LocaleContext } from '../../../../context/localeContext'; -import { getDateFnsLocale } from '../../../../utils'; -import ConversationListItem from './ConversationListItem'; - -type Props = { - repoRef: string; - repoName: string; - openItem: OpenChatHistoryItem | null; - setOpenItem: Dispatch>; -}; - -const AllConversations = ({ - repoRef, - repoName, - openItem, - setOpenItem, -}: Props) => { - const { t } = useTranslation(); - const [conversations, setConversations] = useState( - [], - ); - const { locale } = useContext(LocaleContext); - - const fetchConversations = useCallback(() => { - getAllConversations(repoRef).then(setConversations); - }, [repoRef]); - - useEffect(() => { - fetchConversations(); - }, [fetchConversations]); - - const onDelete = useCallback((threadId: string) => { - deleteConversation(threadId).then(fetchConversations); - }, []); - - const onClick = useCallback((threadId: string) => { - getConversation(threadId).then((resp) => { - const conv: ChatMessage[] = []; - resp.forEach((m) => { - // @ts-ignore - const userQuery = m.search_steps.find((s) => s.type === 'QUERY'); - const parsedQuery = mapUserQuery(m); - conv.push({ - author: ChatMessageAuthor.User, - text: m.query.raw_query || userQuery?.content?.query || '', - parsedQuery, - isFromHistory: true, - }); - conv.push({ - author: ChatMessageAuthor.Server, - isLoading: false, - loadingSteps: mapLoadingSteps(m.search_steps, t), - text: m.answer, - conclusion: m.conclusion, - isFromHistory: true, - queryId: m.id, - responseTimestamp: m.response_timestamp, - explainedFile: m.focused_chunk?.file_path, - }); - }); - setOpenItem({ conversation: conv, threadId }); - conversationsCache[threadId] = conv; - }); - }, []); - - return ( -
    - {!openItem && ( -
    - {conversations.map((c) => ( - onClick(c.thread_id)} - onDelete={() => onDelete(c.thread_id)} - /> - ))} -
    - )} - {!!openItem && ( -
    - {}} - setInputValueImperatively={() => {}} - /> -
    - )} -
    - ); -}; - -export default AllConversations; diff --git a/client/src/components/Chat/ChatBody/Conversation.tsx b/client/src/components/Chat/ChatBody/Conversation.tsx deleted file mode 100644 index ebb7e1e349..0000000000 --- a/client/src/components/Chat/ChatBody/Conversation.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useContext } from 'react'; -import ScrollToBottom from 'react-scroll-to-bottom'; -import { - ChatMessage, - ChatMessageAuthor, - ChatMessageServer, -} from '../../../types/general'; -import { AppNavigationContext } from '../../../context/appNavigationContext'; -import Message from './ConversationMessage'; -import FirstMessage from './FirstMessage'; - -type Props = { - conversation: ChatMessage[]; - threadId: string; - repoRef: string; - repoName: string; - isLoading?: boolean; - isHistory?: boolean; - onMessageEdit: (queryId: string, i: number) => void; - setInputValueImperatively: (s: string) => void; -}; - -const Conversation = ({ - conversation, - threadId, - repoRef, - isLoading, - isHistory, - repoName, - onMessageEdit, - setInputValueImperatively, -}: Props) => { - const { navigatedItem } = useContext(AppNavigationContext); - - return ( - - {!isHistory && ( - - )} - {conversation.map((m, i) => ( - - ))} - - ); -}; - -export default Conversation; diff --git a/client/src/components/Chat/ChatBody/ConversationMessage/MessageFeedback.tsx b/client/src/components/Chat/ChatBody/ConversationMessage/MessageFeedback.tsx deleted file mode 100644 index fdc9e995da..0000000000 --- a/client/src/components/Chat/ChatBody/ConversationMessage/MessageFeedback.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion'; -import React, { useCallback, useContext, useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { Unlike } from '../../../../icons'; -import Button from '../../../Button'; -import { upvoteAnswer } from '../../../../services/api'; -import { DeviceContext } from '../../../../context/deviceContext'; -import UpvoteBtn from '../../FeedbackBtns/Upvote'; -import DownvoteBtn from '../../FeedbackBtns/Downvote'; - -type Props = { - showInlineFeedback: boolean; - isHistory?: boolean; - threadId: string; - queryId: string; - repoRef: string; - error: boolean; -}; - -const MessageFeedback = ({ - showInlineFeedback, - isHistory, - threadId, - queryId, - repoRef, - error, -}: Props) => { - const { t } = useTranslation(); - const [isUpvote, setIsUpvote] = useState(false); - const [isDownvote, setIsDownvote] = useState(false); - const [isSubmitted, setIsSubmitted] = useState(false); - const [showCommentInput, setShowCommentInput] = useState(false); - const [comment, setComment] = useState(''); - const { envConfig } = useContext(DeviceContext); - - const handleUpvote = useCallback( - (isUpvote: boolean) => { - setIsUpvote(isUpvote); - setIsDownvote(!isUpvote); - if (!isUpvote) { - setTimeout(() => { - setShowCommentInput(true); - }, 500); // to play animation - } - if (isUpvote) { - return upvoteAnswer(threadId, queryId, repoRef, { type: 'positive' }); - } - }, - [showInlineFeedback, envConfig.tracking_id, threadId, queryId, repoRef], - ); - - const handleSubmit = useCallback(() => { - setIsSubmitted(true); - return upvoteAnswer(threadId, queryId, repoRef, { - type: 'negative', - feedback: comment, - }); - }, [comment, isUpvote, threadId, queryId, repoRef]); - - return ( - <> - {showInlineFeedback && - !isHistory && - !error && - !isSubmitted && - !showCommentInput && ( -
    - {!isUpvote && !isDownvote && ( -

    - How would you rate this response? -

    - )} -
    - - -
    -
    - )} - - {showInlineFeedback && showCommentInput && !isSubmitted && ( - -
    -
    -
    -
    - -
    - - Bad response - -
    -