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
3 changes: 3 additions & 0 deletions client/src/components/apps/tabs/GitTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export default function GitTab({ appId, appName, repoPath }) {
const [cleanupConfirm, setCleanupConfirm] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetConfirm, setResetConfirm] = useState(false);
const [sourceRefreshKey, setSourceRefreshKey] = useState(0);

const loadGitData = useCallback(async (opts = {}) => {
if (!repoPath) return;
Expand Down Expand Up @@ -253,6 +254,7 @@ export default function GitTab({ appId, appName, repoPath }) {
toast.success(`Branches updated — ${parts.join(', ')}`);
}
await loadGitData({ includeRemote: true });
setSourceRefreshKey((key) => key + 1);
};

const handleReleasePR = async () => {
Expand Down Expand Up @@ -497,6 +499,7 @@ export default function GitTab({ appId, appName, repoPath }) {
<RepositorySourcePanel
appId={appId}
appName={appName}
refreshKey={sourceRefreshKey}
onUpdated={() => loadGitData({ includeRemote: true })}
/>

Expand Down
16 changes: 15 additions & 1 deletion client/src/components/apps/tabs/GitTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ vi.mock('../../../services/api', () => ({
getBranches: vi.fn(),
getBranchComparison: vi.fn(),
getRemoteBranches: vi.fn(),
updateBranches: vi.fn(),
getGitDiff: vi.fn(),
cleanupMergedBranches: vi.fn(),
resetToDefaultBranch: vi.fn(),
}));
vi.mock('./RepositorySourcePanel', () => ({
default: ({ appId }) => <div data-testid="repository-source-panel">{appId}</div>,
default: ({ appId, refreshKey }) => (
<div data-testid="repository-source-panel" data-refresh-key={refreshKey}>{appId}</div>
),
}));

import * as api from '../../../services/api';
Expand All @@ -38,6 +41,7 @@ beforeEach(() => {
api.getBranches.mockResolvedValue({ branches: [] });
api.getBranchComparison.mockResolvedValue(COMPARISON);
api.getRemoteBranches.mockResolvedValue({ branches: [], defaultBranch: 'main' });
api.updateBranches.mockResolvedValue({ currentBranch: 'main', main: 'up to date' });
api.getGitDiff.mockResolvedValue({ diff: '@@ -1 +1 @@\n-old\n+new' });
api.cleanupMergedBranches.mockResolvedValue({ deleted: [], skipped: [] });
api.resetToDefaultBranch.mockResolvedValue({ success: true, branch: 'main', previousBranch: 'main', previousHead: 'b'.repeat(40), head: 'a'.repeat(40), discardedFiles: 1, fetched: true });
Expand Down Expand Up @@ -71,6 +75,16 @@ describe('GitTab managed repository sources', () => {
);
expect(screen.getByTestId('repository-source-panel')).toHaveTextContent('app-other');
});

it('refreshes repository sources after fetching branches', async () => {
render(<GitTab appId="app-example" appName="Example App" repoPath="/repo" />);

expect(await screen.findByTestId('repository-source-panel')).toHaveAttribute('data-refresh-key', '0');
fireEvent.click(screen.getByRole('button', { name: 'Fetch branches' }));

await waitFor(() => expect(api.updateBranches).toHaveBeenCalledWith('/repo'));
await waitFor(() => expect(screen.getByTestId('repository-source-panel')).toHaveAttribute('data-refresh-key', '1'));
});
});

describe('GitTab modal accessibility (issue #1090)', () => {
Expand Down
19 changes: 17 additions & 2 deletions client/src/components/apps/tabs/RepositorySourcePanel.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle,
CheckCircle2,
Expand Down Expand Up @@ -137,14 +137,15 @@ function RepositoryCard({ source }) {
);
}

export default function RepositorySourcePanel({ appId, appName, onUpdated }) {
export default function RepositorySourcePanel({ appId, appName, onUpdated, refreshKey = 0 }) {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [syncingFork, setSyncingFork] = useState(false);
const [updating, setUpdating] = useState(false);
const [error, setError] = useState(null);
const [updateIntent, setUpdateIntent] = useState(null);
const lastRefreshKey = useRef(refreshKey);

const load = useCallback(async ({ initial = false } = {}) => {
if (initial) setLoading(true);
Expand All @@ -164,6 +165,12 @@ export default function RepositorySourcePanel({ appId, appName, onUpdated }) {
load({ initial: true });
}, [load]);

useEffect(() => {
if (lastRefreshKey.current === refreshKey) return;
lastRefreshKey.current = refreshKey;
load();
}, [load, refreshKey]);

const sources = status?.sources || [];
const primary = sources.find((source) => source.id === 'primary') || sources[0] || null;
const companions = sources.filter((source) => source !== primary);
Expand Down Expand Up @@ -212,9 +219,17 @@ export default function RepositorySourcePanel({ appId, appName, onUpdated }) {
{ syncFork: intent?.syncFork === true },
{ silent: true },
).catch((reason) => {
if (appId === api.PORTOS_APP_ID && status?.updateRestartsApp && !reason?.status) {
api.handleSelfRestart();
return { selfRestartTriggered: true };
}
toast.error(reason.message || `Could not update ${appName || 'the app'}`);
return null;
});
if (result?.selfRestartTriggered) {
setUpdating(false);
return;
}
if (result?.success) {
toast.success(`${appName || 'App'} updated${status?.updateRestartsApp ? ' and restarted' : ''}`);
await load();
Expand Down
43 changes: 42 additions & 1 deletion client/src/components/apps/tabs/RepositorySourcePanel.test.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';

vi.mock('../../../services/api', () => ({
PORTOS_APP_ID: 'portos-default',
getAppRepositorySources: vi.fn(),
syncAppRepositoryFork: vi.fn(),
pullAndUpdateApp: vi.fn(),
handleSelfRestart: vi.fn(),
}));

import * as api from '../../../services/api';
Expand Down Expand Up @@ -157,6 +159,45 @@ describe('managed app repository sources', () => {
await waitFor(() => expect(onUpdated).toHaveBeenCalledOnce());
});

it('refreshes source status when the parent Git tab reports a branch update', async () => {
const currentStatus = canonicalStatus();
currentStatus.updateAvailable = false;
currentStatus.sources[0] = source({
id: 'primary',
label: 'Example App',
branch: 'main',
head: '3'.repeat(40),
origin: {
fullName: 'anima-research/example-app',
isUpstream: true,
isFork: false,
},
});
api.getAppRepositorySources
.mockResolvedValueOnce(canonicalStatus())
.mockResolvedValueOnce(currentStatus);

const { rerender } = render(
<RepositorySourcePanel appId="app-example" appName="Example App" refreshKey={0} />,
);
expect(await screen.findByText('Checkout 1 behind')).toBeInTheDocument();

rerender(<RepositorySourcePanel appId="app-example" appName="Example App" refreshKey={1} />);

await waitFor(() => expect(api.getAppRepositorySources).toHaveBeenCalledTimes(2));
await waitFor(() => expect(screen.getByTestId('repository-source-primary')).toHaveTextContent('Current'));
});

it('treats a PortOS update disconnect as the expected self-restart', async () => {
api.pullAndUpdateApp.mockRejectedValueOnce(new Error('Server unreachable — check your connection and try again'));
render(<RepositorySourcePanel appId="portos-default" appName="PortOS" />);

fireEvent.click(await screen.findByRole('button', { name: 'Update app' }));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Update app' }));

await waitFor(() => expect(api.handleSelfRestart).toHaveBeenCalledOnce());
});

it('refuses automatic fork sync after divergence but still permits updating from the fork as-is', async () => {
const status = canonicalStatus();
status.sources[0] = source({
Expand Down