Back to Gh Dash

Notifications Feature — Implementation & Design Summary

internal/tui/components/notificationssection/DESIGN.md

4.23.222.4 KB
Original Source

Notifications Feature — Implementation & Design Summary

Overview

The notifications feature adds a new view to gh-dash that displays GitHub notifications, allowing users to triage their inbox directly from the terminal. The implementation follows the existing patterns established by the PR and Issue views.

Architecture

File Structure

internal/
├── data/
│   ├── notificationapi.go       # GitHub API interactions for notifications
│   ├── bookmarks.go             # Local bookmark storage (singleton)
│   ├── donestore.go             # Timestamp-based Done tracking (singleton)
│   ├── donestore_test.go        # Tests for Done store
│   └── donestore_testing.go     # Test helpers (create/override DoneStore)
├── tui/
│   ├── keys/
│   │   └── notificationKeys.go  # Key bindings specific to notifications
│   └── components/
│       ├── notificationrow/
│       │   ├── data.go          # Data model implementing RowData interface
│       │   ├── data_test.go     # Tests for data accessors
│       │   ├── notificationrow.go # Row rendering for the table
│       │   └── notificationrow_test.go # Tests for rendering logic
│       ├── notificationssection/
│       │   ├── notificationssection.go # Main section component
│       │   ├── commands.go      # Tea commands (mark done, mark read, diff, checkout, etc.)
│       │   ├── commands_test.go # Tests for command functions
│       │   └── filters_test.go  # Tests for filter parsing
│       └── notificationview/
│           └── notificationview.go # Detail view in sidebar

Key Design Decisions

1. Explicit View Action for Notifications

Unlike PRs and Issues which auto-fetch content when selected, notifications require an explicit action (Enter key) to view content. This design choice exists because:

  • Viewing a notification marks it as read (GitHub API behavior)
  • Users should consciously decide when to mark something as read
  • Prevents accidental "read" marking when just browsing the list

When a notification is selected but not yet viewed, a prompt is displayed in the Preview pane:

      Press [Enter] to view the PR
      (Note: this will mark it as read)

      Other Actions

            [D]  mark as done
            [m]  mark as read
            [u]  unsubscribe
            [b]  toggle bookmark
            [t]  toggle filtering
            [S]  sort by repo
            [o]  open in browser
        [Enter]  view
  • Keys are displayed with a background highlight
  • Actions are displayed in green (success color)
  • The note about marking as read appears for all notification types
  • For PR/Issue types: "Press Enter to view the PR/Issue"
  • For other notification types (Discussion, Release, etc.): "Press Enter to open in browser"

2. Notification Data Flow

GitHub REST API
      │
      ▼
notificationapi.go (FetchNotifications)
      │
      ▼
notificationssection.go (stores []notificationrow.Data)
      │
      ├──▶ notificationrow.go (renders table rows)
      │
      └──▶ notificationview.go (renders sidebar detail)
           OR
           renderNotificationPrompt() (shows action prompt)

3. Comment Count Tracking

For PR and Issue notifications, the system fetches additional data to show new comment counts:

  • Compares LastReadAt timestamp with comment timestamps
  • Displays count of comments made since user last read the notification
  • Scrolls to the latest comment when viewing PR/Issue notifications

4. Actor Display

For PR and Issue notifications, the username of the person who triggered the notification is displayed:

  • Fetches the author from latest_comment_url (the comment that triggered the notification)
  • Falls back to the PR/Issue author for new items without comments
  • Helps identify spam without needing to open the notification
  • Fetched alongside comment counts (no additional latency)
  • Color is configurable via theme.colors.text.actor (defaults to secondary text color)

5. Three-Line Row Layout

Each notification row displays three lines of information:

repo/name #123 🔖                        +5💬  2d ago
Title of the notification (bold if unread)
@username commented on this pull request

(The 🔖 bookmark icon only appears if the notification is bookmarked)

  • Line 1: Repository name with issue/PR number, bookmark icon if bookmarked (SecondaryText color; bookmark icon in WarningText color)
  • Line 2: Notification title (PrimaryText, bold for unread notifications)
  • Line 3: Activity description (FaintText color, generated from reason, type, and actor)

The three lines use distinct colors to create visual hierarchy: line 1 is secondary, line 2 is primary/bold, and line 3 is faint.

Unread indicator: A blue dot is displayed below the notification type icon for unread notifications. This is the sole indicator of read/unread status—text is not dimmed for read notifications.

Activity descriptions are generated based on the notification reason:

  • comment: "@username commented on this pull request/issue"
  • review_requested: "@username requested your review" or "Review requested"
  • mention: "@username mentioned you"
  • author: "Activity on your thread"
  • assign: "You were assigned"
  • state_change: "Pull request/Issue state changed"
  • ci_activity: "CI activity"
  • subscribed: "@username commented on this pull request/issue"

6. Title Sanitization

Notification titles from GitHub's API may contain control characters (e.g., trailing \r) that corrupt terminal rendering. The GetTitle() method sanitizes titles by:

  • Removing carriage return characters (\r)
  • Replacing newlines (\n) with spaces
  • Trimming leading/trailing whitespace

7. Multi-Line Row Background

The table component applies cell styling to each line individually in multi-line content. This ensures the background color (for selected rows) extends properly across the entire cell.

To preserve parent background colors, row content uses raw ANSI escape codes for foreground styling without trailing reset sequences. The utils.GetStylePrefix() helper extracts ANSI codes from lipgloss styles while stripping the reset, preventing internal resets from breaking the cell's background color.

Title truncation is handled dynamically by the table component based on actual column width, with ellipsis added when content is truncated. This allows titles to adjust when the sidebar is shown/hidden.

8. Bookmark and Done Systems

Both bookmarks and Done status are tracked locally because GitHub’s API doesn’t provide these features.

Bookmarks use the generic NotificationIDStore type in data/bookmarks.go:

go
type NotificationIDStore struct {
    ids      map[string]bool
    filePath string
    // ... mutex, name for logging
}
  • Stored in ~/.local/state/gh-dash/bookmarks.json as a JSON array of IDs
  • Accessed via data.GetBookmarkStore() singleton
  • Bookmarked notifications appear in the default inbox view even when read
  • Bookmarked notifications are styled as read (faint text) but show a bookmark indicator
  • When user explicitly searches is:unread, bookmarked+read items are excluded

Done tracking uses its own DoneStore type in data/donestore.go, separate from the bookmark store. This is necessary because GitHub’s “mark as done” API (DELETE /notifications/threads/{id}) doesn’t actually delete notifications — they still appear in API responses with all=true. Without local tracking, Done notifications would reappear when filtering to is:read or is:all.

The DoneStore records timestamps, not just IDs:

go
type DoneStore struct {
    entries  map[string]time.Time // id -> updatedAt when marked done
    filePath string
    // ... mutex
}

When marking a notification as Done, the store records the notification’s current updated_at timestamp. When checking whether a notification is still Done, IsDone(id, updatedAt) compares the stored timestamp against the notification’s current updated_at: if the notification has been updated since it was Done (new comments, state changes, etc.), it resurfaces automatically. This prevents notifications with new activity from being permanently hidden.

  • Stored in ~/.local/state/gh-dash/done.json as a JSON object mapping IDs to RFC 3339 timestamps: {"id": "2024-01-15T10:30:00Z", ...}
  • Accessed via data.GetDoneStore() singleton
  • Backward-compatible: loads the legacy format (plain JSON array of IDs) used by older versions; legacy entries are assigned the zero time and pruned on load
  • Persists across sessions and application restarts

Pruning: On load, the DoneStore removes stale entries, to prevent the file from growing indefinitely. Entries older than 90 days are pruned — because those are unlikely to still appear in API responses. Zero-time entries (from the legacy format) are also pruned, since removing them from the store has the same effect as keeping them: IsDone returns false either way, so active notifications still resurface.

Pagination with local filtering: Because Done notifications are filtered out locally after fetching from the API, a single page of results may yield very few visible notifications. To handle this, the fetch logic automatically requests additional pages from the API until the requested limit is reached or all pages are exhausted. This ensures users see a full page of results even when many notifications have been marked as Done.

9. Unsubscribe

The unsubscribe feature allows users to stop receiving notifications for a thread:

  • Uses GitHub's DELETE /notifications/threads/{id}/subscription API
  • Removes the subscription without marking the notification as Done
  • Useful for threads that are no longer relevant but shouldn't be deleted

10. State Management

Notification state (read/unread, done) is tracked both:

  • Locally in the notificationrow.Data struct for immediate UI updates
  • Remotely via GitHub API calls for persistence

The UpdateNotificationMsg and UpdateNotificationReadStateMsg types propagate state changes through the Bubble Tea update cycle.

Session persistence for read notifications: When a notification is marked as read (via m key or by viewing it), its ID is tracked in sessionMarkedRead. These notifications remain visible in the inbox even during automatic refreshes (e.g., when the terminal regains focus). They are only removed when:

  • The user performs a manual refresh (Refresh key)
  • The user quits and restarts the application
  • The notification is explicitly marked as Done

Session persistence for Done notifications: When a notification is marked as Done, its ID is tracked in sessionMarkedDone in addition to being persisted to the DoneStore. The session map provides immediate filtering without waiting for the async DoneStore save to complete. Like sessionMarkedRead, it is cleared on manual refresh.

12. Command Architecture

Notification commands are organized in commands.go, following the pattern established by prssection (which has checkout.go, diff.go). Commands fall into two categories:

Section methods — Commands that operate on section state and are invoked via key handling in the section's Update method:

  • markAsDone() — Marks the current notification as Done (persists ID + updated_at timestamp to DoneStore). Important: This captures updatedAt by value before the closure — because GetCurrNotification() returns a pointer into the Notifications slice, which may shift when other notifications are removed concurrently.
  • markAllAsDone() — Marks all visible notifications as Done (persists each ID + updated_at to DoneStore)
  • markAsRead() — Marks the current notification as read
  • markAllAsRead() — Marks all notifications as read
  • unsubscribe() — Unsubscribes from the current thread
  • openInBrowser() — Marks as read and opens in browser

Standalone functions — Commands that require data from outside the section (e.g., the PR shown in the sidebar). These are called from ui.go with the necessary parameters:

  • DiffPR(ctx, prNumber, repoName) — Opens a diff view for a PR
  • CheckoutPR(ctx, prNumber, repoName) — Checks out a PR branch locally

This split exists because diff and checkout operate on the PR/Issue content shown in the notificationView sidebar, not the notification row itself. When viewing a PR notification, the sidebar displays the full PR details, and diff/checkout actions use that data. The section doesn't have access to this enriched data, so ui.go extracts the PR details from notificationView and passes them to the standalone functions.

Key events are routed through ui.go, which either:

  1. Passes them to the section via updateSection() for section methods
  2. Handles them directly and calls standalone functions with the required data

Interface Compliance

notificationrow.Data implements the data.RowData interface:

go
type RowData interface {
    GetRepoNameWithOwner() string
    GetNumber() int
    GetTitle() string
    GetUrl() string
}
  • GetNumber() extracts the PR/Issue number from the subject URL
  • GetUrl() constructs the GitHub web URL from the API URL

Styling Consistency

Common styling functions were extracted to common/styles.go:

  • RenderPreviewHeader() — renders the repo/type header line with background
  • RenderPreviewTitle() — renders the title block with background highlight

These are used by PR view, Issue view, notification view, and notification prompt for consistent appearance.

Table Column Alignment

The table component was extended to support per-column alignment via an Align property on the Column struct. This allows the comment count column to be right-aligned.

Key Bindings

KeyAction
DMark as done (removes from inbox)
Alt+dMark all as done
mMark as read
MMark all as read
uUnsubscribe from thread
bToggle bookmark
tToggle smart filtering (filter to current repo)
yCopy PR/Issue number
YCopy URL
SSort by repository
sSwitch to PRs view
oOpen in browser
EnterView notification (fetches content, marks as read)

PR/Issue Keybindings in Notifications View

When viewing a PR notification in the preview pane, all PR-specific keybindings become available:

KeyAction
vApprove PR
aAssign
AUnassign
cComment
dView diff
C/SpaceCheckout branch
xClose PR
XReopen PR
WMark ready for review
mMerge PR
uUpdate from base branch
wWatch checks
[Previous sidebar tab
]Next sidebar tab
eExpand description

Similarly, when viewing an Issue notification, Issue-specific keybindings are available:

KeyAction
LAdd/remove labels
aAssign
AUnassign
cComment
xClose issue
XReopen issue

The ? help display dynamically updates to show the applicable keybindings based on what type of notification content is being viewed.

Confirmation Prompts for Destructive Actions

When viewing a PR or Issue notification, destructive actions (close, reopen, merge, etc.) require confirmation before execution. This uses a footer-based confirmation mechanism separate from the section-level confirmation used in PR/Issue views:

  1. User presses action key (e.g., x for close)
  2. Footer displays: "Are you sure you want to close PR #123? (y/N)"
  3. User presses y, Y, or Enter to confirm, any other key cancels
  4. Action executes via the tasks package (same as PR/Issue views)

This design is necessary because:

  • The notification section doesn't understand PR/Issue-specific actions
  • PR/Issue data is stored in notificationView, not in the section
  • Actions operate on the notification's subject PR/Issue, not the notification itself

The confirmation state is managed by notificationView.Model:

  • pendingAction field tracks the pending action (e.g., "pr_close", "issue_reopen")
  • SetPendingPRAction() / SetPendingIssueAction() set the pending action and return the confirmation prompt text
  • Update() method handles confirmation key presses (y/Y/Enter to confirm, any other key cancels)
  • onConfirmAction callback is invoked when confirmed, which ui.go sets to executeNotificationAction()

This encapsulation keeps confirmation logic close to the view that displays it, while ui.go coordinates between the footer prompt and action execution.

Custom Keybindings in Notifications View

User-defined keybindings (configured under keybindings.prs and keybindings.issues in config.yml) are also supported in the Notifications view. When a notification’s subject is a PR or Issue, the corresponding custom keybindings are recognized and dispatched.

The command template receives different fields depending on whether the sidebar has been opened:

StatePR template fields
Sidebar not openRepoName, PrNumber, RepoPath
Sidebar open+ HeadRefName, BaseRefName, Author
StateIssue template fields
Sidebar not openRepoName, IssueNumber, RepoPath
Sidebar open+ Author

If a template references a sidebar-only field (e.g., {{.HeadRefName}}) before the sidebar is opened, the template engine’s missingkey=error option produces an error message. This is intentional — users should open the notification first to populate the full data.

The implementation in modelUtils.go checks notificationView.GetSubjectPR() / GetSubjectIssue() and enriches the template fields map when the subject data is available. This avoids an extra API call — the sidebar fetch that already happened is reused.

Configuration

Search Section

Like PRs and Issues, the Notifications view includes a search section (indicated by a magnifying glass icon 🔍) as the first tab. This serves as a scratchpad for one-off searches without modifying your configured sections.

  • Default filter: archived:false
  • Respects smartFilteringAtLaunch: when enabled and running from a git repository, the search automatically scopes to that repo
  • Use the / key to focus the search bar and enter custom queries
  • Supports all notification filters: is:unread, is:read, repo:owner/name, reason:*

Notification Sections

Notifications support multiple configurable sections, similar to PRs and Issues. Each section appears as a tab and filters notifications by reason:

yaml
notificationsSections:
  - title: All
    filters: ""
  - title: Created
    filters: "reason:author"
  - title: Participating
    filters: "reason:participating"
  - title: Mentioned
    filters: "reason:mention"
  - title: Review Requested
    filters: "reason:review-requested"
  - title: Assigned
    filters: "reason:assign"
  - title: Subscribed
    filters: "reason:subscribed"
  - title: Team Mentioned
    filters: "reason:team-mention"

These are the default sections. Users can customize by defining their own notificationsSections in config.yml.

Reason Filters

The reason: filter matches GitHub's notification reason field:

FilterDescription
reason:authorActivity on threads you created
reason:commentSomeone commented on a thread you're subscribed to
reason:mentionYou were @mentioned
reason:review-requestedYour review was requested on a PR
reason:assignYou were assigned
reason:subscribedActivity on threads you're watching
reason:team-mentionYour team was @mentioned
reason:state-changeThread state changed (merged, closed, etc.)
reason:ci-activityCI workflow activity
reason:participatingMeta-filter: expands to author, comment, mention, review-requested, assign, state-change

Reason filters are applied client-side after fetching from GitHub's API.

Fetch Limit

The initial fetch limit is controlled by defaults.notificationsLimit (default: 20, matching PRs and Issues). Additional notifications are fetched automatically as the user scrolls through the list.

yaml
defaults:
  notificationsLimit: 20

Smart Filtering

Notifications respect the global smartFilteringAtLaunch setting (enabled by default). When enabled and running from within a git repository, notifications are automatically scoped to that repository. The search bar displays repo:owner/name to indicate this filtering.

Users can:

  • Press t to toggle filtering on/off for the current session
  • Manually edit the search bar to remove or replace the repo: filter; submitting with Enter syncs the smart filter state to match
  • Set smartFilteringAtLaunch: false in config to disable this behavior globally

11. CheckSuite URL Resolution

GitHub's API returns subject.url=null for CheckSuite notifications, making it impossible to directly link to the specific workflow run. To work around this:

  1. Initially, CheckSuite notifications link to the repository's /actions page as a fallback
  2. Asynchronously, we fetch recent workflow runs from /repos/{owner}/{repo}/actions/runs
  3. We find the workflow run closest in time to the notification's updated_at timestamp
  4. Once resolved, the notification's URL is updated to point to the specific workflow run

This async resolution uses the existing UpdateNotificationUrlMsg message type, following the same pattern as async comment count fetching for PRs and Issues.

Limitations

  • Mark as Unread: GitHub's REST API does not support marking notifications as unread, so this feature is not available. Bookmarks provide a workaround by keeping items visible in the inbox.
  • Discussion/Release Content: Only PR and Issue notifications can display detailed content in the sidebar; other types open directly in the browser.
  • Local State Persistence: Bookmarks and Done status are stored locally (~/.local/state/gh-dash/) and are not synced across machines or with GitHub.
  • Done Notifications in API: GitHub’s “mark as Done” doesn’t delete notifications — they still appear in API responses with all=true. We track Done IDs with timestamps locally to filter them out and detect new activity. Entries older than 90 days are pruned on startup.
  • Server-Side Reason Filtering: GitHub's notification API does not support filtering by reason on the server side. Reason filters are applied client-side after fetching notifications, which means all notifications are fetched before filtering.