From 499874080ce91c8f2eabb7a09a82a7fd46d9f006 Mon Sep 17 00:00:00 2001 From: David Brownell Date: Wed, 8 Apr 2026 12:35:48 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20[+feature]=20Added=20"pending"=20co?= =?UTF-8?q?lumn=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ensure that all bullet items are expressed in terms of dashes - Use dash in columns to indicate that no information is available - Only look for the last 30 days of CI info - Fixed scrolling issues in additional_data when switching columns - Fixed refresh issue when additional_data changes for the selected column --- src/AllGitStatus/MainApp.py | 42 ++- src/AllGitStatus/Sources/GitHubSource.py | 78 ++-- tests/MainApp_integration_test.py | 461 ++++++++++++++++++++++- tests/Sources/GitHubSource_test.py | 2 +- 4 files changed, 530 insertions(+), 53 deletions(-) diff --git a/src/AllGitStatus/MainApp.py b/src/AllGitStatus/MainApp.py index f84f2b1..de0e616 100644 --- a/src/AllGitStatus/MainApp.py +++ b/src/AllGitStatus/MainApp.py @@ -254,7 +254,8 @@ async def _ResetAllRepositories(self) -> None: self._additional_info_data.clear() self._state_data.clear() self._data_table.clear() - await self._OnSelectionChanged(repopulate_changes=False) + + await self._OnSelectionChanged() # Get the repositories @@ -287,6 +288,7 @@ async def _ResetRepository(self, repository: Repository, repository_index: int) ) if repository_index == self._data_table.cursor_coordinate.row: + await self._OnSelectionChanged() self._RefreshBindings() # Create the repo name @@ -314,22 +316,43 @@ async def _ResetRepository(self, repository: Repository, repository_index: int) async def LoadCells() -> None: assert self._github_session is not None - for source in [ + sources = [ LocalGitSource(), GitHubSource(self._github_session), - ]: + ] + + # Set all of the column values to pending + for source in sources: + if not source.Applies(repository): + continue + + for column_key, column in COLUMN_MAP.items(): + if not column_key[0] and not column_key[1]: + continue + + if column_key[0] != source.__class__.__name__: + continue + + self._data_table.update_cell_at( + Coordinate(repository_index, column.value), + Text("⏳", justify=column.justify), # ty: ignore[invalid-argument-type] + update_width=True, + ) + + # Get the actual values + for source in sources: if not source.Applies(repository): continue async for info in source.Query(repository): - self._PopulateCell(repository_index, info) + await self._PopulateCell(repository_index, info) # ---------------------------------------------------------------------- self.run_worker(LoadCells()) # ---------------------------------------------------------------------- - def _PopulateCell(self, repository_index: int, info: ResultInfo | ErrorInfo) -> None: + async def _PopulateCell(self, repository_index: int, info: ResultInfo | ErrorInfo) -> None: column = COLUMN_MAP[info.key] if isinstance(info, ErrorInfo): @@ -361,14 +384,14 @@ def _PopulateCell(self, repository_index: int, info: ResultInfo | ErrorInfo) -> self._additional_info_data.setdefault(repository_index, {})[column.value] = additional_info + if self._data_table.cursor_row == repository_index and self._data_table.cursor_column == column.value: + await self._OnSelectionChanged() + # ---------------------------------------------------------------------- - async def _OnSelectionChanged(self, *, repopulate_changes: bool = True) -> None: + async def _OnSelectionChanged(self) -> None: self._additional_info.clear() self._RefreshBindings() - if not repopulate_changes: - return - row_index = self._data_table.cursor_coordinate.row col_index = self._data_table.cursor_coordinate.column @@ -376,6 +399,7 @@ async def _OnSelectionChanged(self, *, repopulate_changes: bool = True) -> None: if additional_info: self._additional_info.write(additional_info) + self._additional_info.scroll_home() # ---------------------------------------------------------------------- def _RefreshBindings(self) -> None: diff --git a/src/AllGitStatus/Sources/GitHubSource.py b/src/AllGitStatus/Sources/GitHubSource.py index 488aa8d..d953176 100644 --- a/src/AllGitStatus/Sources/GitHubSource.py +++ b/src/AllGitStatus/Sources/GitHubSource.py @@ -3,6 +3,7 @@ import textwrap from collections.abc import AsyncGenerator +from datetime import datetime, timedelta, UTC import aiohttp @@ -131,6 +132,8 @@ async def _GenerateIssueInfo( repo: Repository, github_url: str, ) -> AsyncGenerator[ResultInfo | ErrorInfo]: + key = (self.__class__.__name__, "issues") + try: label_counts: dict[str, int] = {} total_count = 0 @@ -160,7 +163,7 @@ async def _GenerateIssueInfo( label_str = f" [{', '.join(issue_labels)}]" if issue_labels else "" - issue_data.append(f"• #{issue_number}{label_str} {issue_title} (by {issue_author})") + issue_data.append(f"- #{issue_number}{label_str} {issue_title} (by {issue_author})") # Build additional info with issue details additional_info_lines = [ @@ -182,17 +185,13 @@ async def _GenerateIssueInfo( yield ResultInfo( repo, - (self.__class__.__name__, "issues"), + key, f"{total_count:5} 🐛", "\n".join(additional_info_lines), ) except Exception as ex: - yield ErrorInfo( - repo, - (self.__class__.__name__, "issues"), - ex, - ) + yield ErrorInfo(repo, key, ex) # ---------------------------------------------------------------------- async def _GeneratePullRequestInfo( @@ -200,6 +199,8 @@ async def _GeneratePullRequestInfo( repo: Repository, github_url: str, ) -> AsyncGenerator[ResultInfo | ErrorInfo]: + key = (self.__class__.__name__, "pull_requests") + try: total_count = 0 pr_data: list[str] = [] @@ -216,7 +217,7 @@ async def _GeneratePullRequestInfo( draft_indicator = "[DRAFT] " if pr_draft else "" - pr_data.append(f"• #{pr_number} {draft_indicator}{pr_title} (by {pr_author})") + pr_data.append(f"- #{pr_number} {draft_indicator}{pr_title} (by {pr_author})") additional_info_lines = [ f"Pull Requests: {github_url}/pulls", @@ -229,17 +230,13 @@ async def _GeneratePullRequestInfo( yield ResultInfo( repo, - (self.__class__.__name__, "pull_requests"), + key, f"{total_count:5} 🔀", "\n".join(additional_info_lines), ) except Exception as ex: - yield ErrorInfo( - repo, - (self.__class__.__name__, "pull_requests"), - ex, - ) + yield ErrorInfo(repo, key, ex) # ---------------------------------------------------------------------- async def _GenerateSecurityAlertInfo( @@ -247,6 +244,8 @@ async def _GenerateSecurityAlertInfo( repo: Repository, github_url: str, ) -> AsyncGenerator[ResultInfo | ErrorInfo]: + key = (self.__class__.__name__, "security_alerts") + try: severity_counts: dict[str, int] = { "critical": 0, @@ -271,7 +270,7 @@ async def _GenerateSecurityAlertInfo( package = alert.get("security_vulnerability", {}).get("package", {}) alert_data.append( - f"• [{advisory.get('severity', 'unknown').upper()}] {package.get('name', 'unknown')}: {advisory.get('summary', 'No summary')}" + f"- [{advisory.get('severity', 'unknown').upper()}] {package.get('name', 'unknown')}: {advisory.get('summary', 'No summary')}" ) # Build display value with icon based on severity @@ -303,17 +302,13 @@ async def _GenerateSecurityAlertInfo( yield ResultInfo( repo, - (self.__class__.__name__, "security_alerts"), + key, display_value, "\n".join(additional_info_lines), ) except Exception as ex: - yield ErrorInfo( - repo, - (self.__class__.__name__, "security_alerts"), - ex, - ) + yield ErrorInfo(repo, key, ex) # ---------------------------------------------------------------------- async def _GenerateCICDInfo( @@ -322,12 +317,20 @@ async def _GenerateCICDInfo( github_url: str, default_branch: str, ) -> AsyncGenerator[ResultInfo | ErrorInfo]: - info_key = (self.__class__.__name__, "cicd_status") + key = (self.__class__.__name__, "cicd_status") try: - url = f"https://api.github.com/repos/{repo.github_owner}/{repo.github_repo}/actions/runs?branch={default_branch}&per_page=100" + prev_month = datetime.now(tz=UTC) - timedelta(days=30) + + url = f"https://api.github.com/repos/{repo.github_owner}/{repo.github_repo}/actions/runs" + + params = { + "branch": default_branch, + "per_page": 100, + "created": f">={prev_month.date()}", + } - async with self._session.get(url) as response: + async with self._session.get(url, params=params) as response: response.raise_for_status() result = await response.json() @@ -336,17 +339,18 @@ async def _GenerateCICDInfo( if not workflow_runs: yield ResultInfo( repo, - info_key, - "🔘", + key, + "-", textwrap.dedent( """\ CI/CD Status: {github_url}/actions - No workflow runs found for branch '{default_branch}' + No workflow runs found for branch '{default_branch}' since '{prev_month}'. """, ).format( github_url=github_url, default_branch=default_branch, + prev_month=prev_month.date(), ), ) return @@ -386,7 +390,7 @@ async def _GenerateCICDInfo( else: status_label = conclusion or status or "UNKNOWN" - run_details.append(f"• [{status_label}] {run['created_at']} {run['path']}: {run['name']}") + run_details.append(f"- [{status_label}] {run['created_at']} {run['path']}: {run['name']}") # Determine display icon based on priority: failure > in_progress > success if status_counts["failure"] > 0: @@ -415,25 +419,21 @@ async def _GenerateCICDInfo( yield ResultInfo( repo, - info_key, + key, display_icon, "\n".join(additional_info_lines), ) except Exception as ex: - yield ErrorInfo( - repo, - info_key, - ex, - ) + yield ErrorInfo(repo, key, ex) # ---------------------------------------------------------------------- - async def _GeneratePaginatedResults(self, raw_url: str) -> AsyncGenerator[dict]: - url: str | None = f"{raw_url}?state=open&per_page=100" + async def _GeneratePaginatedResults(self, url: str) -> AsyncGenerator[dict]: + params = {"state": "open", "per_page": 100} next_page_regex = re.compile(r"<([^>]+)>") while url: - async with self._session.get(url) as response: + async with self._session.get(url, params=params) as response: response.raise_for_status() results = await response.json() @@ -441,7 +441,9 @@ async def _GeneratePaginatedResults(self, raw_url: str) -> AsyncGenerator[dict]: for result in results: yield result - url = None + url: str | None = None + params = None + link_header = response.headers.get("Link", "") for link in link_header.split(","): diff --git a/tests/MainApp_integration_test.py b/tests/MainApp_integration_test.py index 9c621ea..4e052f8 100644 --- a/tests/MainApp_integration_test.py +++ b/tests/MainApp_integration_test.py @@ -10,6 +10,7 @@ import aiohttp import pytest +from rich.text import Text from textual.coordinate import Coordinate from textual.widgets import DataTable, Footer, Header, Label, RichLog @@ -652,7 +653,7 @@ async def mock_enum(wd): error=ValueError("Test error"), ) - app._PopulateCell(0, error_info) + await app._PopulateCell(0, error_info) await pilot.pause() # Verify the cell was updated with error indicator @@ -692,13 +693,13 @@ async def mock_enum(wd): state_data={"has_local_changes": True, "has_remote_changes": False}, ) - app._PopulateCell(0, result_info) + await app._PopulateCell(0, result_info) await pilot.pause() # Verify the cell was populated with the display value cell_value = app._data_table.get_cell_at(Coordinate(0, RemoteColumn.value)) - assert "0 🔼" in str(cell_value) - assert "0 🔽" in str(cell_value) + assert "0 🔼" in str(cell_value), (cell_value, result_info.additional_info) + assert "0 🔽" in str(cell_value), (cell_value, result_info.additional_info) # Verify additional_info was stored assert 0 in app._additional_info_data @@ -743,7 +744,7 @@ async def mock_enum(wd): error=e, ) - app._PopulateCell(0, error_info) + await app._PopulateCell(0, error_info) await pilot.pause() # Verify the cell was updated @@ -1100,3 +1101,453 @@ async def test_debug_mode_changes_title(self, working_dir: Path) -> None: app_with_debug = MainApp(working_dir=working_dir, github_pat=None, debug=True) assert app_with_debug.title == "AllGitStatus [DEBUG]" + + +# ---------------------------------------------------------------------- +class TestPendingIconDisplay: + """Tests for pending icon (⏳) display before column data is loaded.""" + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_pending_icon_appears_for_local_git_source_columns(self, working_dir: Path) -> None: + """Pending icon appears in LocalGitSource columns before data is loaded.""" + + repos = [create_mock_repository(working_dir / "repo1")] + + async def mock_enum(wd): + for repo in repos: + yield repo + + # Create an event to control when query completes + query_started = asyncio.Event() + query_can_continue = asyncio.Event() + + async def slow_query(repo): + query_started.set() + await query_can_continue.wait() + # Yield a result after waiting + yield ResultInfo( + repo=repo, + key=("LocalGitSource", "current_branch"), + display_value="main", + additional_info="Branch: main", + ) + + with ( + patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum), + patch("AllGitStatus.MainApp.LocalGitSource.Query", side_effect=slow_query), + ): + app = MainApp(working_dir=working_dir, github_pat=None) + + async with app.run_test() as pilot: + # Wait for repositories to load and pending icons to be set + await pilot.pause() + await asyncio.sleep(0.1) + await pilot.pause() + + # Wait for the query to start (indicating pending icons have been set) + try: + await asyncio.wait_for(query_started.wait(), timeout=2.0) + except asyncio.TimeoutError: + pass # Query may not have started yet, but pending icons should still be set + + # Check that LocalGitSource columns have the pending icon + branch_cell = app._data_table.get_cell_at(Coordinate(0, BranchColumn.value)) + local_cell = app._data_table.get_cell_at(Coordinate(0, LocalColumn.value)) + stashes_cell = app._data_table.get_cell_at(Coordinate(0, StashesColumn.value)) + remote_cell = app._data_table.get_cell_at(Coordinate(0, RemoteColumn.value)) + + # Verify at least one column shows the pending icon + # The pending icon is ⏳ + cells_content = [str(branch_cell), str(local_cell), str(stashes_cell), str(remote_cell)] + has_pending = any("⏳" in cell for cell in cells_content) + assert has_pending, f"Expected pending icon in at least one cell, got: {cells_content}" + + # Allow the test to complete + query_can_continue.set() + await pilot.pause() + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_pending_icon_appears_for_github_source_columns(self, working_dir: Path) -> None: + """Pending icon appears in GitHubSource columns for GitHub-linked repos.""" + + # Create a repository with GitHub remote (so GitHubSource.Applies returns True) + repos = [create_mock_repository(working_dir / "repo1", "https://github.com/testowner/repo1.git")] + + async def mock_enum(wd): + for repo in repos: + yield repo + + query_started = asyncio.Event() + query_can_continue = asyncio.Event() + + async def slow_local_query(repo): + # Return quickly for local source + yield ResultInfo( + repo=repo, + key=("LocalGitSource", "current_branch"), + display_value="main", + additional_info="Branch: main", + ) + + async def slow_github_query(repo): + query_started.set() + await query_can_continue.wait() + yield ResultInfo( + repo=repo, + key=("GitHubSource", "stars"), + display_value="⭐ 42", + additional_info="42 stars", + ) + + with ( + patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum), + patch("AllGitStatus.MainApp.LocalGitSource.Query", side_effect=slow_local_query), + patch("AllGitStatus.MainApp.GitHubSource.Query", side_effect=slow_github_query), + ): + app = MainApp(working_dir=working_dir, github_pat="test_pat") + + async with app.run_test() as pilot: + await pilot.pause() + await asyncio.sleep(0.1) + await pilot.pause() + + # Wait for GitHub query to start + try: + await asyncio.wait_for(query_started.wait(), timeout=2.0) + except asyncio.TimeoutError: + pass + + # GitHub columns should have pending icons + stars_cell = app._data_table.get_cell_at(Coordinate(0, StarsColumn.value)) + forks_cell = app._data_table.get_cell_at(Coordinate(0, ForksColumn.value)) + + # Verify GitHub columns have pending icons + assert "⏳" in str(stars_cell) or "⏳" in str(forks_cell), ( + f"Expected pending icon in GitHub columns, got stars={stars_cell}, forks={forks_cell}" + ) + + # Allow completion + query_can_continue.set() + await pilot.pause() + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_pending_icon_not_shown_for_non_github_repos(self, working_dir: Path) -> None: + """GitHub columns don't show pending icon for repos without GitHub remote.""" + + # Create a repository WITHOUT GitHub remote + repos = [create_mock_repository(working_dir / "repo1")] # No remote_url + + async def mock_enum(wd): + for repo in repos: + yield repo + + async def mock_local_query(repo): + yield ResultInfo( + repo=repo, + key=("LocalGitSource", "current_branch"), + display_value="main", + additional_info="Branch: main", + ) + + with ( + patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum), + patch("AllGitStatus.MainApp.LocalGitSource.Query", side_effect=mock_local_query), + ): + app = MainApp(working_dir=working_dir, github_pat=None) + + async with app.run_test() as pilot: + await pilot.pause() + await asyncio.sleep(0.2) + await pilot.pause() + + # GitHub columns should be empty (not pending) since GitHubSource doesn't apply + stars_cell = app._data_table.get_cell_at(Coordinate(0, StarsColumn.value)) + forks_cell = app._data_table.get_cell_at(Coordinate(0, ForksColumn.value)) + + # These cells should be empty strings, not pending icons + assert str(stars_cell) == "" + assert str(forks_cell) == "" + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_pending_icon_replaced_with_result_value(self, working_dir: Path) -> None: + """Pending icon is replaced with actual value when data arrives.""" + + repos = [create_mock_repository(working_dir / "repo1")] + + async def mock_enum(wd): + for repo in repos: + yield repo + + with patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum): + app = MainApp(working_dir=working_dir, github_pat=None) + + async with app.run_test() as pilot: + await pilot.pause() + await asyncio.sleep(0.1) + await pilot.pause() + + # Manually populate a cell to verify the mechanism + result_info = ResultInfo( + repo=repos[0], + key=("LocalGitSource", "current_branch"), + display_value="main", + additional_info="Branch: main", + ) + + await app._PopulateCell(0, result_info) + await pilot.pause() + + # The cell should now show "main", not the pending icon + branch_cell = app._data_table.get_cell_at(Coordinate(0, BranchColumn.value)) + assert "main" in str(branch_cell) + assert "⏳" not in str(branch_cell) + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_pending_icon_replaced_with_error_indicator(self, working_dir: Path) -> None: + """Pending icon is replaced with error indicator (💥) when error occurs.""" + + repos = [create_mock_repository(working_dir / "repo1")] + + async def mock_enum(wd): + for repo in repos: + yield repo + + with patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum): + app = MainApp(working_dir=working_dir, github_pat=None) + + async with app.run_test() as pilot: + await pilot.pause() + await asyncio.sleep(0.1) + await pilot.pause() + + # Manually populate a cell with an error + error_info = ErrorInfo( + repo=repos[0], + key=("LocalGitSource", "current_branch"), + error=ValueError("Test error"), + ) + + await app._PopulateCell(0, error_info) + await pilot.pause() + + # The cell should now show the error indicator, not pending icon + branch_cell = app._data_table.get_cell_at(Coordinate(0, BranchColumn.value)) + assert "💥" in str(branch_cell) + assert "⏳" not in str(branch_cell) + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_pending_icon_uses_column_justify(self, working_dir: Path) -> None: + """Pending icon respects the column's justify setting.""" + + repos = [create_mock_repository(working_dir / "repo1")] + + async def mock_enum(wd): + for repo in repos: + yield repo + + # We need to capture what's passed to update_cell_at + captured_texts: list[tuple[Coordinate, Text]] = [] + original_update_cell_at = DataTable.update_cell_at + + def capture_update_cell_at(self, coordinate, value, **kwargs): + if isinstance(value, Text): + captured_texts.append((coordinate, value)) + return original_update_cell_at(self, coordinate, value, **kwargs) + + with ( + patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum), + patch.object(DataTable, "update_cell_at", capture_update_cell_at), + ): + app = MainApp(working_dir=working_dir, github_pat=None) + + async with app.run_test() as pilot: + await pilot.pause() + await asyncio.sleep(0.2) + await pilot.pause() + + # Find pending icon updates for different columns + pending_updates = [(coord, text) for coord, text in captured_texts if str(text) == "⏳"] + + # Verify that pending icons were set with proper justify + for coord, text in pending_updates: + column = next( + (c for c in COLUMN_MAP.values() if c.value == coord.column), + None, + ) + if column: + assert text.justify == column.justify + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_all_local_source_columns_get_pending_icon(self, working_dir: Path) -> None: + """All LocalGitSource columns receive pending icon during reset.""" + + repos = [create_mock_repository(working_dir / "repo1")] + + async def mock_enum(wd): + for repo in repos: + yield repo + + # Track which columns received pending icons + pending_columns: set[int] = set() + original_update_cell_at = DataTable.update_cell_at + + def track_pending_updates(self, coordinate, value, **kwargs): + if isinstance(value, Text) and str(value) == "⏳": + pending_columns.add(coordinate.column) + return original_update_cell_at(self, coordinate, value, **kwargs) + + # Block the query from completing so we can verify pending state + query_started = asyncio.Event() + + async def blocking_query(_repo): + query_started.set() + # Never yield - just wait forever + await asyncio.Event().wait() + yield # Make it a generator + + with ( + patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum), + patch.object(DataTable, "update_cell_at", track_pending_updates), + patch("AllGitStatus.MainApp.LocalGitSource.Query", side_effect=blocking_query), + ): + app = MainApp(working_dir=working_dir, github_pat=None) + + async with app.run_test() as pilot: + await pilot.pause() + await asyncio.sleep(0.1) + await pilot.pause() + + # Wait for query to start (means pending icons were set) + try: + await asyncio.wait_for(query_started.wait(), timeout=2.0) + except asyncio.TimeoutError: + pass + + # Verify all LocalGitSource columns received pending icons + local_columns = { + BranchColumn.value, + LocalColumn.value, + StashesColumn.value, + RemoteColumn.value, + } + assert local_columns.issubset(pending_columns), ( + f"Expected LocalGitSource columns {local_columns} to have pending icons, " + f"but only got {pending_columns}" + ) + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_all_github_source_columns_get_pending_icon(self, working_dir: Path) -> None: + """All GitHubSource columns receive pending icon for GitHub repos.""" + + # Create a repository WITH GitHub remote + repos = [create_mock_repository(working_dir / "repo1", "https://github.com/testowner/repo1.git")] + + async def mock_enum(wd): + for repo in repos: + yield repo + + # Track which columns received pending icons + pending_columns: set[int] = set() + original_update_cell_at = DataTable.update_cell_at + + def track_pending_updates(self, coordinate, value, **kwargs): + if isinstance(value, Text) and str(value) == "⏳": + pending_columns.add(coordinate.column) + return original_update_cell_at(self, coordinate, value, **kwargs) + + # Block queries from completing + async def blocking_local_query(_repo): + await asyncio.Event().wait() + yield + + async def blocking_github_query(_repo): + await asyncio.Event().wait() + yield + + with ( + patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum), + patch.object(DataTable, "update_cell_at", track_pending_updates), + patch("AllGitStatus.MainApp.LocalGitSource.Query", side_effect=blocking_local_query), + patch("AllGitStatus.MainApp.GitHubSource.Query", side_effect=blocking_github_query), + ): + app = MainApp(working_dir=working_dir, github_pat="test_pat") + + async with app.run_test() as pilot: + await pilot.pause() + await asyncio.sleep(0.2) + await pilot.pause() + + # Verify all GitHubSource columns received pending icons + github_columns = { + StarsColumn.value, + ForksColumn.value, + WatchersColumn.value, + IssuesColumn.value, + PullRequestsColumn.value, + SecurityAlertsColumn.value, + CICDStatusColumn.value, + ArchivedColumn.value, + } + assert github_columns.issubset(pending_columns), ( + f"Expected GitHubSource columns {github_columns} to have pending icons, " + f"but only got {pending_columns}" + ) + + # ---------------------------------------------------------------------- + @pytest.mark.asyncio + async def test_pending_icon_on_refresh_selected(self, working_dir: Path) -> None: + """Pending icon appears when refreshing a single repository.""" + + repos = [ + create_mock_repository(working_dir / "repo1"), + create_mock_repository(working_dir / "repo2"), + ] + + async def mock_enum(_wd): + for repo in repos: + yield repo + + with patch("AllGitStatus.MainApp.EnumerateRepositories", side_effect=mock_enum): + app = MainApp(working_dir=working_dir, github_pat=None) + + async with app.run_test() as pilot: + # Wait for initial load + await pilot.pause() + await asyncio.sleep(0.2) + await pilot.pause() + + # Verify repos loaded + assert app._repositories is not None + assert len(app._repositories) == 2 + + # Track pending icons after refresh + pending_updates: list[Coordinate] = [] + original_update_cell_at = DataTable.update_cell_at + + def track_refresh_pending(self, coordinate, value, **kwargs): # noqa: ARG001 + if isinstance(value, Text) and str(value) == "⏳": + pending_updates.append(coordinate) + return original_update_cell_at(self, coordinate, value, **kwargs) + + with patch.object(DataTable, "update_cell_at", track_refresh_pending): + # Press 'r' to refresh selected (first row) + await pilot.press("r") + await pilot.pause() + await asyncio.sleep(0.1) + await pilot.pause() + + # Verify pending icons were set for row 0 (the selected row) + row_0_pending = [c for c in pending_updates if c.row == 0] + assert len(row_0_pending) > 0, "Expected pending icons for refreshed row" + + # Verify no pending icons for row 1 (not refreshed) + row_1_pending = [c for c in pending_updates if c.row == 1] + assert len(row_1_pending) == 0, "Should not have pending icons for non-refreshed row" diff --git a/tests/Sources/GitHubSource_test.py b/tests/Sources/GitHubSource_test.py index 9a974e4..5062a1a 100644 --- a/tests/Sources/GitHubSource_test.py +++ b/tests/Sources/GitHubSource_test.py @@ -2134,7 +2134,7 @@ async def test_no_workflows_shows_neutral_icon(self, github_repo: Repository) -> cicd_result = next(r for r in results if r.key[1] == "cicd_status") assert isinstance(cicd_result, ResultInfo) - assert cicd_result.display_value == "🔘" + assert cicd_result.display_value == "-" # ----------------------------------------------------------------------