diff --git a/client/src/components/apps/tabs/GitTab.jsx b/client/src/components/apps/tabs/GitTab.jsx index 823818ce17..b818b8184f 100644 --- a/client/src/components/apps/tabs/GitTab.jsx +++ b/client/src/components/apps/tabs/GitTab.jsx @@ -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; @@ -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 () => { @@ -497,6 +499,7 @@ export default function GitTab({ appId, appName, repoPath }) { loadGitData({ includeRemote: true })} /> diff --git a/client/src/components/apps/tabs/GitTab.test.jsx b/client/src/components/apps/tabs/GitTab.test.jsx index a3123020ab..32a83d4078 100644 --- a/client/src/components/apps/tabs/GitTab.test.jsx +++ b/client/src/components/apps/tabs/GitTab.test.jsx @@ -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 }) =>
{appId}
, + default: ({ appId, refreshKey }) => ( +
{appId}
+ ), })); import * as api from '../../../services/api'; @@ -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 }); @@ -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(); + + 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)', () => { diff --git a/client/src/components/apps/tabs/RepositorySourcePanel.jsx b/client/src/components/apps/tabs/RepositorySourcePanel.jsx index 8311a030df..772f4e4613 100644 --- a/client/src/components/apps/tabs/RepositorySourcePanel.jsx +++ b/client/src/components/apps/tabs/RepositorySourcePanel.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertTriangle, CheckCircle2, @@ -137,7 +137,7 @@ 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); @@ -145,6 +145,7 @@ export default function RepositorySourcePanel({ appId, appName, onUpdated }) { 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); @@ -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); @@ -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(); diff --git a/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx b/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx index e6c7e76f11..00cbf5f2ed 100644 --- a/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx +++ b/client/src/components/apps/tabs/RepositorySourcePanel.test.jsx @@ -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'; @@ -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( + , + ); + expect(await screen.findByText('Checkout 1 behind')).toBeInTheDocument(); + + rerender(); + + 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(); + + 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({