Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.

Commit e80fcbe

Browse files
matejvasekclaude
andcommitted
feat: add refresh button with loading state
Add a manual refresh button to the functions list that re-fetches repos from GitHub on click. The button uses the PF Button isLoading prop for a native spinner and is disabled while a fetch is in flight. Place create and refresh buttons in a PF Toolbar above the table, separated by a vertical divider. Fix a caching bug in GithubService where deleted repos persisted after refresh. Replace the stale merge logic with a pendingRepos buffer that only keeps locally created repos until GitHub's search index catches up. Signed-off-by: Matej Vašek <matejvasek@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9a8b3e5 commit e80fcbe

5 files changed

Lines changed: 251 additions & 35 deletions

File tree

docs/claude-progress.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ Completed:
3737
Left off: Three follow-up items for next day.
3838
Blockers: oc login not accessible from agent sandbox (permission denied on ~/.kube/config)
3939

40+
---
41+
## 2026-05-20 | Session: Refresh button and list page toolbar (PR #26)
42+
Worked on: Add refresh button to functions list, address review feedback
43+
- Added manual refresh button (SyncAltIcon) that re-fetches function repos from GitHub on click
44+
- Refresh uses PF Button isLoading prop for native spinner (no custom CSS animation)
45+
- Refresh button disabled while fetch is in flight (prevents queued refreshes)
46+
- Fixed caching bug in GithubService where deleted repos persisted after refresh (replaced stale merge logic with pendingRepos buffer)
47+
- Moved create and refresh buttons into a PF Toolbar above the table with ToolbarItem variant="separator"
48+
- 13 suites, 117 tests, all passing, zero lint errors
49+
Left off: PR #26 ready for re-review. Visual verification pending (could not authenticate to GitHub from sandbox).
50+
Blockers: None
51+
4052
---
4153
## 2026-05-18 | Session: Error server and JSON error responses
4254
Worked on: Errserver for failed Go compilation, JSON error responses for backend

src/common/services/source-control/GithubService.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,36 @@ describe('GithubService', () => {
116116
expect(repos).toEqual([expectedRepo1, expectedRepo2]);
117117
});
118118

119+
it('removes a deleted repo from the list on next fetch', async () => {
120+
// GIVEN: initial search returns two repos
121+
const repo1 = {
122+
owner: 'twoGiants',
123+
name: 'my-func',
124+
url: 'https://github.com/twoGiants/my-func',
125+
defaultBranch: 'main',
126+
};
127+
const repo2 = {
128+
owner: 'twoGiants',
129+
name: 'my-func-2',
130+
url: 'https://github.com/twoGiants/my-func-2',
131+
defaultBranch: 'main',
132+
};
133+
setupGithubSearchReposResponse({ secondItem: repo2 });
134+
135+
const svc = new GithubService(() => 'pat');
136+
let repos = await svc.listFunctionRepos();
137+
expect(repos).toEqual([repo1, repo2]);
138+
139+
// GIVEN: repo2 is deleted, search now returns only repo1
140+
setupGithubSearchReposResponse();
141+
142+
// WHEN
143+
repos = await svc.listFunctionRepos();
144+
145+
// THEN: deleted repo is gone
146+
expect(repos).toEqual([repo1]);
147+
});
148+
119149
function setupGithubSearchReposResponse({
120150
secondItem,
121151
}: {

src/common/services/source-control/GithubService.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export class GithubService implements SourceControlService {
77
#cachedOctokit: Octokit | null = null;
88
#cachedToken: string = '';
99
#lastCommitSha = new Map<string, string>();
10-
#cachedFunctionRepos: RepoMetadata[] = [];
10+
#pendingRepos: RepoMetadata[] = [];
1111

1212
constructor(getToken: () => string) {
1313
this.#getToken = getToken;
@@ -18,7 +18,7 @@ export class GithubService implements SourceControlService {
1818
if (token !== this.#cachedToken) {
1919
this.#cachedToken = token;
2020
this.#cachedOctokit = new Octokit({ auth: token });
21-
this.#cachedFunctionRepos = [];
21+
this.#pendingRepos = [];
2222
this.#lastCommitSha.clear();
2323
}
2424
return this.#cachedOctokit!;
@@ -44,9 +44,8 @@ export class GithubService implements SourceControlService {
4444
defaultBranch: item.default_branch,
4545
}));
4646
const fetchedNames = new Set(fetchedFunctionRepos.map((r) => r.name));
47-
const unfetched = this.#cachedFunctionRepos.filter((r) => !fetchedNames.has(r.name));
48-
this.#cachedFunctionRepos = [...fetchedFunctionRepos, ...unfetched];
49-
return this.#cachedFunctionRepos;
47+
this.#pendingRepos = this.#pendingRepos.filter((r) => !fetchedNames.has(r.name));
48+
return [...fetchedFunctionRepos, ...this.#pendingRepos];
5049
}
5150

5251
async createRepoWithSecret(
@@ -132,7 +131,7 @@ export class GithubService implements SourceControlService {
132131
sha: commit.sha,
133132
});
134133

135-
this.#cachedFunctionRepos.push({
134+
this.#pendingRepos.push({
136135
owner,
137136
name: repoName,
138137
url: `https://github.com/${owner}/${repoName}`,

src/pages/function-list/FunctionsListPage.test.tsx

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
23
import { MemoryRouter } from 'react-router-dom-v5-compat';
34
import FunctionsListPage from './FunctionsListPage';
45
import { PAT_KEY } from '../../common/services/types';
@@ -435,4 +436,126 @@ describe('FunctionsListPage', () => {
435436

436437
expect(mockUseClusterService).toHaveBeenLastCalledWith(['fn-a']);
437438
});
439+
440+
it('re-fetches repos when refresh button is clicked', async () => {
441+
renderAuthenticated();
442+
const mockListRepos = vi.fn().mockResolvedValue([repoFixture('fn-a')]);
443+
mockUseSourceControl.mockReturnValue({
444+
listFunctionRepos: mockListRepos,
445+
fetchFileContent: vi.fn().mockResolvedValue('name: fn-a\nruntime: go\nnamespace: demo\n'),
446+
});
447+
mockUseClusterService.mockReturnValue(clusterData());
448+
449+
render(
450+
<MemoryRouter>
451+
<FunctionsListPage />
452+
</MemoryRouter>,
453+
);
454+
455+
await screen.findByTestId('fn-name');
456+
expect(mockListRepos).toHaveBeenCalledTimes(1);
457+
458+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
459+
460+
await waitFor(() => {
461+
expect(mockListRepos).toHaveBeenCalledTimes(2);
462+
});
463+
});
464+
465+
it('does not show spinner on refresh button during initial page load', async () => {
466+
renderAuthenticated();
467+
mockUseSourceControl.mockReturnValue({
468+
listFunctionRepos: vi.fn().mockResolvedValue([repoFixture('fn-a')]),
469+
fetchFileContent: vi.fn().mockResolvedValue('name: fn-a\nruntime: go\nnamespace: demo\n'),
470+
});
471+
mockUseClusterService.mockReturnValue(clusterData());
472+
473+
render(
474+
<MemoryRouter>
475+
<FunctionsListPage />
476+
</MemoryRouter>,
477+
);
478+
479+
await screen.findByTestId('fn-name');
480+
481+
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
482+
expect(refreshBtn.querySelector('[role="progressbar"]')).not.toBeInTheDocument();
483+
});
484+
485+
it('shows spinner on refresh button only while a button-triggered refresh is in flight', async () => {
486+
renderAuthenticated();
487+
let resolveRepos: (value: unknown[]) => void;
488+
const mockListRepos = vi.fn().mockImplementation(
489+
() =>
490+
new Promise((resolve) => {
491+
resolveRepos = resolve;
492+
}),
493+
);
494+
mockUseSourceControl.mockReturnValue({
495+
listFunctionRepos: mockListRepos,
496+
fetchFileContent: vi.fn().mockResolvedValue('name: fn-a\nruntime: go\nnamespace: demo\n'),
497+
});
498+
mockUseClusterService.mockReturnValue(clusterData());
499+
500+
render(
501+
<MemoryRouter>
502+
<FunctionsListPage />
503+
</MemoryRouter>,
504+
);
505+
506+
// Complete initial load
507+
resolveRepos!([repoFixture('fn-a')]);
508+
await screen.findByTestId('fn-name');
509+
510+
const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
511+
512+
// Click refresh -- should show spinner
513+
await userEvent.click(refreshBtn);
514+
expect(refreshBtn.querySelector('[role="progressbar"]')).toBeInTheDocument();
515+
516+
// Resolve the refresh fetch -- spinner should disappear
517+
resolveRepos!([repoFixture('fn-a')]);
518+
await waitFor(() => {
519+
expect(refreshBtn.querySelector('[role="progressbar"]')).not.toBeInTheDocument();
520+
});
521+
});
522+
523+
it('removes a deleted repo from the list after refresh', async () => {
524+
renderAuthenticated();
525+
const mockListRepos = vi
526+
.fn()
527+
.mockResolvedValueOnce([repoFixture('fn-a'), repoFixture('fn-b')])
528+
.mockResolvedValueOnce([repoFixture('fn-a')]);
529+
const mockFetchFile = vi
530+
.fn()
531+
.mockImplementation(
532+
(repo: { name: string }) => `name: ${repo.name}\nruntime: go\nnamespace: demo\n`,
533+
);
534+
mockUseSourceControl.mockReturnValue({
535+
listFunctionRepos: mockListRepos,
536+
fetchFileContent: mockFetchFile,
537+
});
538+
mockUseClusterService.mockReturnValue(clusterData());
539+
540+
render(
541+
<MemoryRouter>
542+
<FunctionsListPage />
543+
</MemoryRouter>,
544+
);
545+
546+
// Initial load: both repos visible
547+
const names = await screen.findAllByTestId('fn-name');
548+
expect(names).toHaveLength(2);
549+
expect(names[0]).toHaveTextContent('fn-a');
550+
expect(names[1]).toHaveTextContent('fn-b');
551+
552+
// Click refresh (second call returns only fn-a)
553+
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
554+
555+
await waitFor(() => {
556+
const refreshedNames = screen.getAllByTestId('fn-name');
557+
expect(refreshedNames).toHaveLength(1);
558+
expect(refreshedNames[0]).toHaveTextContent('fn-a');
559+
});
560+
});
438561
});

0 commit comments

Comments
 (0)