Skip to content
Open
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
12 changes: 12 additions & 0 deletions docs/claude-progress.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
# Claude Progress Log
# Newest entries first. Agents: append your entry at the top after the header.

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

---
## 2026-05-18 | Session: Error server and JSON error responses
Worked on: Errserver for failed Go compilation, JSON error responses for backend
Expand Down
30 changes: 30 additions & 0 deletions src/services/source-control/GithubService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,36 @@ describe('GithubService', () => {
expect(repos).toEqual([expectedRepo1, expectedRepo2]);
});

it('removes a deleted repo from the list on next fetch', async () => {
// GIVEN: initial search returns two repos
const repo1 = {
owner: 'twoGiants',
name: 'my-func',
url: 'https://github.com/twoGiants/my-func',
defaultBranch: 'main',
};
const repo2 = {
owner: 'twoGiants',
name: 'my-func-2',
url: 'https://github.com/twoGiants/my-func-2',
defaultBranch: 'main',
};
setupGithubSearchReposResponse({ secondItem: repo2 });

const svc = new GithubService(() => 'pat');
let repos = await svc.listFunctionRepos();
expect(repos).toEqual([repo1, repo2]);

// GIVEN: repo2 is deleted, search now returns only repo1
setupGithubSearchReposResponse();

// WHEN
repos = await svc.listFunctionRepos();

// THEN: deleted repo is gone
expect(repos).toEqual([repo1]);
});

function setupGithubSearchReposResponse({
secondItem,
}: {
Expand Down
11 changes: 5 additions & 6 deletions src/services/source-control/GithubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class GithubService implements SourceControlService {
#cachedOctokit: Octokit | null = null;
#cachedToken: string = '';
#lastCommitSha = new Map<string, string>();
#cachedFunctionRepos: RepoMetadata[] = [];
#pendingRepos: RepoMetadata[] = [];

constructor(getToken: () => string) {
this.#getToken = getToken;
Expand All @@ -18,7 +18,7 @@ export class GithubService implements SourceControlService {
if (token !== this.#cachedToken) {
this.#cachedToken = token;
this.#cachedOctokit = new Octokit({ auth: token });
this.#cachedFunctionRepos = [];
this.#pendingRepos = [];
this.#lastCommitSha.clear();
}
return this.#cachedOctokit!;
Expand All @@ -44,9 +44,8 @@ export class GithubService implements SourceControlService {
defaultBranch: item.default_branch,
}));
const fetchedNames = new Set(fetchedFunctionRepos.map((r) => r.name));
const unfetched = this.#cachedFunctionRepos.filter((r) => !fetchedNames.has(r.name));
this.#cachedFunctionRepos = [...fetchedFunctionRepos, ...unfetched];
return this.#cachedFunctionRepos;
this.#pendingRepos = this.#pendingRepos.filter((r) => !fetchedNames.has(r.name));
return [...fetchedFunctionRepos, ...this.#pendingRepos];
}

async createRepoWithSecret(
Expand Down Expand Up @@ -132,7 +131,7 @@ export class GithubService implements SourceControlService {
sha: commit.sha,
});

this.#cachedFunctionRepos.push({
this.#pendingRepos.push({
owner,
name: repoName,
url: `https://github.com/${owner}/${repoName}`,
Expand Down
125 changes: 124 additions & 1 deletion src/views/FunctionsListPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom-v5-compat';
import FunctionsListPage from './FunctionsListPage';
import { PAT_KEY } from '../services/types';
Expand Down Expand Up @@ -435,4 +436,126 @@ describe('FunctionsListPage', () => {

expect(mockUseClusterService).toHaveBeenLastCalledWith(['fn-a']);
});

it('re-fetches repos when refresh button is clicked', async () => {
renderAuthenticated();
const mockListRepos = vi.fn().mockResolvedValue([repoFixture('fn-a')]);
mockUseSourceControl.mockReturnValue({
listFunctionRepos: mockListRepos,
fetchFileContent: vi.fn().mockResolvedValue('name: fn-a\nruntime: go\nnamespace: demo\n'),
});
mockUseClusterService.mockReturnValue(clusterData());

render(
<MemoryRouter>
<FunctionsListPage />
</MemoryRouter>,
);

await screen.findByTestId('fn-name');
expect(mockListRepos).toHaveBeenCalledTimes(1);

await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));

await waitFor(() => {
expect(mockListRepos).toHaveBeenCalledTimes(2);
});
});

it('does not show spinner on refresh button during initial page load', async () => {
renderAuthenticated();
mockUseSourceControl.mockReturnValue({
listFunctionRepos: vi.fn().mockResolvedValue([repoFixture('fn-a')]),
fetchFileContent: vi.fn().mockResolvedValue('name: fn-a\nruntime: go\nnamespace: demo\n'),
});
mockUseClusterService.mockReturnValue(clusterData());

render(
<MemoryRouter>
<FunctionsListPage />
</MemoryRouter>,
);

await screen.findByTestId('fn-name');

const refreshBtn = screen.getByRole('button', { name: 'Refresh' });
expect(refreshBtn.querySelector('[role="progressbar"]')).not.toBeInTheDocument();
});

it('shows spinner on refresh button only while a button-triggered refresh is in flight', async () => {
renderAuthenticated();
let resolveRepos: (value: unknown[]) => void;
const mockListRepos = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveRepos = resolve;
}),
);
mockUseSourceControl.mockReturnValue({
listFunctionRepos: mockListRepos,
fetchFileContent: vi.fn().mockResolvedValue('name: fn-a\nruntime: go\nnamespace: demo\n'),
});
mockUseClusterService.mockReturnValue(clusterData());

render(
<MemoryRouter>
<FunctionsListPage />
</MemoryRouter>,
);

// Complete initial load
resolveRepos!([repoFixture('fn-a')]);
await screen.findByTestId('fn-name');

const refreshBtn = screen.getByRole('button', { name: 'Refresh' });

// Click refresh -- should show spinner
await userEvent.click(refreshBtn);
expect(refreshBtn.querySelector('[role="progressbar"]')).toBeInTheDocument();

// Resolve the refresh fetch -- spinner should disappear
resolveRepos!([repoFixture('fn-a')]);
await waitFor(() => {
expect(refreshBtn.querySelector('[role="progressbar"]')).not.toBeInTheDocument();
});
});

it('removes a deleted repo from the list after refresh', async () => {
renderAuthenticated();
const mockListRepos = vi
.fn()
.mockResolvedValueOnce([repoFixture('fn-a'), repoFixture('fn-b')])
.mockResolvedValueOnce([repoFixture('fn-a')]);
const mockFetchFile = vi
.fn()
.mockImplementation(
(repo: { name: string }) => `name: ${repo.name}\nruntime: go\nnamespace: demo\n`,
);
mockUseSourceControl.mockReturnValue({
listFunctionRepos: mockListRepos,
fetchFileContent: mockFetchFile,
});
mockUseClusterService.mockReturnValue(clusterData());

render(
<MemoryRouter>
<FunctionsListPage />
</MemoryRouter>,
);

// Initial load: both repos visible
const names = await screen.findAllByTestId('fn-name');
expect(names).toHaveLength(2);
expect(names[0]).toHaveTextContent('fn-a');
expect(names[1]).toHaveTextContent('fn-b');

// Click refresh (second call returns only fn-a)
await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));

await waitFor(() => {
const refreshedNames = screen.getAllByTestId('fn-name');
expect(refreshedNames).toHaveLength(1);
expect(refreshedNames[0]).toHaveTextContent('fn-a');
});
});
});
Loading