diff --git a/docs/widgets/(Widget)-Github.md b/docs/widgets/(Widget)-Github.md index 2291eb158..d4b85733e 100644 --- a/docs/widgets/(Widget)-Github.md +++ b/docs/widgets/(Widget)-Github.md @@ -7,12 +7,14 @@ | `tooltip` | boolean | `true` | Whether to show the tooltip on hover. | | `update_interval` | integer | `600` | The interval in seconds to update the notifications. Must be between 60 and 3600. | | `token` | string | `""` | The GitHub personal access token. | -| `max_notification` | integer | `20` | The maximum number of notifications to display in the menu. | +| `max_notification` | integer | `30` | The maximum number of notifications to display in the menu. | | `notification_dot` | dict | `{'enabled': true, 'corner': 'bottom_left', 'color': 'red', 'margin': [1, 1]}` | A dictionary specifying the notification dot settings for the widget. | | `only_unread` | boolean | `false` | Whether to show only unread notifications. | +| `show_comment_count`| boolean | `false` | Whether to request and display aggregated comment counts for supported notifications. | +| `reason_filters` | list | `[]` | Optional list of notification reasons to include (e.g. `['mention', 'assign']`). Empty list returns all reasons. | | `max_field_size` | integer | `100` | The maximum number of characters in the title before truncation. | -| `menu` | dict | `{'blur': true, 'round_corners': true, 'round_corners_type': 'normal', 'border_color': 'System', 'alignment': 'right', 'direction': 'down', 'offset_top': 6, 'offset_left': 0}` | Menu settings for the widget. | -| `icons` | dict | `{'issue': '\uf41b', 'pull_request': '\uea64', 'release': '\uea84', 'discussion': '\uf442', 'checksuite': '\uf418', 'default': '\uea84', 'github_logo': '\uea84'}` | Icons for different types of notifications in the menu. | +| `menu` | dict | `{'blur': true, 'round_corners': true, 'round_corners_type': 'normal', 'border_color': 'System', 'alignment': 'right', 'direction': 'down', 'offset_top': 6, 'offset_left': 0, 'show_categories': true, 'categories_order': []}` | Menu settings for the widget. | +| `icons` | dict | `{'issue': '\uf41b', 'issue_closed': '\uf41d', 'pull_request': '\uea64', 'pull_request_closed': '\uebda', 'pull_request_merged': '\uf17f', 'pull_request_draft': '\uebdb', 'release': '\uea84', 'discussion': '\uf442', 'discussion_answered': '\uf4c0', 'checksuite': '\uf418', 'default': '\uea84', 'github_logo': '\uea84', 'comment': '\uf41f'}` | Icons for different types of notifications in the menu. | | `animation` | dict | `{'enabled': true, 'type': 'fadeInOut', 'duration': 200}` | Animation settings for the widget. | | `container_shadow` | dict | `None` | Container shadow options. | | `label_shadow` | dict | `None` | Label shadow options. | @@ -24,13 +26,15 @@ github: label: "\ueba1" label_alt: "Notifications {data}" # {data} return number of unread notification token: ghp_xxxxxxxxxxx # GitHub Personal access tokens (classic) https://github.com/settings/tokens - max_notification: 20 # Max number of notification displaying in menu max: 50 + max_notification: 30 # Max number of notification displaying in menu max: 50 notification_dot: enabled: True corner: "bottom_left" # Can be "top_left", "top_right", "bottom_left", "bottom_right" color: "red" # Can be hex color or string margin: [ 1, 1 ] # x and y margin for the dot only_unread: false # Show only unread or all notifications; + show_comment_count: false # Summarize comment totals for issues, PRs, and discussions + reason_filters: [] # e.g. ['mention', 'assign'] to limit notifications by reason max_field_size: 54 # Max characters in title before truncation. update_interval: 300 # Check for new notification in seconds menu: @@ -40,6 +44,8 @@ github: border_color: "System" # Set the border color for the menu (this option is not supported on Windows 10) alignment: "right" direction: "down" + show_categories: false + categories_order: ["PullRequest", "Issue", "CheckSuite", "Release", "Discussion"] label_shadow: enabled: True color: "black" @@ -50,14 +56,20 @@ github: - **label:** The format string for the label. You can use placeholders like `{icon}` to dynamically insert icon information. - **label_alt:** The alternative format string for the label. Useful for displaying additional notification details. -- **icons:** A dictionary specifying the icons for different types of notifications in the menu. The available keys are: - - **issue:** The icon for issue notifications. - - **pull_request:** The icon for pull request notifications. +- **icons:** A dictionary specifying the icons for different types of notifications in the menu. All icons have defaults defined in validation. The available keys are: + - **issue:** The icon for open issue notifications. + - **issue_closed:** The icon for closed issues. + - **pull_request:** The icon for open pull request notifications. + - **pull_request_closed:** The icon for closed pull requests (not merged). + - **pull_request_merged:** The icon for merged pull requests. + - **pull_request_draft:** The icon for draft pull requests. - **release:** The icon for release notifications. - - **discussion:** The icon for discussion notifications. + - **discussion:** The icon for open discussion notifications. + - **discussion_answered:** The icon for discussions with an accepted answer. - **checksuite:** The icon for check suite notifications. - - **default:** The default icon for other types of notifications. - - **github_logo:** The icon for the GitHub logo. + - **default:** The default icon for notification types not explicitly handled. + - **github_logo:** The icon for the GitHub logo (used in empty state). + - **comment:** The icon that prefixes the comment count badge when `show_comment_count` is enabled. - **tooltip:** Whether to show the tooltip on hover. - **update_interval:** The interval in seconds to update the notifications. Must be between 60 and 3600. - **token:** The GitHub personal access token. GitHub Personal access tokens (classic) https://github.com/settings/tokens you can set `token: env`, this means you have to set YASB_GITHUB_TOKEN in environment variable. @@ -68,6 +80,8 @@ github: - **color:** Set the color of the notification dot. Can be hex or string color. - **margin:** Set the x, y margin for the notification dot. - **only_unread:** Whether to show only unread notifications. +- **show_comment_count:** When enabled, the widget performs a single batched GraphQL request to fetch comment totals for issues, pull requests, and discussions. Pull request counts include review threads. The comment count is displayed alongside each notification item. +- **reason_filters:** Optional list of notification reasons to include. Leave empty to show all reasons. Supported values include `assign`, `author`, `comment`, `ci_activity`, `invitation`, `manual`, `mention`, `review_requested`, `security_alert`, `state_change`, `subscribed`, and `team_mention`. - **max_field_size:** The maximum number of characters in the title before truncation. - **menu:** A dictionary specifying the menu settings for the widget. It contains the following keys: - **blur:** Enable blur effect for the menu. @@ -78,6 +92,11 @@ github: - **direction:** Set the direction of the menu (up, down). - **offset_top:** Set the offset from the top of the screen. - **offset_left:** Set the offset from the left of the screen. + - **show_categories:** Toggle grouping notifications by their GitHub type. When enabled, each group renders inside a `.section` container with a `.section-header` label. + - **categories_order:** Optional list that defines the preferred order of categories when `show_categories` is enabled. Values are case-insensitive and must match GitHub notification types (for example `PullRequest`, `Issue`). Any categories not listed appear after the configured ones. Available categories include `PullRequest`, `Issue`, `CheckSuite`, `Release`, and `Discussion`. + + When `show_categories` is enabled, the first and last notification card within each section gains the `.first` and `.last` classes. If categories are hidden, those classes are applied to the first and last items in the flat list instead. Use them to fine-tune spacing or borders. + - **animation:** A dictionary specifying the animation settings for the widget. It contains three keys: `enabled`, `type`, and `duration`. The `type` can be `fadeInOut` and the `duration` is the animation duration in milliseconds. - **container_shadow:** Container shadow options. - **label_shadow:** Label shadow options. @@ -92,17 +111,30 @@ github: .github-menu .header {} .github-menu .footer {} .github-menu .footer .label {} -.github-menu .contents {} -.github-menu .contents .item {} -.github-menu .contents .item.new {} /* New notification */ -.github-menu .contents .item .title {} -.github-menu .contents .item .description {} -.github-menu .contents .item .icon {} /* Default icon */ +.github-menu .contents {} /* Scrollable area containing all notification sections */ +.github-menu .contents .section {} /* Container for notification items, wraps all items when categories disabled, or each category group when enabled */ +.github-menu .contents .section-header {} /* Category title (e.g., "Issues", "Pull Requests"). Only visible when show_categories is enabled */ +.github-menu .contents .item {} /* Individual notification card */ +.github-menu .contents .item.first {} /* First item in section or list */ +.github-menu .contents .item.last {} /* Last item in section or list */ +.github-menu .contents .item.new {} /* Style for unread notification card */ +.github-menu .contents .item .title {} /* Title text of notification card */ +.github-menu .contents .item .description {} /* Description text of notification card */ +.github-menu .contents .item .icon {} /* Default style for icon */ .github-menu .contents .item .icon.issue {} /* Issue icon */ +.github-menu .contents .item .icon.issue.closed {} +.github-menu .contents .item .icon.issue.open {} .github-menu .contents .item .icon.pullrequest {} /* Pull request icon */ +.github-menu .contents .item .icon.pullrequest.open {} +.github-menu .contents .item .icon.pullrequest.closed {} +.github-menu .contents .item .icon.pullrequest.merged {} +.github-menu .contents .item .icon.pullrequest.draft {} .github-menu .contents .item .icon.release {} /* Release icon */ .github-menu .contents .item .icon.discussion {} /* Discussion icon */ +.github-menu .contents .item .icon.discussion.answered {} .github-menu .contents .item .icon.checksuite {} /* Check suite icon */ +.github-menu .contents .item .comment-count {} /* Comment label text */ +.github-menu .contents .item .comment-icon {} /* Comment icon */ ``` ## Example Style for the Widget and Menu @@ -117,7 +149,7 @@ github: border-bottom: 1px solid rgba(255, 255, 255, 0.1); font-size: 15px; font-weight: 400; - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-family: 'Segoe UI'; padding: 8px; color: white; background-color: rgba(17, 17, 27, 0.75); diff --git a/src/core/utils/utilities.py b/src/core/utils/utilities.py index be3f63e22..7277bfa77 100644 --- a/src/core/utils/utilities.py +++ b/src/core/utils/utilities.py @@ -2,6 +2,7 @@ import os import platform import re +from datetime import datetime, timezone from enum import StrEnum from functools import lru_cache from pathlib import Path @@ -49,6 +50,64 @@ def is_windows_10() -> bool: return bool(re.match(r"^10\.0\.1\d{4}$", version)) +def get_relative_time(iso_timestamp: str) -> str: + """ + Convert an ISO 8601 timestamp to a human-readable relative time string. + + Args: + iso_timestamp: ISO 8601 formatted timestamp (e.g., "2024-11-01T12:00:00Z") + + Returns: + A relative time string (e.g., "3 days ago", "2 weeks ago", "just now") + Returns empty string if timestamp is invalid or empty. + + Examples: + >>> get_relative_time("2024-11-07T12:00:00Z") + "just now" + >>> get_relative_time("2024-11-04T12:00:00Z") + "3 days ago" + """ + if not iso_timestamp: + return "" + + try: + # Parse ISO 8601 timestamp + updated = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")) + now = datetime.now(timezone.utc) + diff = now - updated + + seconds = diff.total_seconds() + minutes = seconds / 60 + hours = minutes / 60 + days = hours / 24 + weeks = days / 7 + months = days / 30 + years = days / 365 + + if seconds < 60: + return "just now" + elif minutes < 60: + m = int(minutes) + return f"{m} minute{'s' if m != 1 else ''} ago" + elif hours < 24: + h = int(hours) + return f"{h} hour{'s' if h != 1 else ''} ago" + elif days < 7: + d = int(days) + return f"{d} day{'s' if d != 1 else ''} ago" + elif weeks < 4: + w = int(weeks) + return f"{w} week{'s' if w != 1 else ''} ago" + elif months < 12: + mo = int(months) + return f"{mo} month{'s' if mo != 1 else ''} ago" + else: + y = int(years) + return f"{y} year{'s' if y != 1 else ''} ago" + except Exception: + return "" + + def is_process_running(process_name: str) -> bool: for proc in psutil.process_iter(["name"]): if proc.info["name"] == process_name: diff --git a/src/core/utils/widgets/github/api.py b/src/core/utils/widgets/github/api.py index f1a46f33a..eedcb1718 100644 --- a/src/core/utils/widgets/github/api.py +++ b/src/core/utils/widgets/github/api.py @@ -21,10 +21,18 @@ class GitHubDataManager: _token: str | None = None _only_unread: bool = True _max_notification: int = 50 + _reason_filters: list[str] | None = None + _show_comment_count: bool = False @classmethod def initialize( - cls, token: str, only_unread: bool = True, max_notification: int = 50, update_interval: int = 300 + cls, + token: str, + only_unread: bool = True, + max_notification: int = 50, + update_interval: int = 300, + reason_filters: list[str] | None = None, + show_comment_count: bool = False, ) -> None: """ Initialize the manager with settings and start the timer. @@ -36,6 +44,11 @@ def initialize( cls._only_unread = only_unread cls._max_notification = max_notification cls._timer_interval = update_interval * 1000 + cls._show_comment_count = show_comment_count + if reason_filters: + cls._reason_filters = [reason.lower() for reason in reason_filters if reason] + else: + cls._reason_filters = None # Create and start timer cls._timer = QTimer() @@ -57,7 +70,13 @@ def stop(cls) -> None: def _on_timer(cls) -> None: """Called by QTimer - triggers data fetch.""" if cls._token: - cls.fetch_notifications(cls._token, cls._only_unread, cls._max_notification) + cls.fetch_notifications( + cls._token, + cls._only_unread, + cls._max_notification, + cls._reason_filters, + cls._show_comment_count, + ) @classmethod def register_callback(cls, callback: Callable) -> None: @@ -80,7 +99,14 @@ def get_data(cls) -> list[dict[str, Any]]: return cls._shared_data.copy() @classmethod - def fetch_notifications(cls, token: str, only_unread: bool = True, max_notification: int = 50) -> None: + def fetch_notifications( + cls, + token: str, + only_unread: bool = True, + max_notification: int = 50, + reason_filters: list[str] | None = None, + show_comment_count: bool = False, + ) -> None: """ Fetch notifications from GitHub API in a background thread. After fetching, calls all registered callbacks with the new data. @@ -88,7 +114,13 @@ def fetch_notifications(cls, token: str, only_unread: bool = True, max_notificat def _fetch(): try: - notifications = cls._get_github_notifications(token, only_unread, max_notification) + notifications = cls._get_github_notifications( + token, + only_unread, + max_notification, + reason_filters, + show_comment_count, + ) with cls._lock: cls._shared_data = notifications @@ -197,7 +229,14 @@ def _sync_to_github(): threading.Thread(target=_sync_to_github, daemon=True).start() @classmethod - def _get_github_notifications(cls, token: str, only_unread: bool, max_notification: int) -> list[dict[str, Any]]: + def _get_github_notifications( + cls, + token: str, + only_unread: bool, + max_notification: int, + reason_filters: list[str] | None = None, + show_comment_count: bool = False, + ) -> list[dict[str, Any]]: """Fetch notifications from GitHub API.""" headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"} params = { @@ -219,35 +258,57 @@ def _get_github_notifications(cls, token: str, only_unread: bool, max_notificati result = [] if notifications: for notification in notifications: - repo_full_name = notification["repository"]["full_name"] - subject_type = notification["subject"]["type"] - subject_url = notification["subject"]["url"] - unread = notification["unread"] - - if subject_type == "Issue": - github_url = subject_url.replace("api.github.com/repos", "github.com") - elif subject_type == "PullRequest": + # Extract nested values once + repository = notification["repository"] + subject = notification["subject"] + repo_full_name = repository["full_name"] + subject_type = subject["type"] + subject_url = subject["url"] + if subject_type == "PullRequest": github_url = subject_url.replace("api.github.com/repos", "github.com").replace( "/pulls/", "/pull/" ) elif subject_type == "Release": github_url = f"https://github.com/{repo_full_name}/releases" - elif subject_type == "Discussion": + elif subject_type in ("Issue", "Discussion"): github_url = subject_url.replace("api.github.com/repos", "github.com") + elif subject_type == "CheckSuite": + # CheckSuite notifications don't provide a direct URL in the API + github_url = f"https://github.com/{repo_full_name}/actions" else: - github_url = notification["repository"]["html_url"] + github_url = repository["html_url"] result.append( { "id": notification["id"], "repository": repo_full_name, - "title": notification["subject"]["title"], + "title": subject["title"], "type": subject_type, "url": github_url, - "unread": unread, + "unread": notification["unread"], + "reason": notification.get("reason", ""), + "comment_count": None, + "updated_at": notification.get("updated_at", ""), + "__subject_api_url": subject_url, } ) + # Filter by reason (set lookup is O(1) vs list lookup O(n)) + if reason_filters: + normalized_filters = {reason.lower() for reason in reason_filters if reason} + if normalized_filters: + result = [item for item in result if item.get("reason", "").lower() in normalized_filters] + + if token: + cls._enrich_notifications( + token, + result, + include_comment_count=show_comment_count, + ) + + for item in result: + item.pop("__subject_api_url", None) + return result except urllib.error.URLError: @@ -259,3 +320,156 @@ def _get_github_notifications(cls, token: str, only_unread: bool, max_notificati except Exception as e: logging.error(f"GitHubDataManager an unexpected error occurred: {str(e)}") return [] + + @classmethod + def _enrich_notifications( + cls, + token: str, + notifications: list[dict[str, Any]], + *, + include_comment_count: bool, + ) -> None: + query_parts: list[str] = [] + alias_map: dict[str, tuple[dict[str, Any], str]] = {} + + for index, notification in enumerate(notifications): + subject_type = notification.get("type") + subject_url = notification.get("__subject_api_url") + if subject_type not in {"Issue", "PullRequest", "Discussion"} or not subject_url: + continue + + parsed = cls._parse_subject_metadata(subject_url) + if not parsed: + continue + + owner, repo, number = parsed + alias = f"n{index}" + + # Build GraphQL fragment based on type + if subject_type == "Issue": + fields = "state comments { totalCount }" if include_comment_count else "state" + fragment = f'{alias}: repository(owner: "{owner}", name: "{repo}") {{ issue(number: {number}) {{ {fields} }} }}' + elif subject_type == "PullRequest": + if include_comment_count: + fields = "state mergedAt isDraft comments { totalCount } reviewThreads { totalCount }" + else: + fields = "state mergedAt isDraft" + fragment = f'{alias}: repository(owner: "{owner}", name: "{repo}") {{ pullRequest(number: {number}) {{ {fields} }} }}' + elif subject_type == "Discussion": + fields = "isAnswered comments { totalCount }" if include_comment_count else "isAnswered" + fragment = f'{alias}: repository(owner: "{owner}", name: "{repo}") {{ discussion(number: {number}) {{ {fields} }} }}' + else: + continue + + query_parts.append(fragment) + alias_map[alias] = (notification, subject_type) + + if not query_parts: + return + + selection = "\n".join(query_parts) + graphql_query = f"query {{\n{selection}}}" + payload = json.dumps({"query": graphql_query}).encode("utf-8") + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + request = urllib.request.Request("https://api.github.com/graphql", data=payload, headers=headers, method="POST") + + try: + with urllib.request.urlopen(request) as response: + data = json.loads(response.read().decode()) + + if data.get("errors"): + logging.warning("GitHubDataManager GraphQL errors: %s", data["errors"]) + return + + result_data = data.get("data", {}) + for alias, (notification, subject_type) in alias_map.items(): + repo_data = result_data.get(alias) + if not repo_data: + continue + + if subject_type == "Issue": + issue_data = repo_data.get("issue") + if not issue_data: + continue + + state_value = issue_data.get("state") + if isinstance(state_value, str): + notification["issue_state"] = state_value.lower() + else: + notification.pop("issue_state", None) + + if include_comment_count and issue_data.get("comments") is not None: + total_count = issue_data["comments"].get("totalCount") + if isinstance(total_count, int): + notification["comment_count"] = total_count + elif subject_type == "PullRequest": + pr_data = repo_data.get("pullRequest") + if not pr_data: + continue + + state_value = pr_data.get("state") + if isinstance(state_value, str): + notification["pull_request_state"] = state_value.lower() + else: + notification.pop("pull_request_state", None) + + notification["pull_request_is_merged"] = bool(pr_data.get("mergedAt")) + + is_draft_value = pr_data.get("isDraft") + if isinstance(is_draft_value, bool): + notification["pull_request_is_draft"] = is_draft_value + else: + notification.pop("pull_request_is_draft", None) + + if include_comment_count: + base_comments = pr_data.get("comments", {}).get("totalCount") + review_threads = pr_data.get("reviewThreads", {}).get("totalCount") + total_comments = 0 + if isinstance(base_comments, int): + total_comments += base_comments + if isinstance(review_threads, int): + total_comments += review_threads + notification["comment_count"] = total_comments + elif subject_type == "Discussion": + discussion_data = repo_data.get("discussion") + if not discussion_data: + continue + + is_answered_value = discussion_data.get("isAnswered") + if isinstance(is_answered_value, bool): + notification["discussion_is_answered"] = is_answered_value + else: + notification.pop("discussion_is_answered", None) + + if include_comment_count and discussion_data.get("comments") is not None: + total_count = discussion_data["comments"].get("totalCount") + if isinstance(total_count, int): + notification["comment_count"] = total_count + except urllib.error.HTTPError as exc: + logging.error( + "GitHubDataManager GraphQL HTTP error: %s - %s", getattr(exc, "code", "?"), getattr(exc, "reason", "") + ) + except urllib.error.URLError: + logging.error("GitHubDataManager no internet connection. Unable to enrich notifications via GraphQL.") + except Exception as exc: + logging.error("GitHubDataManager unexpected error enriching notifications: %s", exc) + + @staticmethod + def _parse_subject_metadata(subject_url: str) -> tuple[str, str, int] | None: + try: + cleaned_url = subject_url.rstrip("/") + parts = cleaned_url.split("/") + if len(parts) < 8: + return None + owner = parts[4] + repo = parts[5] + identifier = parts[-1] + number = int(identifier.split("?")[0]) + return owner, repo, number + except (IndexError, ValueError): + return None diff --git a/src/core/validation/widgets/yasb/github.py b/src/core/validation/widgets/yasb/github.py index 49ade3e08..9a8c3ed92 100644 --- a/src/core/validation/widgets/yasb/github.py +++ b/src/core/validation/widgets/yasb/github.py @@ -6,7 +6,9 @@ "tooltip": True, "max_notification": 30, "only_unread": False, + "show_comment_count": False, "max_field_size": 100, + "reason_filters": [], "notification_dot": { "enabled": True, "corner": "bottom_left", @@ -23,15 +25,29 @@ "distance": 6, # deprecated "offset_top": 6, "offset_left": 0, + "show_categories": False, + "categories_order": [ + "PullRequest", + "Issue", + "CheckSuite", + "Release", + "Discussion", + ], }, "icons": { "issue": "\uf41b", + "issue_closed": "\uf41d", "pull_request": "\uea64", + "pull_request_closed": "\uebda", + "pull_request_merged": "\uf17f", + "pull_request_draft": "\uebdb", "release": "\uea84", "discussion": "\uf442", + "discussion_answered": "\uf4c0", "checksuite": "\uf418", "default": "\uea84", "github_logo": "\uea84", + "comment": "\uf41f", }, "animation": {"enabled": True, "type": "fadeInOut", "duration": 200}, "container_padding": {"top": 0, "left": 0, "bottom": 0, "right": 0}, @@ -66,7 +82,13 @@ "default": DEFAULTS["notification_dot"], }, "only_unread": {"type": "boolean", "default": DEFAULTS["only_unread"]}, + "show_comment_count": {"type": "boolean", "default": DEFAULTS["show_comment_count"]}, "max_field_size": {"type": "integer", "default": DEFAULTS["max_field_size"]}, + "reason_filters": { + "type": "list", + "schema": {"type": "string"}, + "default": DEFAULTS["reason_filters"], + }, "menu": { "type": "dict", "required": False, @@ -80,6 +102,12 @@ "distance": {"type": "integer", "default": DEFAULTS["menu"]["distance"]}, "offset_top": {"type": "integer", "default": DEFAULTS["menu"]["offset_top"]}, "offset_left": {"type": "integer", "default": DEFAULTS["menu"]["offset_left"]}, + "show_categories": {"type": "boolean", "default": DEFAULTS["menu"]["show_categories"]}, + "categories_order": { + "type": "list", + "schema": {"type": "string"}, + "default": DEFAULTS["menu"]["categories_order"], + }, }, "default": DEFAULTS["menu"], }, @@ -88,12 +116,18 @@ "required": False, "schema": { "issue": {"type": "string", "default": DEFAULTS["icons"]["issue"]}, + "issue_closed": {"type": "string", "default": DEFAULTS["icons"]["issue_closed"]}, "pull_request": {"type": "string", "default": DEFAULTS["icons"]["pull_request"]}, + "pull_request_closed": {"type": "string", "default": DEFAULTS["icons"]["pull_request_closed"]}, + "pull_request_merged": {"type": "string", "default": DEFAULTS["icons"]["pull_request_merged"]}, + "pull_request_draft": {"type": "string", "default": DEFAULTS["icons"]["pull_request_draft"]}, "release": {"type": "string", "default": DEFAULTS["icons"]["release"]}, "discussion": {"type": "string", "default": DEFAULTS["icons"]["discussion"]}, + "discussion_answered": {"type": "string", "default": DEFAULTS["icons"]["discussion_answered"]}, "checksuite": {"type": "string", "default": DEFAULTS["icons"]["checksuite"]}, "default": {"type": "string", "default": DEFAULTS["icons"]["default"]}, "github_logo": {"type": "string", "default": DEFAULTS["icons"]["github_logo"]}, + "comment": {"type": "string", "default": DEFAULTS["icons"]["comment"]}, }, "default": DEFAULTS["icons"], }, diff --git a/src/core/widgets/yasb/github.py b/src/core/widgets/yasb/github.py index 2a73db1e1..7d794af92 100644 --- a/src/core/widgets/yasb/github.py +++ b/src/core/widgets/yasb/github.py @@ -8,7 +8,7 @@ from PyQt6.QtWidgets import QFrame, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QScrollArea, QVBoxLayout, QWidget from core.utils.tooltip import set_tooltip -from core.utils.utilities import PopupWidget, add_shadow, refresh_widget_style +from core.utils.utilities import PopupWidget, add_shadow, get_relative_time, refresh_widget_style from core.utils.widgets.animation_manager import AnimationManager from core.utils.widgets.github.api import GitHubDataManager from core.validation.widgets.yasb.github import VALIDATION_SCHEMA @@ -97,6 +97,8 @@ def __init__( max_notification: int, notification_dot: dict[str, Any], only_unread: bool, + reason_filters: list[str] | None, + show_comment_count: bool, max_field_size: int, menu: dict[str, str], icons: dict[str, str], @@ -116,22 +118,30 @@ def __init__( self._icons = icons self._max_notification = max_notification self._only_unread = only_unread + self._reason_filters = [str(reason) for reason in (reason_filters or []) if str(reason).strip()] + self._show_comment_count = show_comment_count self._max_field_size = max_field_size self._update_interval = update_interval self._animation = animation self._padding = container_padding self._label_shadow = label_shadow self._container_shadow = container_shadow + self._show_categories = self._menu_popup.get("show_categories", True) + categories_order = self._menu_popup.get("categories_order", []) + if isinstance(categories_order, list): + self._categories_order = [str(category) for category in categories_order] + else: + self._categories_order = [] self._notification_label: NotificationLabel | None = None self._notification_label_alt: NotificationLabel | None = None self._notification_dot: dict[str, Any] = notification_dot + self._shared_cursor = QCursor(Qt.CursorShape.PointingHandCursor) + self._widget_container_layout = QHBoxLayout() self._widget_container_layout.setSpacing(0) - self._widget_container_layout.setContentsMargins( - self._padding["left"], self._padding["top"], self._padding["right"], self._padding["bottom"] - ) + self._widget_container_layout.setContentsMargins(0, 0, 0, 0) self._widget_container = QFrame() self._widget_container.setLayout(self._widget_container_layout) @@ -155,6 +165,8 @@ def __init__( only_unread=self._only_unread, max_notification=self._max_notification, update_interval=self._update_interval, + reason_filters=self._reason_filters, + show_comment_count=self._show_comment_count, ) def _toggle_menu(self): @@ -284,6 +296,164 @@ def mouse_press_event(event): return mouse_press_event + def _format_category_title(self, category_type: str) -> str: + """Return a human-friendly label for a GitHub notification type.""" + custom_titles = { + "Issue": "Issues", + "PullRequest": "Pull Requests", + "Release": "Releases", + "Discussion": "Discussions", + "CheckSuite": "Check Suites", + } + if category_type in custom_titles: + return custom_titles[category_type] + spaced = re.sub(r"(? tuple[str, list[str]]: + """Return icon and state class list for a notification.""" + notification_type = notification.get("type", "") + state_classes: list[str] = [] + + if notification_type == "Issue": + issue_state = (notification.get("issue_state") or "").lower() + if issue_state == "closed": + icon_type = self._icons["issue_closed"] + else: + icon_type = self._icons["issue"] + if issue_state: + state_classes.append(issue_state) + elif notification_type == "PullRequest": + pr_state = (notification.get("pull_request_state") or "").lower() + pr_is_merged = bool(notification.get("pull_request_is_merged")) + pr_is_draft = bool(notification.get("pull_request_is_draft")) + + if pr_is_merged: + icon_type = self._icons["pull_request_merged"] + state_classes.append("merged") + elif pr_state == "closed": + icon_type = self._icons["pull_request_closed"] + elif pr_is_draft: + icon_type = self._icons["pull_request_draft"] + state_classes.append("draft") + else: + icon_type = self._icons["pull_request"] + + if pr_state: + state_classes.append(pr_state) + elif notification_type == "Discussion": + discussion_answered = bool(notification.get("discussion_is_answered")) + if discussion_answered: + icon_type = self._icons["discussion_answered"] + state_classes.append("answered") + else: + icon_type = self._icons["discussion"] + elif notification_type == "Release": + icon_type = self._icons["release"] + elif notification_type == "CheckSuite": + icon_type = self._icons["checksuite"] + else: + icon_type = self._icons["default"] + + return icon_type, state_classes + + def _create_notification_item( + self, + notification: dict[str, Any], + extra_classes: list[str] | None = None, + parent: QWidget | None = None, + ) -> QFrame: + title = notification["title"] + if len(title) > self._max_field_size: + title = title[: self._max_field_size - 3] + "..." + + if self._show_categories: + repo_description = notification["repository"] + else: + repo_description = f"{notification['type']} • {notification['repository']}" + + updated_at = notification.get("updated_at", "") + relative_time = get_relative_time(updated_at) + if relative_time: + repo_description = f"{repo_description} • Updated {relative_time}" + + if len(repo_description) > self._max_field_size: + repo_description = repo_description[: self._max_field_size - 3] + "..." + + notification_type = notification.get("type", "") + icon_type, state_classes = self._resolve_icon_and_states(notification) + base_class = notification_type.lower() if notification_type else "" + + classes = ["item"] + if notification.get("unread"): + classes.append("new") + + if base_class: + classes.append(base_class) + if extra_classes: + classes.extend(extra_classes) + + comment_count_value = notification.get("comment_count") + + container = QFrame(parent) + container.setProperty("class", " ".join(dict.fromkeys(classes))) + container.setContentsMargins(0, 0, 0, 0) + container.setCursor(self._shared_cursor) + + icon_label = QLabel(icon_type) + icon_classes = ["icon", base_class] if base_class else ["icon"] + icon_classes.extend(state_classes) + icon_label.setProperty("class", " ".join(dict.fromkeys(icon_classes))) + + title_label = QLabel(title) + title_label.setProperty("class", "title") + title_label.setContentsMargins(0, 0, 0, 0) + + description_label = QLabel(repo_description) + description_label.setProperty("class", "description") + description_label.setContentsMargins(0, 0, 0, 0) + + text_content = QWidget() + text_content_layout = QVBoxLayout(text_content) + text_content_layout.addWidget(title_label) + text_content_layout.addWidget(description_label) + text_content_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + text_content_layout.setContentsMargins(0, 0, 0, 0) + text_content_layout.setSpacing(0) + + container_layout = QHBoxLayout(container) + container_layout.addWidget(icon_label) + container_layout.addWidget(text_content, 1) + + if self._show_comment_count and isinstance(comment_count_value, int): + comment_wrapper = QWidget() + comment_wrapper_layout = QHBoxLayout(comment_wrapper) + comment_wrapper_layout.setContentsMargins(8, 0, 0, 0) + comment_wrapper_layout.setSpacing(0) + + comment_icon_text = (self._icons.get("comment", "") or "").strip() + if comment_icon_text: + comment_icon_label = QLabel(comment_icon_text) + comment_icon_label.setProperty("class", "comment-icon") + comment_icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + comment_wrapper_layout.addWidget(comment_icon_label) + + comment_value_label = QLabel(str(comment_count_value)) + comment_value_label.setProperty("class", "comment-count") + comment_value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + comment_wrapper_layout.addWidget(comment_value_label) + + container_layout.addWidget(comment_wrapper) + container_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(0) + + container.mousePressEvent = self._create_container_mouse_press_event( + notification["id"], notification["url"], container + ) + + return container + def show_menu(self): github_data = GitHubDataManager.get_data() notifications_count = len(github_data) @@ -328,67 +498,80 @@ def show_menu(self): scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop) scroll_layout.setContentsMargins(0, 0, 0, 0) scroll_layout.setSpacing(0) - scroll_area.setWidget(scroll_widget) + # Build the content first before attaching to scroll area if notifications_count > 0: - for notification in github_data: - repo_title = notification["title"] - repo_description = f"{notification['type']}: {notification['repository']}" - repo_title = ( - (notification["title"][: self._max_field_size - 3] + "...") - if len(notification["title"]) > self._max_field_size - else notification["title"] - ) - repo_description = ( - (repo_description[: self._max_field_size - 3] + "...") - if len(repo_description) > self._max_field_size - else repo_description - ) - - icon_type = { - "Issue": self._icons["issue"], - "PullRequest": self._icons["pull_request"], - "Release": self._icons["release"], - "Discussion": self._icons["discussion"], - "CheckSuite": self._icons["checksuite"], - }.get(notification["type"], self._icons["default"]) - - new_item_class = "new" if notification["unread"] else "" - - container = QWidget() - container.setProperty("class", f"item {new_item_class}") - container.setContentsMargins(0, 0, 8, 0) - container.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) - - icon_label = QLabel(f"{icon_type}") - type_class = notification["type"] if "type" in notification else "" - icon_label.setProperty("class", f"icon {type_class.lower()}") - title_label = QLabel(repo_title) - title_label.setProperty("class", "title") - - description_label = QLabel(repo_description) - description_label.setProperty("class", "description") - - text_content = QWidget() - text_content_layout = QVBoxLayout(text_content) - text_content_layout.addWidget(title_label) - text_content_layout.addWidget(description_label) - text_content_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - text_content_layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) - text_content_layout.setContentsMargins(0, 0, 0, 0) - text_content_layout.setSpacing(0) - - container_layout = QHBoxLayout(container) - container_layout.addWidget(icon_label) - container_layout.addWidget(text_content, 1) - container_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) - container_layout.setContentsMargins(0, 0, 0, 0) - container_layout.setSpacing(0) - scroll_layout.addWidget(container) - - container.mousePressEvent = self._create_container_mouse_press_event( - notification["id"], notification["url"], container - ) + if self._show_categories: + grouped_notifications: dict[str, list[dict[str, Any]]] = {} + for notification in github_data: + grouped_notifications.setdefault(notification["type"], []).append(notification) + + category_lookup = {key.lower(): key for key in grouped_notifications} + + ordered_categories: list[str] = [] + for configured_category in self._categories_order: + actual_key = category_lookup.get(configured_category.lower()) + if actual_key and actual_key not in ordered_categories: + ordered_categories.append(actual_key) + + # Add remaining categories not in configured order + for category in grouped_notifications: + if category not in ordered_categories: + ordered_categories.append(category) + + for category_type in ordered_categories: + items = grouped_notifications[category_type] + + section_header = QLabel(self._format_category_title(category_type)) + section_header.setProperty("class", "section-header") + section_header.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + scroll_layout.addWidget(section_header) + + section_widget = QFrame() + section_widget.setProperty("class", "section") + + section_layout = QVBoxLayout(section_widget) + section_layout.setContentsMargins(0, 0, 0, 0) + section_layout.setSpacing(0) + + items_count = len(items) + for index, notification in enumerate(items): + position_classes: list[str] = [] + if index == 0: + position_classes.append("first") + if index == items_count - 1: + position_classes.append("last") + container = self._create_notification_item( + notification, + position_classes, + parent=section_widget, + ) + section_layout.addWidget(container) + + scroll_layout.addWidget(section_widget) + else: + section_widget = QFrame() + section_widget.setProperty("class", "section") + + section_layout = QVBoxLayout(section_widget) + section_layout.setContentsMargins(0, 0, 0, 0) + section_layout.setSpacing(0) + + notifications_count_total = len(github_data) + for index, notification in enumerate(github_data): + position_classes: list[str] = [] + if index == 0: + position_classes.append("first") + if index == notifications_count_total - 1: + position_classes.append("last") + container = self._create_notification_item( + notification, + position_classes, + parent=section_widget, + ) + section_layout.addWidget(container) + + scroll_layout.addWidget(section_widget) else: large_label = QLabel(self._icons["github_logo"]) large_label.setStyleSheet("font-size:88px;font-weight:400") @@ -411,6 +594,10 @@ def show_menu(self): # Add the center layout to the scroll layout scroll_layout.addLayout(center_layout) + + # Attach the fully-built widget to scroll area + scroll_area.setWidget(scroll_widget) + if notifications_count > 0: # Create footer container footer_container = QFrame()