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
21 changes: 20 additions & 1 deletion client/src/components/ProjectsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Wrench,
RefreshCw,
ClipboardList,
AlertCircle,
} from 'lucide-react';
import { useApp } from '../hooks/useApp';
import { useNotifications } from '../context/NotificationContext';
Expand All @@ -38,7 +39,8 @@ export function ProjectsSidebar({
isCollapsed = false,
onExpand,
}: ProjectsSidebarProps) {
const { projects, repositories, handleDeleteRepository } = useApp();
const { projects, repositories, handleDeleteRepository, loading, error, refreshData } =
useApp();
const { showNotification } = useNotifications();
const navigate = useNavigate();
const router = useRouterState();
Expand Down Expand Up @@ -386,6 +388,23 @@ export function ProjectsSidebar({
</div>
</div>

{error && !loading && (
<div className="mb-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-red-700 dark:text-red-300 flex items-start gap-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p>Couldn't reach the server to load your projects.</p>
<button
type="button"
onClick={() => refreshData()}
className="mt-1 inline-flex items-center gap-1 font-medium hover:underline"
>
<RefreshCw className="w-3 h-3" />
Retry
</button>
</div>
</div>
)}

<div className="space-y-1 flex-1 overflow-y-auto">
{projects.map((project) => {
const projectRepos = projectRepoMap.get(project.id) || [];
Expand Down
36 changes: 31 additions & 5 deletions client/src/context/AppContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,55 @@ import {
} from '../api';
import { AppContext } from './context';

// The client and server start as separate containers with no readiness
// handshake between them - on a stack restart the client's first request can
// beat the server to accepting connections. Retry the initial load a few
// times before surfacing an error, so a normal restart doesn't strand the
// user on an empty project list.
const INITIAL_LOAD_MAX_ATTEMPTS = 4;
const INITIAL_LOAD_RETRY_DELAY_MS = 1000;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export function AppProvider({ children }: { children: ReactNode }) {
const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<Repository[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const refreshData = async () => {
const loadData = async (): Promise<boolean> => {
try {
setLoading(true);
setError(null);
const [projectList, repoList] = await Promise.all([getProjects(), getRepositories()]);
setProjects(projectList);
setRepositories(repoList);
return true;
} catch (err) {
setError('Failed to load data');
console.error('Failed to load data:', err);
} finally {
setLoading(false);
return false;
}
};

const refreshData = async () => {
setLoading(true);
await loadData();
setLoading(false);
};

useEffect(() => {
refreshData();
const loadWithRetry = async () => {
setLoading(true);
for (let attempt = 1; attempt <= INITIAL_LOAD_MAX_ATTEMPTS; attempt++) {
const succeeded = await loadData();
if (succeeded) break;
if (attempt < INITIAL_LOAD_MAX_ATTEMPTS) {
await sleep(INITIAL_LOAD_RETRY_DELAY_MS * attempt);
}
}
setLoading(false);
};
loadWithRetry();
}, []);

const handleAddProject = async (name: string, description?: string) => {
Expand Down