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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions frontend/src/components/repo/RepoList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createUseSettingsMock } from '@/test/test-utils'
import { RepoList } from './RepoList'

const mocks = vi.hoisted(() => ({ listRepos: vi.fn() }))

vi.mock('@/api/repos', () => ({
listRepos: mocks.listRepos,
deleteRepo: vi.fn(),
updateRepoOrder: vi.fn(),
}))

vi.mock('@/hooks/useSettings', () => ({
useSettings: () => createUseSettingsMock(),
}))

vi.mock('@/hooks/useMobile', () => ({ useMobile: () => true }))

function renderRepoList(onAddRepo: () => void) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })

return render(
<QueryClientProvider client={queryClient}>
<RepoList onAddRepo={onAddRepo} />
</QueryClientProvider>,
)
}

describe('RepoList', () => {
it('calls onAddRepo once from the empty state', async () => {
mocks.listRepos.mockResolvedValue([])
const onAddRepo = vi.fn()
const user = userEvent.setup()

renderRepoList(onAddRepo)

await user.click(await screen.findByRole('button', { name: 'Add repository' }))

expect(onAddRepo).toHaveBeenCalledTimes(1)
})
})
10 changes: 8 additions & 2 deletions frontend/src/components/repo/RepoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { CSS } from "@dnd-kit/utilities"
import { listRepos, deleteRepo, updateRepoOrder, type DeleteBranchChoice } from "@/api/repos"
import { fetchReposGitStatus } from "@/api/git"
import { DeleteDialog } from "@/components/ui/delete-dialog"
import { Button } from "@/components/ui/button"
import { RadioOptionGroup, type RadioOption } from "@/components/ui/radio-option-group"
import { GitBranch, Search, GripVertical } from "lucide-react"
import { GitBranch, Search, GripVertical, Plus } from "lucide-react"
import type { Repo } from "@/api/types"
import type { GitStatusResponse } from "@/types/git"
import { RepoCard } from "./RepoCard"
Expand Down Expand Up @@ -135,7 +136,7 @@ function StaticRepoCard({
)
}

export function RepoList() {
export function RepoList({ onAddRepo }: { onAddRepo: () => void }) {
const queryClient = useQueryClient()
const isMobile = useMobile()
const { preferences, updateSettings } = useSettings()
Expand Down Expand Up @@ -341,6 +342,10 @@ export function RepoList() {
<p className="text-muted-foreground">
No repositories yet. Add one to get started.
</p>
<Button size="sm" className="mt-4" onClick={onAddRepo}>
<Plus className="w-4 h-4 mr-1" />
Add repository
</Button>
</div>
)

Expand Down Expand Up @@ -413,6 +418,7 @@ export function RepoList() {
hasClonedRepos={hasClonedRepos}
selectionMode={selectionMode}
onSelectionModeChange={handleSelectionModeChange}
onAddRepo={onAddRepo}
/>

<div className="mx-2 md:mx-0 flex-1 min-h-0">
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/components/repo/RepoListControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { RepoListControls } from './RepoListControls'

const mocks = vi.hoisted(() => ({ mobile: { current: false } }))

vi.mock('@/hooks/useMobile', () => ({
isMobileViewport: () => mocks.mobile.current,
useMobile: () => mocks.mobile.current,
}))

function baseProps() {
return {
searchQuery: '',
onSearchChange: vi.fn(),
filterMode: 'recent' as const,
onFilterModeChange: vi.fn(),
sortMode: 'recent' as const,
onSortModeChange: vi.fn(),
filteredCount: 3,
attentionCount: 0,
selectedCount: 0,
allVisibleSelected: false,
onSelectAll: vi.fn(),
onClearSelection: vi.fn(),
onDelete: vi.fn(),
hasLocalRepos: false,
hasClonedRepos: false,
selectionMode: false,
onSelectionModeChange: vi.fn(),
onAddRepo: vi.fn(),
}
}

describe('RepoListControls', () => {
beforeEach(() => {
mocks.mobile.current = false
})

it('renders the add repository button after the search input on mobile', () => {
mocks.mobile.current = true

render(<RepoListControls {...baseProps()} />)

const search = screen.getByRole('textbox')
const addButton = screen.getByRole('button', { name: 'Add repository' })

expect(
search.compareDocumentPosition(addButton) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})

it('calls onAddRepo once when the mobile button is clicked', async () => {
mocks.mobile.current = true
const props = baseProps()
const user = userEvent.setup()

render(<RepoListControls {...props} />)
await user.click(screen.getByRole('button', { name: 'Add repository' }))

expect(props.onAddRepo).toHaveBeenCalledTimes(1)
})

it('does not render the add repository button on desktop', () => {
render(<RepoListControls {...baseProps()} />)

expect(screen.queryByRole('button', { name: 'Add repository' })).not.toBeInTheDocument()
})
})
25 changes: 23 additions & 2 deletions frontend/src/components/repo/RepoListControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { SlidersHorizontal, Trash2, X } from "lucide-react";
import { Plus, SlidersHorizontal, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RepoSearchInput } from "./RepoSearchInput";
import {
Expand Down Expand Up @@ -31,6 +31,7 @@ interface RepoListControlsProps {
hasClonedRepos: boolean;
selectionMode: boolean;
onSelectionModeChange: (enabled: boolean) => void;
onAddRepo: () => void;
}

const FILTER_OPTIONS: { value: RepoFilterMode; label: string }[] = [
Expand Down Expand Up @@ -65,6 +66,7 @@ export function RepoListControls({
hasClonedRepos,
selectionMode,
onSelectionModeChange,
onAddRepo,
}: RepoListControlsProps) {
const isMobile = useMobile();
const [showMenu, setShowMenu] = useState(false);
Expand Down Expand Up @@ -122,10 +124,29 @@ export function RepoListControls({
<div className="flex items-center gap-2">
<RepoSearchInput value={searchQuery} onValueChange={onSearchChange} />

{isMobile && (
<Button
variant="outline"
size="icon"
aria-label="Add repository"
title="Add repository"
className="h-9 w-9 shrink-0"
onClick={onAddRepo}
>
<Plus className="h-4 w-4" />
</Button>
)}

{isMobile ? (
<DropdownMenu open={showMenu} onOpenChange={setShowMenu}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Button
variant="outline"
size="icon"
aria-label="Filter and sort repositories"
title="Filter and sort repositories"
className="h-9 w-9 shrink-0"
>
<SlidersHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/pages/Repos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ export function Repos() {
All Schedules
</Button>
{isLocalServer ? (
<Button onClick={() => setAddRepoOpen(true)} size="sm">
<Button
onClick={() => setAddRepoOpen(true)}
size="sm"
className="hidden md:inline-flex"
>
<Plus className="w-4 h-4 mr-1" />
Repo
</Button>
Expand All @@ -73,7 +77,7 @@ export function Repos() {
</Header.Actions>
</Header>
<div className="container mx-auto flex-1 pt-2 px-2 min-h-0 overflow-auto pb-[calc(env(safe-area-inset-bottom)+60px)] sm:pb-0">
{isLocalServer ? <RepoList /> : <RemoteProjectList serverId={selectedServerId} />}
{isLocalServer ? <RepoList onAddRepo={() => setAddRepoOpen(true)} /> : <RemoteProjectList serverId={selectedServerId} />}
</div>
{isLocalServer ? (
<AddRepoDialog open={addRepoOpen} onOpenChange={setAddRepoOpen} />
Expand Down