Skip to content

Commit cf1f6ad

Browse files
authored
In-app workspace switching (#4)
* feat: Add workspace switching functionality with path normalization and dedicated TUI state. * feat: Add directory path auto-completion to the workspace input field. * feat: Add persistent star nudge with browser opening functionality and update workspace keybinding. * docs: Add workspace switching and symlink support features, and update roadmap.
1 parent 15cf24d commit cf1f6ad

File tree

6 files changed

+593
-11
lines changed

6 files changed

+593
-11
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ git-scope -h
7373

7474
## ✨ Features
7575

76+
* **📁 Workspace Switch** — Switch root directories without quitting (`w`). Supports `~`, relative paths, and **symlinks**.
7677
* **🔍 Fuzzy Search** — Find any repo by name, path, or branch (`/`).
7778
* **🛡️ Dirty Filter** — Instantly show only repos with uncommitted changes (`f`).
7879
* **🚀 Editor Jump** — Open the selected repo in VSCode, Neovim, Vim, or Helix (`Enter`).
@@ -81,13 +82,15 @@ git-scope -h
8182
* **🌿 Contribution Graph** — GitHub-style local heatmap for your activity (`g`).
8283
* **💾 Disk Usage** — Visualize `.git` vs `node_modules` size (`d`).
8384
* **⏰ Timeline** — View recent activity across all projects (`t`).
85+
* **🔗 Symlink Support** — Symlinked directories resolve transparently (great for Codespaces/devcontainers).
8486

8587
-----
8688

8789
## ⌨️ Keyboard Shortcuts
8890

8991
| Key | Action |
9092
| :--- | :--- |
93+
| `w` | **Switch Workspace** (with Tab completion) |
9194
| `/` | **Search** repositories (Fuzzy) |
9295
| `f` | **Filter** (Cycle: All / Dirty / Clean) |
9396
| `s` | Cycle **Sort** Mode |
@@ -142,6 +145,8 @@ I built `git-scope` to solve the **"Multi-Repo Blindness"** problem. It gives me
142145

143146
## 🗺️ Roadmap
144147

148+
- [x] In-app workspace switching with Tab completion
149+
- [x] Symlink resolution for devcontainers/Codespaces
145150
- [ ] Background file watcher (real-time updates)
146151
- [ ] Quick actions (bulk pull/fetch)
147152
- [ ] Repo grouping (Service / Team / Stack)

internal/nudge/nudge.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package nudge
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// Version is the current app version - used to track per-version nudge
10+
const Version = "1.3.0"
11+
12+
// GitHubRepoURL is the URL to open when user presses S
13+
const GitHubRepoURL = "https://github.com/Bharath-code/git-scope"
14+
15+
// NudgeState represents the persistent state of the star nudge
16+
type NudgeState struct {
17+
SeenVersion string `json:"seenVersion"`
18+
Dismissed bool `json:"dismissed"`
19+
Completed bool `json:"completed"`
20+
}
21+
22+
// getNudgePath returns the path to the nudge state file
23+
func getNudgePath() string {
24+
home, err := os.UserHomeDir()
25+
if err != nil {
26+
return ""
27+
}
28+
return filepath.Join(home, ".cache", "git-scope", "nudge.json")
29+
}
30+
31+
// loadState loads the nudge state from disk
32+
func loadState() *NudgeState {
33+
path := getNudgePath()
34+
if path == "" {
35+
return &NudgeState{}
36+
}
37+
38+
data, err := os.ReadFile(path)
39+
if err != nil {
40+
return &NudgeState{}
41+
}
42+
43+
var state NudgeState
44+
if err := json.Unmarshal(data, &state); err != nil {
45+
return &NudgeState{}
46+
}
47+
48+
return &state
49+
}
50+
51+
// saveState saves the nudge state to disk
52+
func saveState(state *NudgeState) error {
53+
path := getNudgePath()
54+
if path == "" {
55+
return nil
56+
}
57+
58+
// Ensure directory exists
59+
dir := filepath.Dir(path)
60+
if err := os.MkdirAll(dir, 0755); err != nil {
61+
return err
62+
}
63+
64+
data, err := json.MarshalIndent(state, "", " ")
65+
if err != nil {
66+
return err
67+
}
68+
69+
return os.WriteFile(path, data, 0644)
70+
}
71+
72+
// ShouldShowNudge checks if the star nudge should be shown
73+
// Returns true only if:
74+
// - Not already seen for this version
75+
// - Not dismissed
76+
// - Not completed (user already starred)
77+
func ShouldShowNudge() bool {
78+
state := loadState()
79+
80+
// Already seen for this version
81+
if state.SeenVersion == Version {
82+
return false
83+
}
84+
85+
// User already completed (pressed S)
86+
if state.Completed {
87+
return false
88+
}
89+
90+
return true
91+
}
92+
93+
// MarkShown marks the nudge as shown for the current version
94+
func MarkShown() {
95+
state := loadState()
96+
state.SeenVersion = Version
97+
state.Dismissed = false
98+
_ = saveState(state)
99+
}
100+
101+
// MarkDismissed marks the nudge as dismissed (any key pressed)
102+
func MarkDismissed() {
103+
state := loadState()
104+
state.SeenVersion = Version
105+
state.Dismissed = true
106+
_ = saveState(state)
107+
}
108+
109+
// MarkCompleted marks the nudge as completed (S pressed, GitHub opened)
110+
func MarkCompleted() {
111+
state := loadState()
112+
state.SeenVersion = Version
113+
state.Completed = true
114+
_ = saveState(state)
115+
}

internal/tui/model.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const (
2323
StateReady
2424
StateError
2525
StateSearching
26+
StateWorkspaceSwitch
2627
)
2728

2829
// SortMode represents different sorting options
@@ -66,6 +67,13 @@ type Model struct {
6667
grassData *stats.ContributionData
6768
diskData *stats.DiskUsageData
6869
timelineData *stats.TimelineData
70+
// Workspace switch state
71+
workspaceInput textinput.Model
72+
workspaceError string
73+
activeWorkspace string
74+
// Star nudge state
75+
showStarNudge bool
76+
nudgeShownThisSession bool
6977
}
7078

7179
// NewModel creates a new TUI model
@@ -115,19 +123,26 @@ func NewModel(cfg *config.Config) Model {
115123
ti.CharLimit = 50
116124
ti.Width = 30
117125

126+
// Create text input for workspace switch
127+
wi := textinput.New()
128+
wi.Placeholder = "~/projects or /path/to/dir"
129+
wi.CharLimit = 200
130+
wi.Width = 40
131+
118132
// Create spinner with Braille pattern
119133
sp := spinner.New()
120134
sp.Spinner = spinner.Dot
121135
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED"))
122136

123137
return Model{
124-
cfg: cfg,
125-
table: t,
126-
textInput: ti,
127-
spinner: sp,
128-
state: StateLoading,
129-
sortMode: SortByDirty,
130-
filterMode: FilterAll,
138+
cfg: cfg,
139+
table: t,
140+
textInput: ti,
141+
workspaceInput: wi,
142+
spinner: sp,
143+
state: StateLoading,
144+
sortMode: SortByDirty,
145+
filterMode: FilterAll,
131146
}
132147
}
133148

0 commit comments

Comments
 (0)