feat(frontend): persist recent repos (#11171)

This commit is contained in:
Hiep Le 2025-09-30 22:15:36 +07:00 committed by GitHub
parent 98ce55e2fc
commit 2382baacc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 187 additions and 38 deletions

View File

@ -47,7 +47,6 @@ export function BranchDropdownMenu({
key={branch.name}
item={branch}
index={index}
isHighlighted={currentHighlightedIndex === index}
isSelected={currentSelectedItem?.name === branch.name}
getItemProps={currentGetItemProps}
getDisplayText={(branchItem) => branchItem.name}

View File

@ -134,7 +134,6 @@ export function GitProviderDropdown({
key={item}
item={item}
index={index}
isHighlighted={index === currentHighlightedIndex}
isSelected={item === currentSelectedItem}
getItemProps={currentGetItemProps}
getDisplayText={formatProviderName}

View File

@ -23,6 +23,8 @@ import { GenericDropdownMenu } from "../shared/generic-dropdown-menu";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import RepoIcon from "#/icons/repo.svg?react";
import { useHomeStore } from "#/stores/home-store";
import { Typography } from "#/ui/typography";
export interface GitRepoDropdownProps {
provider: Provider;
@ -45,6 +47,7 @@ export function GitRepoDropdown({
}: GitRepoDropdownProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
const { recentRepositories: storedRecentRepositories } = useHomeStore();
const [inputValue, setInputValue] = useState("");
const [localSelectedItem, setLocalSelectedItem] =
useState<GitRepository | null>(null);
@ -88,37 +91,78 @@ export function GitRepoDropdown({
repositoryName,
);
// Filter repositories based on input value
const filteredRepositories = useMemo(() => {
// If we have URL search results, show them directly (no filtering needed)
if (urlSearchResults.length > 0) {
return repositories;
}
// Get recent repositories filtered by provider and input keyword
const recentRepositories = useMemo(() => {
const allRecentRepos = storedRecentRepositories;
const providerFilteredRepos = allRecentRepos.filter(
(repo) => repo.git_provider === provider,
);
// If we have a selected repository and the input matches it exactly, show all repositories
if (selectedRepository && inputValue === selectedRepository.full_name) {
return repositories;
}
// If no input value, show all repositories
// If no input value, return all recent repos for this provider
if (!inputValue || !inputValue.trim()) {
return repositories;
return providerFilteredRepos;
}
// For URL inputs, use the processed search input for filtering
// Filter by input keyword
const filterText = inputValue.startsWith("https://")
? processedSearchInput
: inputValue;
return repositories.filter((repo) =>
return providerFilteredRepos.filter((repo) =>
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
);
}, [storedRecentRepositories, provider, inputValue, processedSearchInput]);
// Helper function to prioritize recent repositories at the top
const prioritizeRecentRepositories = useCallback(
(repoList: GitRepository[]) => {
const recentRepoIds = new Set(recentRepositories.map((repo) => repo.id));
const recentRepos = repoList.filter((repo) => recentRepoIds.has(repo.id));
const otherRepos = repoList.filter((repo) => !recentRepoIds.has(repo.id));
return [...recentRepos, ...otherRepos];
},
[recentRepositories],
);
// Filter repositories based on input value
const filteredRepositories = useMemo(() => {
let baseRepositories: GitRepository[];
// If we have URL search results, show them directly (no filtering needed)
if (urlSearchResults.length > 0) {
baseRepositories = repositories;
}
// If we have a selected repository and the input matches it exactly, show all repositories
else if (
selectedRepository &&
inputValue === selectedRepository.full_name
) {
baseRepositories = repositories;
}
// If no input value, show all repositories
else if (!inputValue || !inputValue.trim()) {
baseRepositories = repositories;
}
// For URL inputs, use the processed search input for filtering
else {
const filterText = inputValue.startsWith("https://")
? processedSearchInput
: inputValue;
baseRepositories = repositories.filter((repo) =>
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
);
}
// Prioritize recent repositories at the top
return prioritizeRecentRepositories(baseRepositories);
}, [
repositories,
inputValue,
selectedRepository,
urlSearchResults,
processedSearchInput,
prioritizeRecentRepositories,
]);
// Handle selection
@ -240,7 +284,6 @@ export function GitRepoDropdown({
key={item.id}
item={item}
index={index}
isHighlighted={itemHighlightedIndex === index}
isSelected={itemSelectedItem?.id === item.id}
getItemProps={itemGetItemProps}
getDisplayText={(repo) => repo.full_name}
@ -257,6 +300,21 @@ export function GitRepoDropdown({
/>
);
// Create sticky top item for recent repositories
const stickyTopItem = useMemo(() => {
if (recentRepositories.length === 0) {
return null;
}
return (
<div>
<Typography.Text className="text-xs text-[#FAFAFA] font-semibold leading-4 pl-2">
{t(I18nKey.COMMON$MOST_RECENT)}
</Typography.Text>
</div>
);
}, [recentRepositories, localSelectedItem, getItemProps, t]);
return (
<div className={cn("relative", className)}>
<div className="relative">
@ -309,8 +367,10 @@ export function GitRepoDropdown({
menuRef={menuRef}
renderItem={renderItem}
renderEmptyState={renderEmptyState}
stickyTopItem={stickyTopItem}
stickyFooterItem={stickyFooterItem}
testId="git-repo-dropdown-menu"
numberOfRecentItems={recentRepositories.length}
/>
<ErrorMessage isError={isError} />

View File

@ -13,6 +13,7 @@ import RepoForkedIcon from "#/icons/repo-forked.svg?react";
import { GitProviderDropdown } from "./git-provider-dropdown";
import { GitBranchDropdown } from "./git-branch-dropdown";
import { GitRepoDropdown } from "./git-repo-dropdown";
import { useHomeStore } from "#/stores/home-store";
interface RepositorySelectionFormProps {
onRepoSelection: (repo: GitRepository | null) => void;
@ -34,6 +35,7 @@ export function RepositorySelectionForm({
React.useState<Provider | null>(null);
const { providers } = useUserProviders();
const { addRecentRepository } = useHomeStore();
const {
mutate: createConversation,
isPending,
@ -168,7 +170,12 @@ export function RepositorySelectionForm({
(providers.length > 1 && !selectedProvider) ||
isLoadingSettings
}
onClick={() =>
onClick={() => {
// Persist the repository to recent repositories when launching
if (selectedRepository) {
addRecentRepository(selectedRepository);
}
createConversation(
{
repository: {
@ -181,8 +188,8 @@ export function RepositorySelectionForm({
onSuccess: (data) =>
navigate(`/conversations/${data.conversation_id}`),
},
)
}
);
}}
className="w-full font-semibold"
>
{!isCreatingConversation && "Launch"}

View File

@ -4,7 +4,6 @@ import { cn } from "#/utils/utils";
interface DropdownItemProps<T> {
item: T;
index: number;
isHighlighted: boolean;
isSelected: boolean;
getItemProps: <Options>(options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
getDisplayText: (item: T) => string;
@ -17,7 +16,6 @@ interface DropdownItemProps<T> {
export function DropdownItem<T>({
item,
index,
isHighlighted,
isSelected,
getItemProps,
getDisplayText,
@ -35,7 +33,6 @@ export function DropdownItem<T>({
: "px-2 py-2 cursor-pointer text-sm rounded-md mx-0 my-0.5",
"text-white focus:outline-none font-normal",
{
"bg-[#5C5D62]": isHighlighted && !isSelected,
"bg-[#C9B974] text-black": isSelected,
"hover:bg-[#5C5D62]": !isSelected,
"hover:bg-[#C9B974] hover:text-black": isSelected,

View File

@ -29,8 +29,10 @@ export interface GenericDropdownMenuProps<T> {
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
) => React.ReactNode;
renderEmptyState: (inputValue: string) => React.ReactNode;
stickyTopItem?: React.ReactNode;
stickyFooterItem?: React.ReactNode;
testId?: string;
numberOfRecentItems?: number;
}
export function GenericDropdownMenu<T>({
@ -45,13 +47,15 @@ export function GenericDropdownMenu<T>({
menuRef,
renderItem,
renderEmptyState,
stickyTopItem,
stickyFooterItem,
testId,
numberOfRecentItems = 0,
}: GenericDropdownMenuProps<T>) {
if (!isOpen) return null;
const hasItems = filteredItems.length > 0;
const showEmptyState = !hasItems && !stickyFooterItem;
const showEmptyState = !hasItems && !stickyTopItem && !stickyFooterItem;
return (
<div className="relative">
@ -59,7 +63,7 @@ export function GenericDropdownMenu<T>({
className={cn(
"absolute z-10 w-full bg-[#454545] border border-[#727987] rounded-lg shadow-none",
"focus:outline-none mt-1 z-[9999]",
stickyFooterItem ? "max-h-60" : "max-h-60",
stickyTopItem || stickyFooterItem ? "max-h-60" : "max-h-60",
)}
>
<ul
@ -68,23 +72,36 @@ export function GenericDropdownMenu<T>({
ref: menuRef,
className: cn(
"w-full overflow-auto p-1",
stickyFooterItem ? "max-h-[calc(15rem-3rem)]" : "max-h-60", // Reserve space for sticky footer
stickyTopItem || stickyFooterItem
? "max-h-[calc(15rem-3rem)]"
: "max-h-60", // Reserve space for sticky items
),
onScroll,
"data-testid": testId,
})}
>
{showEmptyState
? renderEmptyState(inputValue)
: filteredItems.map((item, index) =>
renderItem(
item,
index,
highlightedIndex,
selectedItem,
getItemProps,
),
)}
{showEmptyState ? (
renderEmptyState(inputValue)
) : (
<>
{stickyTopItem}
{filteredItems.map((item, index) => (
<>
{renderItem(
item,
index,
highlightedIndex,
selectedItem,
getItemProps,
)}
{numberOfRecentItems > 0 &&
index === numberOfRecentItems - 1 && (
<div className="border-b border-[#727987] bg-[#454545] pb-1 mb-1 h-[1px]" />
)}
</>
))}
</>
)}
</ul>
{stickyFooterItem && (
<div className="border-t border-[#727987] bg-[#454545] p-1 rounded-b-lg">

View File

@ -916,5 +916,6 @@ export enum I18nKey {
COMMON$START_RUNTIME = "COMMON$START_RUNTIME",
COMMON$JUPYTER_EMPTY_MESSAGE = "COMMON$JUPYTER_EMPTY_MESSAGE",
COMMON$CONFIRMATION_MODE_ENABLED = "COMMON$CONFIRMATION_MODE_ENABLED",
COMMON$MOST_RECENT = "COMMON$MOST_RECENT",
HOME$NO_REPOSITORY_FOUND = "HOME$NO_REPOSITORY_FOUND",
}

View File

@ -14655,6 +14655,22 @@
"de": "Bestätigungsmodus aktiviert",
"uk": "Режим підтвердження увімкнено"
},
"COMMON$MOST_RECENT": {
"en": "Most Recent",
"ja": "最新",
"zh-CN": "最新",
"zh-TW": "最新",
"ko-KR": "최신",
"no": "Nyeste",
"it": "Più recente",
"pt": "Mais recente",
"es": "Más reciente",
"ar": "الأحدث",
"fr": "Le plus récent",
"tr": "En Son",
"de": "Neueste",
"uk": "Найновіше"
},
"HOME$NO_REPOSITORY_FOUND": {
"en": "No repository found to launch conversation",
"ja": "会話を開始するためのリポジトリが見つかりません",

View File

@ -0,0 +1,53 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { GitRepository } from "#/types/git";
interface HomeState {
recentRepositories: GitRepository[];
}
interface HomeActions {
addRecentRepository: (repository: GitRepository) => void;
clearRecentRepositories: () => void;
getRecentRepositories: () => GitRepository[];
}
type HomeStore = HomeState & HomeActions;
const initialState: HomeState = {
recentRepositories: [],
};
export const useHomeStore = create<HomeStore>()(
persist(
(set, get) => ({
...initialState,
addRecentRepository: (repository: GitRepository) =>
set((state) => {
// Remove the repository if it already exists to avoid duplicates
const filteredRepos = state.recentRepositories.filter(
(repo) => repo.id !== repository.id,
);
// Add the new repository to the beginning and keep only top 3
const updatedRepos = [repository, ...filteredRepos].slice(0, 3);
return {
recentRepositories: updatedRepos,
};
}),
clearRecentRepositories: () =>
set(() => ({
recentRepositories: [],
})),
getRecentRepositories: () => get().recentRepositories,
}),
{
name: "home-store", // unique name for localStorage
storage: createJSONStorage(() => localStorage),
},
),
);