import type { UnifiedAppState, UnifiedAppAction, SortField, ModalState, GitStatus, RemoteStatus, } from "../types/index.ts"; /** * Initial app state */ export const initialState: UnifiedAppState = { projects: [], isLoading: true, error: null, message: null, cursorIndex: 0, selectedIndices: new Set(), scrollOffset: 0, filterText: "", quickFilter: "all", sortBy: "status", sortDirection: "desc", mode: "normal", actionInProgress: null, actionProgress: null, modal: { kind: "none" }, viewMode: "combined", githubRepos: [], unifiedRepos: [], isLoadingGitHub: false, githubError: null, isRefreshing: false, languageFilter: null, }; /** * Sort field cycle order (matches column order) */ export const SORT_FIELDS: SortField[] = [ "status", "name", "branch", "sync", "language", "stars", "forks", "lastActivity", "size", ]; function mergeRemoteStatusIntoGitStatus( status: GitStatus, remote: RemoteStatus | null, ): GitStatus { return { ...status, unpushedCommits: remote?.unpushedCommits ?? 0, unpulledCommits: remote?.unpulledCommits ?? 0, lastRemoteActivity: remote?.lastRemoteActivity ?? null, isAhead: remote?.isAhead ?? false, isBehind: remote?.isBehind ?? false, isOutOfSync: remote?.isOutOfSync ?? false, }; } /** * App state reducer */ export function appReducer(state: UnifiedAppState, action: UnifiedAppAction): UnifiedAppState { switch (action.type) { case "SET_REPO_DATA": { const { projects, githubRepos, unifiedRepos, finishInitialLoading } = action.payload; return { ...state, projects, githubRepos, unifiedRepos, isLoading: finishInitialLoading ? false : state.isLoading, cursorIndex: Math.min(state.cursorIndex, Math.max(0, unifiedRepos.length - 1)), selectedIndices: new Set(), }; } case "SET_PROJECTS": return { ...state, projects: action.payload, // Reset cursor if needed cursorIndex: Math.min(state.cursorIndex, Math.max(0, action.payload.length - 1)), selectedIndices: new Set(), }; case "SET_LOADING": return { ...state, isLoading: action.payload }; case "SET_ERROR": return { ...state, error: action.payload }; case "SET_MESSAGE": return { ...state, message: action.payload }; case "MOVE_CURSOR": { // Caller passes maxIndex (length-1 of the visible/filtered list) so the // cursor can't drift past visible rows when filters are active. const { index, maxIndex } = action.payload; return { ...state, cursorIndex: Math.max(0, Math.min(index, maxIndex)), }; } case "TOGGLE_SELECTION": { const newSelected = new Set(state.selectedIndices); if (newSelected.has(action.payload)) { newSelected.delete(action.payload); } else { newSelected.add(action.payload); } return { ...state, selectedIndices: newSelected }; } case "SELECT_ALL": { // Caller passes the count of the visible/filtered list so we don't // accidentally select hidden repos. const { count } = action.payload; const indices = new Set(); for (let i = 0; i < count; i++) indices.add(i); return { ...state, selectedIndices: indices }; } case "DESELECT_ALL": return { ...state, selectedIndices: new Set() }; case "SET_FILTER": return { ...state, filterText: action.payload, cursorIndex: 0, // Reset cursor when filtering selectedIndices: new Set(), }; case "SET_QUICK_FILTER": return { ...state, quickFilter: action.payload, cursorIndex: 0, // Reset cursor when filtering selectedIndices: new Set(), // Clear selection }; case "SET_SORT": return { ...state, sortBy: action.payload.by, sortDirection: action.payload.direction, selectedIndices: new Set(), }; case "CYCLE_SORT": { const currentIndex = SORT_FIELDS.indexOf(state.sortBy); const nextIndex = (currentIndex + 1) % SORT_FIELDS.length; return { ...state, sortBy: SORT_FIELDS[nextIndex]!, selectedIndices: new Set(), }; } case "SET_MODE": return { ...state, mode: action.payload }; case "START_ACTION": return { ...state, actionInProgress: action.payload, actionProgress: null }; case "END_ACTION": return { ...state, actionInProgress: null, actionProgress: null }; case "UPDATE_PROGRESS": return { ...state, actionProgress: action.payload }; case "SET_SCROLL_OFFSET": return { ...state, scrollOffset: action.payload }; case "UPDATE_PROJECT": { const { id, updates } = action.payload; return { ...state, projects: state.projects.map((p) => p.id === id ? { ...p, ...updates } : p ), }; } case "UPDATE_PROJECT_REMOTE_STATUS": { const { path, remote } = action.payload; return { ...state, projects: state.projects.map((p) => { if (p.path !== path || p.status === null) return p; return { ...p, status: mergeRemoteStatusIntoGitStatus(p.status, remote), }; }), unifiedRepos: state.unifiedRepos.map((repo) => { if (repo.local?.path !== path || repo.local.status === null) return repo; return { ...repo, local: { ...repo.local, status: mergeRemoteStatusIntoGitStatus(repo.local.status, remote), }, }; }), }; } case "OPEN_MODAL": return { ...state, modal: action.payload, }; case "CLOSE_MODAL": return { ...state, modal: { kind: "none" }, }; case "UPDATE_MODAL": { // Drop the update silently when the open modal's kind doesn't match. if (state.modal.kind !== action.payload.kind) return state; return { ...state, // Narrowing: action.payload.kind === state.modal.kind, so the data // shapes line up by construction. modal: { ...state.modal, data: { ...state.modal.data, ...action.payload.data }, } as ModalState, }; } // New unified view actions case "SET_VIEW_MODE": return { ...state, viewMode: action.payload, cursorIndex: 0, // Reset cursor when changing view mode selectedIndices: new Set(), }; case "SET_GITHUB_REPOS": return { ...state, githubRepos: action.payload, }; case "SET_UNIFIED_REPOS": return { ...state, unifiedRepos: action.payload, cursorIndex: Math.min(state.cursorIndex, Math.max(0, action.payload.length - 1)), selectedIndices: new Set(), }; case "SET_GITHUB_LOADING": return { ...state, isLoadingGitHub: action.payload, }; case "SET_GITHUB_ERROR": return { ...state, githubError: action.payload, }; case "SET_REFRESHING": return { ...state, isRefreshing: action.payload, }; case "CLONE_REPO_START": return { ...state, actionInProgress: `Cloning ${action.payload}...`, actionProgress: null, }; case "CLONE_REPO_COMPLETE": { const { id, localPath } = action.payload; return { ...state, unifiedRepos: state.unifiedRepos.map((repo) => repo.id === id ? { ...repo, isCloned: true, localPath } : repo ), actionInProgress: null, actionProgress: null, }; } case "CLONE_REPO_FAILED": return { ...state, error: `Failed to clone ${action.payload.id}: ${action.payload.error}`, actionInProgress: null, actionProgress: null, }; case "SET_LANGUAGE_FILTER": return { ...state, languageFilter: action.payload, cursorIndex: 0, selectedIndices: new Set(), }; default: return state; } }