mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(frontend): persist recent repos (#11171)
This commit is contained in:
parent
98ce55e2fc
commit
2382baacc2
@ -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}
|
||||
|
||||
@ -134,7 +134,6 @@ export function GitProviderDropdown({
|
||||
key={item}
|
||||
item={item}
|
||||
index={index}
|
||||
isHighlighted={index === currentHighlightedIndex}
|
||||
isSelected={item === currentSelectedItem}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={formatProviderName}
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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": "会話を開始するためのリポジトリが見つかりません",
|
||||
|
||||
53
frontend/src/stores/home-store.ts
Normal file
53
frontend/src/stores/home-store.ts
Normal 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),
|
||||
},
|
||||
),
|
||||
);
|
||||
Loading…
x
Reference in New Issue
Block a user