Skip to content

Commit 1ac9ce2

Browse files
committed
feat: add search, filter, and caching features (v0.2.0)
New features: - / : Fuzzy search repos by name, path, or branch (live search-as-you-type) - f : Toggle filter between All/Dirty Only/Clean Only - c : Clear all search and filter settings - JSON caching in ~/.cache/git-scope/repos.json (5 min TTL) - Search bar UI with active query display - Filter badge in stats bar - Updated help text for all modes
1 parent ac25ef1 commit 1ac9ce2

File tree

8 files changed

+387
-53
lines changed

8 files changed

+387
-53
lines changed

cmd/git-scope/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
"github.com/Bharath-code/git-scope/internal/tui"
1515
)
1616

17-
const version = "0.1.3"
17+
const version = "0.2.0"
1818

1919
func usage() {
2020
fmt.Fprintf(os.Stderr, `git-scope v%s — A fast TUI to see the status of all git repositories

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
)
1111

1212
require (
13+
github.com/atotto/clipboard v0.1.4 // indirect
1314
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1415
github.com/charmbracelet/x/ansi v0.1.1 // indirect
1516
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
2+
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
13
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
24
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
35
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=

internal/cache/cache.go

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,118 @@
11
package cache
22

3-
import "github.com/Bharath-code/git-scope/internal/model"
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"time"
48

5-
// Store defines the interface for caching repo data
9+
"github.com/Bharath-code/git-scope/internal/model"
10+
)
11+
12+
// CacheData represents the cached scan results
13+
type CacheData struct {
14+
Repos []model.Repo `json:"repos"`
15+
Timestamp time.Time `json:"timestamp"`
16+
Roots []string `json:"roots"`
17+
}
18+
19+
// Store interface for caching repo data
620
type Store interface {
7-
Load() ([]model.Repo, error)
8-
Save([]model.Repo) error
21+
Load() (*CacheData, error)
22+
Save(repos []model.Repo, roots []string) error
23+
IsValid(maxAge time.Duration) bool
24+
}
25+
26+
// FileStore implements Store using a JSON file
27+
type FileStore struct {
28+
path string
29+
data *CacheData
30+
}
31+
32+
// NewFileStore creates a new file-based cache store
33+
func NewFileStore() *FileStore {
34+
return &FileStore{
35+
path: getCachePath(),
36+
}
37+
}
38+
39+
// getCachePath returns the path to the cache file
40+
func getCachePath() string {
41+
home, err := os.UserHomeDir()
42+
if err != nil {
43+
return ""
44+
}
45+
return filepath.Join(home, ".cache", "git-scope", "repos.json")
46+
}
47+
48+
// Load reads cached data from disk
49+
func (s *FileStore) Load() (*CacheData, error) {
50+
data, err := os.ReadFile(s.path)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
var cache CacheData
56+
if err := json.Unmarshal(data, &cache); err != nil {
57+
return nil, err
58+
}
59+
60+
s.data = &cache
61+
return &cache, nil
962
}
1063

11-
// TODO: Implement JSON or SQLite-backed store for faster startup
12-
// This is a stub for the MVP - caching will be added in a future version
64+
// Save writes repos to cache file
65+
func (s *FileStore) Save(repos []model.Repo, roots []string) error {
66+
cache := CacheData{
67+
Repos: repos,
68+
Timestamp: time.Now(),
69+
Roots: roots,
70+
}
71+
72+
// Ensure cache directory exists
73+
dir := filepath.Dir(s.path)
74+
if err := os.MkdirAll(dir, 0755); err != nil {
75+
return err
76+
}
77+
78+
data, err := json.MarshalIndent(cache, "", " ")
79+
if err != nil {
80+
return err
81+
}
82+
83+
return os.WriteFile(s.path, data, 0644)
84+
}
85+
86+
// IsValid checks if cache is still valid based on max age
87+
func (s *FileStore) IsValid(maxAge time.Duration) bool {
88+
if s.data == nil {
89+
return false
90+
}
91+
return time.Since(s.data.Timestamp) < maxAge
92+
}
93+
94+
// IsSameRoots checks if cached roots match current roots
95+
func (s *FileStore) IsSameRoots(roots []string) bool {
96+
if s.data == nil || len(s.data.Roots) != len(roots) {
97+
return false
98+
}
99+
for i, r := range roots {
100+
if s.data.Roots[i] != r {
101+
return false
102+
}
103+
}
104+
return true
105+
}
106+
107+
// GetTimestamp returns the cache timestamp
108+
func (s *FileStore) GetTimestamp() time.Time {
109+
if s.data == nil {
110+
return time.Time{}
111+
}
112+
return s.data.Timestamp
113+
}
114+
115+
// Clear removes the cache file
116+
func (s *FileStore) Clear() error {
117+
return os.Remove(s.path)
118+
}

internal/tui/app.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
package tui
22

33
import (
4+
"time"
5+
46
tea "github.com/charmbracelet/bubbletea"
7+
"github.com/Bharath-code/git-scope/internal/cache"
58
"github.com/Bharath-code/git-scope/internal/config"
69
"github.com/Bharath-code/git-scope/internal/model"
710
"github.com/Bharath-code/git-scope/internal/scan"
811
)
912

13+
// Cache max age - use cached data if less than 5 minutes old
14+
const cacheMaxAge = 5 * time.Minute
15+
1016
// Run starts the Bubbletea TUI application
1117
func Run(cfg *config.Config) error {
1218
m := NewModel(cfg)
@@ -18,17 +24,38 @@ func Run(cfg *config.Config) error {
1824
// scanReposCmd is a command that scans for repositories
1925
func scanReposCmd(cfg *config.Config) tea.Cmd {
2026
return func() tea.Msg {
27+
// Try to load from cache first
28+
cacheStore := cache.NewFileStore()
29+
cached, err := cacheStore.Load()
30+
31+
if err == nil && cacheStore.IsValid(cacheMaxAge) && cacheStore.IsSameRoots(cfg.Roots) {
32+
// Use cached data but trigger background refresh
33+
return scanCompleteMsg{
34+
repos: cached.Repos,
35+
fromCache: true,
36+
}
37+
}
38+
39+
// Scan fresh
2140
repos, err := scan.ScanRoots(cfg.Roots, cfg.Ignore)
2241
if err != nil {
2342
return scanErrorMsg{err: err}
2443
}
25-
return scanCompleteMsg{repos: repos}
44+
45+
// Save to cache
46+
_ = cacheStore.Save(repos, cfg.Roots)
47+
48+
return scanCompleteMsg{
49+
repos: repos,
50+
fromCache: false,
51+
}
2652
}
2753
}
2854

2955
// scanCompleteMsg is sent when scanning is complete
3056
type scanCompleteMsg struct {
31-
repos []model.Repo
57+
repos []model.Repo
58+
fromCache bool
3259
}
3360

3461
// scanErrorMsg is sent when scanning fails

internal/tui/model.go

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package tui
33
import (
44
"fmt"
55
"sort"
6+
"strings"
67

78
"github.com/charmbracelet/bubbles/table"
9+
"github.com/charmbracelet/bubbles/textinput"
810
tea "github.com/charmbracelet/bubbletea"
911
"github.com/charmbracelet/lipgloss"
1012
"github.com/Bharath-code/git-scope/internal/config"
@@ -18,6 +20,7 @@ const (
1820
StateLoading State = iota
1921
StateReady
2022
StateError
23+
StateSearching
2124
)
2225

2326
// SortMode represents different sorting options
@@ -30,24 +33,37 @@ const (
3033
SortByLastCommit
3134
)
3235

36+
// FilterMode represents different filter options
37+
type FilterMode int
38+
39+
const (
40+
FilterAll FilterMode = iota
41+
FilterDirty
42+
FilterClean
43+
)
44+
3345
// Model is the Bubbletea model for the TUI
3446
type Model struct {
35-
cfg *config.Config
36-
table table.Model
37-
repos []model.Repo
38-
sortedRepos []model.Repo // Sorted copy for display
39-
state State
40-
err error
41-
statusMsg string
42-
width int
43-
height int
44-
sortMode SortMode
47+
cfg *config.Config
48+
table table.Model
49+
textInput textinput.Model
50+
repos []model.Repo
51+
filteredRepos []model.Repo // After filter applied
52+
sortedRepos []model.Repo // After sort applied
53+
state State
54+
err error
55+
statusMsg string
56+
width int
57+
height int
58+
sortMode SortMode
59+
filterMode FilterMode
60+
searchQuery string
4561
}
4662

4763
// NewModel creates a new TUI model
4864
func NewModel(cfg *config.Config) Model {
4965
columns := []table.Column{
50-
{Title: "Status", Width: 6},
66+
{Title: "Status", Width: 8},
5167
{Title: "Repository", Width: 18},
5268
{Title: "Branch", Width: 14},
5369
{Title: "Staged", Width: 6},
@@ -85,11 +101,19 @@ func NewModel(cfg *config.Config) Model {
85101

86102
t.SetStyles(s)
87103

104+
// Create text input for search
105+
ti := textinput.New()
106+
ti.Placeholder = "Search repos..."
107+
ti.CharLimit = 50
108+
ti.Width = 30
109+
88110
return Model{
89-
cfg: cfg,
90-
table: t,
91-
state: StateLoading,
92-
sortMode: SortByDirty,
111+
cfg: cfg,
112+
table: t,
113+
textInput: ti,
114+
state: StateLoading,
115+
sortMode: SortByDirty,
116+
filterMode: FilterAll,
93117
}
94118
}
95119

@@ -111,10 +135,45 @@ func (m Model) GetSelectedRepo() *model.Repo {
111135
return nil
112136
}
113137

114-
// sortRepos sorts repos based on current sort mode
138+
// applyFilter filters repos based on current filter mode and search query
139+
func (m *Model) applyFilter() {
140+
m.filteredRepos = make([]model.Repo, 0, len(m.repos))
141+
142+
for _, r := range m.repos {
143+
// Apply filter mode
144+
switch m.filterMode {
145+
case FilterDirty:
146+
if !r.Status.IsDirty {
147+
continue
148+
}
149+
case FilterClean:
150+
if r.Status.IsDirty {
151+
continue
152+
}
153+
}
154+
155+
// Apply search query
156+
if m.searchQuery != "" {
157+
query := strings.ToLower(m.searchQuery)
158+
name := strings.ToLower(r.Name)
159+
path := strings.ToLower(r.Path)
160+
branch := strings.ToLower(r.Status.Branch)
161+
162+
if !strings.Contains(name, query) &&
163+
!strings.Contains(path, query) &&
164+
!strings.Contains(branch, query) {
165+
continue
166+
}
167+
}
168+
169+
m.filteredRepos = append(m.filteredRepos, r)
170+
}
171+
}
172+
173+
// sortRepos sorts the filtered repos based on current sort mode
115174
func (m *Model) sortRepos() {
116-
m.sortedRepos = make([]model.Repo, len(m.repos))
117-
copy(m.sortedRepos, m.repos)
175+
m.sortedRepos = make([]model.Repo, len(m.filteredRepos))
176+
copy(m.sortedRepos, m.filteredRepos)
118177

119178
switch m.sortMode {
120179
case SortByDirty:
@@ -139,8 +198,9 @@ func (m *Model) sortRepos() {
139198
}
140199
}
141200

142-
// updateTable refreshes the table with current sorted repos
201+
// updateTable refreshes the table with current filtered and sorted repos
143202
func (m *Model) updateTable() {
203+
m.applyFilter()
144204
m.sortRepos()
145205
m.table.SetRows(reposToRows(m.sortedRepos))
146206
}
@@ -160,6 +220,19 @@ func (m Model) GetSortModeName() string {
160220
return "Unknown"
161221
}
162222

223+
// GetFilterModeName returns the display name of current filter mode
224+
func (m Model) GetFilterModeName() string {
225+
switch m.filterMode {
226+
case FilterAll:
227+
return "All"
228+
case FilterDirty:
229+
return "Dirty Only"
230+
case FilterClean:
231+
return "Clean Only"
232+
}
233+
return "All"
234+
}
235+
163236
// reposToRows converts repos to table rows with status indicators
164237
func reposToRows(repos []model.Repo) []table.Row {
165238
rows := make([]table.Row, 0, len(repos))

0 commit comments

Comments
 (0)