Skip to content

Commit 06e9bbf

Browse files
feat: add GitHub notifications dashboard
This change adds a new Notifications view to dash that displays GH notifications — allowing you to triage your GH “inbox” within dash. - View unread notifications with type icons (PR, Issue, Discussion, Release) - Mark notifications as done (d) or read (m) - Mark all notifications as done (D) or read (M) - Unsubscribe from notification threads (u) - Bookmark notifications to keep them visible after marking read (b) - Open notifications in browser (o) or view in sidebar (Enter) - Sort by repository (S) - Filter by repo:owner/name and is:unread/read/all/done - New-comment count indicator for PR/Issue notifications - Auto-scroll to latest comment when viewing Bookmark system: - Local storage in ~/.config/gh-dash/bookmarks.json - Bookmarked items appear in default view even when read - Explicit "is:unread" search excludes bookmarked+read items The implementation follows existing patterns from PR and Issue views, but in this case with requiring you to explicitly/intentionally initiate the “view” action — to prevent accidental “read” marking.
1 parent 9513616 commit 06e9bbf

File tree

24 files changed

+3467
-57
lines changed

24 files changed

+3467
-57
lines changed

internal/config/parser.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ func (a *ViewType) UnmarshalJSON(b []byte) error {
5757
return err
5858
}
5959
switch strings.ToLower(s) {
60+
case "notifications":
61+
*a = NotificationsView
6062
case "prs":
6163
*a = PRsView
6264
case "issues":
@@ -69,9 +71,10 @@ func (a *ViewType) UnmarshalJSON(b []byte) error {
6971
}
7072

7173
const (
72-
PRsView ViewType = "prs"
73-
IssuesView ViewType = "issues"
74-
RepoView ViewType = "repo"
74+
NotificationsView ViewType = "notifications"
75+
PRsView ViewType = "prs"
76+
IssuesView ViewType = "issues"
77+
RepoView ViewType = "repo"
7578
)
7679

7780
type SectionConfig struct {

internal/data/bookmarks.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package data
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"sync"
8+
9+
"github.com/charmbracelet/log"
10+
)
11+
12+
const (
13+
bookmarksFileName = "bookmarks.json"
14+
defaultXDGConfigDir = ".config"
15+
dashDir = "gh-dash"
16+
)
17+
18+
// BookmarkStore manages locally stored notification bookmarks
19+
type BookmarkStore struct {
20+
mu sync.RWMutex
21+
bookmarks map[string]bool // notification ID -> bookmarked
22+
filePath string
23+
}
24+
25+
var (
26+
bookmarkStore *BookmarkStore
27+
bookmarkStoreOnce sync.Once
28+
)
29+
30+
// GetBookmarkStore returns the singleton bookmark store instance
31+
func GetBookmarkStore() *BookmarkStore {
32+
bookmarkStoreOnce.Do(func() {
33+
store := &BookmarkStore{
34+
bookmarks: make(map[string]bool),
35+
}
36+
store.filePath = store.getBookmarksFilePath()
37+
store.load()
38+
bookmarkStore = store
39+
})
40+
return bookmarkStore
41+
}
42+
43+
func (s *BookmarkStore) getBookmarksFilePath() string {
44+
configDir := os.Getenv("XDG_CONFIG_HOME")
45+
if configDir == "" {
46+
homeDir, err := os.UserHomeDir()
47+
if err != nil {
48+
log.Error("Failed to get home directory", "err", err)
49+
return ""
50+
}
51+
configDir = filepath.Join(homeDir, defaultXDGConfigDir)
52+
}
53+
return filepath.Join(configDir, dashDir, bookmarksFileName)
54+
}
55+
56+
// load reads bookmarks from the JSON file
57+
func (s *BookmarkStore) load() {
58+
s.mu.Lock()
59+
defer s.mu.Unlock()
60+
61+
if s.filePath == "" {
62+
return
63+
}
64+
65+
data, err := os.ReadFile(s.filePath)
66+
if err != nil {
67+
if !os.IsNotExist(err) {
68+
log.Error("Failed to read bookmarks file", "err", err)
69+
}
70+
return
71+
}
72+
73+
var bookmarkList []string
74+
if err := json.Unmarshal(data, &bookmarkList); err != nil {
75+
log.Error("Failed to parse bookmarks file", "err", err)
76+
return
77+
}
78+
79+
for _, id := range bookmarkList {
80+
s.bookmarks[id] = true
81+
}
82+
log.Debug("Loaded bookmarks", "count", len(s.bookmarks))
83+
}
84+
85+
// save writes bookmarks to the JSON file
86+
func (s *BookmarkStore) save() error {
87+
s.mu.RLock()
88+
defer s.mu.RUnlock()
89+
90+
if s.filePath == "" {
91+
return nil
92+
}
93+
94+
bookmarkList := make([]string, 0, len(s.bookmarks))
95+
for id := range s.bookmarks {
96+
bookmarkList = append(bookmarkList, id)
97+
}
98+
99+
data, err := json.MarshalIndent(bookmarkList, "", " ")
100+
if err != nil {
101+
return err
102+
}
103+
104+
dir := filepath.Dir(s.filePath)
105+
if err := os.MkdirAll(dir, 0o755); err != nil {
106+
return err
107+
}
108+
109+
if err := os.WriteFile(s.filePath, data, 0o644); err != nil {
110+
return err
111+
}
112+
113+
log.Debug("Saved bookmarks", "count", len(bookmarkList))
114+
return nil
115+
}
116+
117+
// IsBookmarked checks if a notification is bookmarked
118+
func (s *BookmarkStore) IsBookmarked(notificationId string) bool {
119+
s.mu.RLock()
120+
defer s.mu.RUnlock()
121+
return s.bookmarks[notificationId]
122+
}
123+
124+
// ToggleBookmark toggles the bookmark state for a notification
125+
// Returns the new bookmark state
126+
func (s *BookmarkStore) ToggleBookmark(notificationId string) bool {
127+
s.mu.Lock()
128+
if s.bookmarks[notificationId] {
129+
delete(s.bookmarks, notificationId)
130+
s.mu.Unlock()
131+
s.save()
132+
return false
133+
}
134+
s.bookmarks[notificationId] = true
135+
s.mu.Unlock()
136+
s.save()
137+
return true
138+
}
139+
140+
// AddBookmark adds a bookmark for a notification
141+
func (s *BookmarkStore) AddBookmark(notificationId string) {
142+
s.mu.Lock()
143+
s.bookmarks[notificationId] = true
144+
s.mu.Unlock()
145+
s.save()
146+
}
147+
148+
// RemoveBookmark removes a bookmark for a notification
149+
func (s *BookmarkStore) RemoveBookmark(notificationId string) {
150+
s.mu.Lock()
151+
delete(s.bookmarks, notificationId)
152+
s.mu.Unlock()
153+
s.save()
154+
}
155+
156+
// GetBookmarkedIds returns all bookmarked notification IDs
157+
func (s *BookmarkStore) GetBookmarkedIds() []string {
158+
s.mu.RLock()
159+
defer s.mu.RUnlock()
160+
ids := make([]string, 0, len(s.bookmarks))
161+
for id := range s.bookmarks {
162+
ids = append(ids, id)
163+
}
164+
return ids
165+
}

internal/data/issueapi.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ package data
22

33
import (
44
"fmt"
5+
"net/url"
56
"time"
67

78
"github.com/charmbracelet/log"
89
gh "github.com/cli/go-gh/v2/pkg/api"
910
graphql "github.com/cli/shurcooL-graphql"
11+
"github.com/shurcooL/githubv4"
1012

1113
"github.com/dlvhdr/gh-dash/v4/internal/tui/theme"
1214
)
@@ -25,7 +27,7 @@ type IssueData struct {
2527
Url string
2628
Repository Repository
2729
Assignees Assignees `graphql:"assignees(first: 3)"`
28-
Comments IssueComments `graphql:"comments(first: 15)"`
30+
Comments IssueComments `graphql:"comments(last: 15)"`
2931
Reactions IssueReactions `graphql:"reactions(first: 1)"`
3032
Labels IssueLabels `graphql:"labels(first: 20)"`
3133
}
@@ -147,3 +149,35 @@ type IssuesResponse struct {
147149
TotalCount int
148150
PageInfo PageInfo
149151
}
152+
153+
// FetchIssue fetches a single issue by its GitHub URL
154+
func FetchIssue(issueUrl string) (IssueData, error) {
155+
var err error
156+
if cachedClient == nil {
157+
cachedClient, err = gh.NewGraphQLClient(gh.ClientOptions{EnableCache: true, CacheTTL: 5 * time.Minute})
158+
if err != nil {
159+
return IssueData{}, err
160+
}
161+
}
162+
163+
var queryResult struct {
164+
Resource struct {
165+
Issue IssueData `graphql:"... on Issue"`
166+
} `graphql:"resource(url: $url)"`
167+
}
168+
parsedUrl, err := url.Parse(issueUrl)
169+
if err != nil {
170+
return IssueData{}, err
171+
}
172+
variables := map[string]any{
173+
"url": githubv4.URI{URL: parsedUrl},
174+
}
175+
log.Debug("Fetching Issue", "url", issueUrl)
176+
err = cachedClient.Query("FetchIssue", &queryResult, variables)
177+
if err != nil {
178+
return IssueData{}, err
179+
}
180+
log.Info("Successfully fetched Issue", "url", issueUrl)
181+
182+
return queryResult.Resource.Issue, nil
183+
}

0 commit comments

Comments
 (0)