@fatalmistake02/rian 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -2
  3. package/THIRD_PARTY_NOTICES.txt +673 -0
  4. package/bin/rian.cjs +12 -0
  5. package/cmd/rian/main.go +17 -0
  6. package/dist/binaries/darwin-arm64/rian +0 -0
  7. package/dist/binaries/darwin-x64/rian +0 -0
  8. package/dist/binaries/linux-arm64/rian +0 -0
  9. package/dist/binaries/linux-x64/rian +0 -0
  10. package/dist/binaries/manifest.json +29 -0
  11. package/dist/binaries/win32-arm64/rian.exe +0 -0
  12. package/dist/binaries/win32-x64/rian.exe +0 -0
  13. package/go.mod +28 -0
  14. package/go.sum +44 -0
  15. package/internal/app/app.go +298 -0
  16. package/internal/app/app_test.go +85 -0
  17. package/internal/config/config.go +189 -0
  18. package/internal/config/config_test.go +101 -0
  19. package/internal/detect/detect.go +252 -0
  20. package/internal/detect/detect_test.go +86 -0
  21. package/internal/git/git.go +166 -0
  22. package/internal/git/git_test.go +73 -0
  23. package/internal/git/window_unix.go +7 -0
  24. package/internal/git/window_windows.go +8 -0
  25. package/internal/process/platform_unix.go +19 -0
  26. package/internal/process/platform_windows.go +25 -0
  27. package/internal/process/process.go +172 -0
  28. package/internal/process/process_test.go +137 -0
  29. package/internal/project/project.go +180 -0
  30. package/internal/project/project_test.go +71 -0
  31. package/internal/tools/lookup.go +41 -0
  32. package/internal/tools/platform_unix.go +34 -0
  33. package/internal/tools/platform_windows.go +52 -0
  34. package/internal/tools/platform_windows_test.go +29 -0
  35. package/internal/tools/tools.go +152 -0
  36. package/internal/tools/tools_test.go +56 -0
  37. package/internal/ui/actions.go +259 -0
  38. package/internal/ui/forms.go +156 -0
  39. package/internal/ui/model.go +220 -0
  40. package/internal/ui/styles.go +85 -0
  41. package/internal/ui/ui_test.go +170 -0
  42. package/internal/ui/update.go +470 -0
  43. package/internal/ui/view.go +459 -0
  44. package/lib/launcher.cjs +46 -0
  45. package/lib/platforms.cjs +20 -0
  46. package/package.json +39 -4
  47. package/scripts/build-binaries.cjs +60 -0
  48. package/scripts/check-package.cjs +49 -0
  49. package/scripts/ensure-binary.cjs +11 -0
  50. package/test/npm/launcher.test.cjs +45 -0
  51. package/test/npm/package.test.cjs +26 -0
@@ -0,0 +1,259 @@
1
+ package ui
2
+
3
+ import (
4
+ "fmt"
5
+ "os/exec"
6
+ "sort"
7
+ "strings"
8
+
9
+ tea "charm.land/bubbletea/v2"
10
+
11
+ "rian/internal/config"
12
+ "rian/internal/detect"
13
+ "rian/internal/project"
14
+ "rian/internal/tools"
15
+ )
16
+
17
+ func (m *Model) actions(p project.Project) []Action {
18
+ items := []Action{}
19
+ for _, tool := range []struct{ id, label, binary string }{{"vscode", "Open in VS Code", "code"}, {"opencode", "Open in OpenCode", "opencode"}, {"kilocode", "Open in KiloCode", "kilocode"}, {"chatgpt", "Open ChatGPT", ""}, {"terminal", "Open Terminal", ""}} {
20
+ item := Action{ID: tool.id, Label: tool.label}
21
+ if !p.ToolEnabled(tool.id) {
22
+ item.Disabled = "Disabled in project settings"
23
+ } else if tool.binary != "" && !m.available[tool.binary] {
24
+ item.Disabled = tool.binary + " was not found in PATH"
25
+ }
26
+ items = append(items, item)
27
+ }
28
+ repoURL, err := tools.GitHubURL(p, m.statuses[p.ID])
29
+ item := Action{ID: "github", Label: "Open GitHub", Detail: repoURL}
30
+ if err != nil {
31
+ item.Disabled = "No GitHub remote or override"
32
+ }
33
+ items = append(items, item)
34
+ for _, label := range []string{"local", "production"} {
35
+ if p.URLs[label] != "" {
36
+ items = append(items, Action{ID: "url", Label: "Open " + label + " URL", Value: p.URLs[label], Detail: p.URLs[label]})
37
+ }
38
+ }
39
+ for _, name := range detect.CommandNames(p.AllCommands()) {
40
+ items = append(items, Action{ID: "run", Label: "Run " + name, Value: name, Detail: p.AllCommands()[name]})
41
+ }
42
+ if m.statuses[p.ID].Repository {
43
+ items = append(items, m.gitActions()...)
44
+ }
45
+ if m.manager.Snapshot(p.ID).Command != "" {
46
+ items = append(items, Action{ID: "process", Label: "Show process output"})
47
+ }
48
+ favorite := "Favorite project"
49
+ if p.Favorite {
50
+ favorite = "Unfavorite project"
51
+ }
52
+ return append(items, Action{ID: "favorite", Label: favorite}, Action{ID: "edit", Label: "Edit project"}, Action{ID: "remove", Label: "Remove project"}, Action{ID: "add", Label: "Add project"}, Action{ID: "refresh", Label: "Rescan project", Detail: "Refresh stack, commands, tools, and Git"})
53
+ }
54
+
55
+ func (m *Model) gitActions() []Action {
56
+ items := []Action{{ID: "git", Label: "Git Pull", Value: "pull", Detail: "Fast-forward only"}, {ID: "git", Label: "Git Push", Value: "push", Detail: "Push current branch"}, {ID: "git", Label: "Git Fetch", Value: "fetch"}, {ID: "commit", Label: "Git Commit", Detail: "Commit staged changes"}, {ID: "branch", Label: "Switch branch", Detail: "An existing local branch"}, {ID: "github", Label: "Open GitHub"}}
57
+ item := Action{ID: "lazygit", Label: "Open lazygit"}
58
+ if !m.available["lazygit"] {
59
+ item.Disabled = "lazygit was not found in PATH"
60
+ }
61
+ return append(items, item)
62
+ }
63
+
64
+ func (m *Model) openMenu(which mode) tea.Cmd {
65
+ m.mode, m.menuIndex = which, 0
66
+ m.query = input("Search actions…")
67
+ p, ok := m.current()
68
+ if !ok {
69
+ m.menu = []Action{{ID: "add", Label: "Add project"}, {ID: "help", Label: "Keyboard shortcuts"}}
70
+ } else {
71
+ switch which {
72
+ case gitMenu:
73
+ m.menu = m.gitActions()
74
+ case commands:
75
+ m.menu = nil
76
+ for _, name := range detect.CommandNames(p.AllCommands()) {
77
+ detail := p.AllCommands()[name]
78
+ if _, custom := p.Commands[name]; custom {
79
+ detail = "Custom · " + detail
80
+ }
81
+ m.menu = append(m.menu, Action{ID: "run", Label: name, Value: name, Detail: detail})
82
+ }
83
+ m.menu = append(m.menu, Action{ID: "custom", Label: "+ Add custom command"}, Action{ID: "adhoc", Label: "Run a one-off command"})
84
+ default:
85
+ m.menu = m.actions(p)
86
+ }
87
+ }
88
+ m.resize()
89
+ return m.query.Focus()
90
+ }
91
+
92
+ func (m *Model) filteredActions() []Action {
93
+ items := []Action{}
94
+ for _, action := range m.menu {
95
+ if project.Score(m.query.Value(), action.Label) >= 0 {
96
+ items = append(items, action)
97
+ }
98
+ }
99
+ if m.query.Value() != "" {
100
+ sort.SliceStable(items, func(i, j int) bool {
101
+ return project.Score(m.query.Value(), items[i].Label) > project.Score(m.query.Value(), items[j].Label)
102
+ })
103
+ }
104
+ return items
105
+ }
106
+
107
+ func (m *Model) execute(action Action) tea.Cmd {
108
+ if action.Disabled != "" {
109
+ return m.notify("Action unavailable", fmt.Errorf("%s", action.Disabled))
110
+ }
111
+ if action.ID == "add" {
112
+ m.startAdd()
113
+ return m.query.Focus()
114
+ }
115
+ if action.ID == "help" {
116
+ m.mode = help
117
+ m.resize()
118
+ m.viewport.GotoTop()
119
+ return nil
120
+ }
121
+ p, ok := m.current()
122
+ if !ok {
123
+ return nil
124
+ }
125
+ if !p.ToolEnabled(action.ID) {
126
+ return m.notify("Action disabled", fmt.Errorf("%s is disabled in project settings", action.Label))
127
+ }
128
+ m.closeModal()
129
+ switch action.ID {
130
+ case "favorite":
131
+ return m.save(p.ID, "Favorite updated", func(cfg *config.Config) error {
132
+ for i := range cfg.Projects {
133
+ if cfg.Projects[i].ID == p.ID {
134
+ cfg.Projects[i].Favorite = !cfg.Projects[i].Favorite
135
+ return nil
136
+ }
137
+ }
138
+ return fmt.Errorf("project no longer exists")
139
+ })
140
+ case "edit":
141
+ m.startEdit(p)
142
+ return m.fields[0].input.Focus()
143
+ case "remove":
144
+ m.editProject = p
145
+ m.mode = removeConfirm
146
+ return nil
147
+ case "refresh":
148
+ return m.inspectDirectory("refresh", p.Path, p.ID)
149
+ case "process":
150
+ m.showProcess(p.ID)
151
+ return nil
152
+ case "run":
153
+ return m.runCommand(p, action.Value, p.AllCommands()[action.Value])
154
+ case "adhoc":
155
+ m.startPrompt("run", "Command to run…")
156
+ return m.query.Focus()
157
+ case "custom":
158
+ m.startCustom("", "")
159
+ return m.commandName.Focus()
160
+ case "commit":
161
+ m.startPrompt("commit", "Commit message (staged changes only)…")
162
+ return m.query.Focus()
163
+ case "branch":
164
+ m.startPrompt("branch", "Existing branch name…")
165
+ return m.query.Focus()
166
+ case "git":
167
+ args := []string{action.Value}
168
+ if action.Value == "pull" {
169
+ args = append(args, "--ff-only")
170
+ }
171
+ return m.interactive(p, func() (*exec.Cmd, error) { return tools.GitCommand(p, args...) }, action.Label, true)
172
+ case "terminal", "opencode", "kilocode", "lazygit":
173
+ return m.interactive(p, func() (*exec.Cmd, error) { return m.launcher.Interactive(action.ID, p) }, action.Label, true)
174
+ default:
175
+ launcher := m.launcher
176
+ status := m.statuses[p.ID]
177
+ return func() tea.Msg {
178
+ var err error
179
+ switch action.ID {
180
+ case "vscode":
181
+ err = launcher.OpenEditor(p)
182
+ case "chatgpt":
183
+ err = launcher.OpenChatGPT()
184
+ case "github":
185
+ var value string
186
+ value, err = tools.GitHubURL(p, status)
187
+ if err == nil {
188
+ err = launcher.OpenURL(value)
189
+ }
190
+ case "url":
191
+ err = launcher.OpenURL(action.Value)
192
+ }
193
+ return actionMsg{id: p.ID, notice: action.Label + " opened", err: err}
194
+ }
195
+ }
196
+ }
197
+
198
+ type preparedExecMsg struct {
199
+ cmd *exec.Cmd
200
+ id, label string
201
+ refresh bool
202
+ err error
203
+ }
204
+
205
+ func (m *Model) interactive(p project.Project, prepare func() (*exec.Cmd, error), label string, refresh bool) tea.Cmd {
206
+ return func() tea.Msg { cmd, err := prepare(); return preparedExecMsg{cmd, p.ID, label, refresh, err} }
207
+ }
208
+
209
+ func (m *Model) runCommand(p project.Project, name, command string) tea.Cmd {
210
+ if strings.TrimSpace(command) == "" {
211
+ return m.notify("No command", fmt.Errorf("this command is empty"))
212
+ }
213
+ m.showProcess(p.ID)
214
+ m.processState.Command = command
215
+ m.busy = true
216
+ manager := m.manager
217
+ return func() tea.Msg {
218
+ state, err := manager.Start(p.ID, p.Path, command)
219
+ return processMsg{p.ID, state, err}
220
+ }
221
+ }
222
+
223
+ func (m *Model) showProcess(id string) {
224
+ m.mode = processView
225
+ m.resize()
226
+ m.processID = id
227
+ m.processState = m.manager.Snapshot(id)
228
+ m.followOutput = true
229
+ m.viewport.SetContent(plain(m.processState.Output))
230
+ m.viewport.GotoBottom()
231
+ }
232
+
233
+ func (m *Model) startPrompt(action, placeholder string) {
234
+ m.mode = prompt
235
+ m.promptAction = action
236
+ m.query = input(placeholder)
237
+ m.resize()
238
+ }
239
+
240
+ func (m *Model) submitPrompt() tea.Cmd {
241
+ value := strings.TrimSpace(m.query.Value())
242
+ if value == "" {
243
+ m.formError = "Enter a value first"
244
+ return nil
245
+ }
246
+ p, ok := m.current()
247
+ if !ok {
248
+ return nil
249
+ }
250
+ action := m.promptAction
251
+ m.closeModal()
252
+ if action == "run" {
253
+ return m.runCommand(p, "custom", value)
254
+ }
255
+ if action == "commit" {
256
+ return m.interactive(p, func() (*exec.Cmd, error) { return tools.GitCommand(p, "commit", "-m", value) }, "Git commit", true)
257
+ }
258
+ return m.interactive(p, func() (*exec.Cmd, error) { return tools.GitCommand(p, "switch", "--", value) }, "Switch branch", true)
259
+ }
@@ -0,0 +1,156 @@
1
+ package ui
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+
7
+ tea "charm.land/bubbletea/v2"
8
+
9
+ "rian/internal/config"
10
+ "rian/internal/detect"
11
+ "rian/internal/git"
12
+ "rian/internal/project"
13
+ "rian/internal/tools"
14
+ )
15
+
16
+ func (m *Model) startAdd() {
17
+ m.mode, m.formError = add, ""
18
+ m.query = input("Directory, e.g. ~/dev/my-project")
19
+ m.resize()
20
+ }
21
+
22
+ func (m *Model) inspectDirectory(purpose, path, id string) tea.Cmd {
23
+ m.busy = true
24
+ return func() tea.Msg { result, err := detect.Inspect(path); return inspectedMsg{result, err, purpose, id} }
25
+ }
26
+
27
+ func (m *Model) confirmAdd() tea.Cmd {
28
+ p := m.pending.Project
29
+ return m.save(p.ID, "Project added", func(cfg *config.Config) error { return config.Add(cfg, p) })
30
+ }
31
+
32
+ func (m *Model) startEdit(p project.Project) {
33
+ m.mode, m.formError, m.editProject = edit, "", p
34
+ m.fields = nil
35
+ for _, field := range []struct{ label, value, hint string }{
36
+ {"Name", p.Name, "Display name"}, {"Directory", p.Path, "Project directory"},
37
+ {"GitHub", p.GitHub, "owner/repo override (optional)"}, {"Local URL", p.URLs["local"], "http://localhost:3000"}, {"Production URL", p.URLs["production"], "https://example.com"},
38
+ } {
39
+ i := input(field.hint)
40
+ i.SetValue(field.value)
41
+ m.fields = append(m.fields, formField{label: field.label, input: i})
42
+ }
43
+ m.fields = append(m.fields, formField{label: "Favorite", toggle: true, checked: p.Favorite})
44
+ for _, name := range []string{"vscode", "opencode", "kilocode", "chatgpt", "terminal"} {
45
+ m.fields = append(m.fields, formField{label: name, toggle: true, checked: p.ToolEnabled(name)})
46
+ }
47
+ m.fieldIndex = 0
48
+ m.resize()
49
+ }
50
+
51
+ func (m *Model) focusField(index int) tea.Cmd {
52
+ m.fields[m.fieldIndex].input.Blur()
53
+ m.fieldIndex = (index + len(m.fields)) % len(m.fields)
54
+ if !m.fields[m.fieldIndex].toggle {
55
+ return m.fields[m.fieldIndex].input.Focus()
56
+ }
57
+ return nil
58
+ }
59
+
60
+ func (m *Model) submitEdit() tea.Cmd {
61
+ if strings.TrimSpace(m.fields[0].input.Value()) == "" {
62
+ m.formError = "Give this project a name"
63
+ return nil
64
+ }
65
+ repo, err := git.NormalizeRepo(strings.TrimSpace(m.fields[2].input.Value()))
66
+ if err != nil {
67
+ m.formError = err.Error()
68
+ return nil
69
+ }
70
+ for _, i := range []int{3, 4} {
71
+ if err := tools.ValidateURL(strings.TrimSpace(m.fields[i].input.Value())); err != nil {
72
+ m.formError = m.fields[i].label + ": " + err.Error()
73
+ return nil
74
+ }
75
+ }
76
+ p := m.editProject
77
+ p.Name = strings.TrimSpace(m.fields[0].input.Value())
78
+ p.GitHub = repo
79
+ p.URLs = map[string]string{"local": strings.TrimSpace(m.fields[3].input.Value()), "production": strings.TrimSpace(m.fields[4].input.Value())}
80
+ p.Favorite = m.fields[5].checked
81
+ p.Tools = map[string]bool{}
82
+ for _, field := range m.fields[6:] {
83
+ p.Tools[field.label] = field.checked
84
+ }
85
+ m.editProject = p
86
+ return m.inspectDirectory("edit", m.fields[1].input.Value(), p.ID)
87
+ }
88
+
89
+ func (m *Model) startCustom(name, command string) {
90
+ m.commandOriginal = name
91
+ m.mode, m.commandEditing, m.formError = prompt, true, ""
92
+ m.promptAction, m.commandField = "custom", 0
93
+ m.commandName, m.commandValue = input("e.g. dev, deploy, docs"), input("e.g. npm run dev")
94
+ m.commandName.SetValue(name)
95
+ m.commandValue.SetValue(command)
96
+ m.resize()
97
+ }
98
+
99
+ func (m *Model) submitCustom() tea.Cmd {
100
+ name, value := strings.TrimSpace(m.commandName.Value()), strings.TrimSpace(m.commandValue.Value())
101
+ if name == "" || value == "" {
102
+ m.formError = "A name and command are required"
103
+ return nil
104
+ }
105
+ p, ok := m.current()
106
+ if !ok {
107
+ return nil
108
+ }
109
+ original := m.commandOriginal
110
+ return m.save(p.ID, "Command saved", func(cfg *config.Config) error {
111
+ for i := range cfg.Projects {
112
+ if cfg.Projects[i].ID == p.ID {
113
+ if cfg.Projects[i].Commands == nil {
114
+ cfg.Projects[i].Commands = map[string]string{}
115
+ }
116
+ if original != "" && original != name {
117
+ delete(cfg.Projects[i].Commands, original)
118
+ }
119
+ cfg.Projects[i].Commands[name] = value
120
+ return nil
121
+ }
122
+ }
123
+ return fmt.Errorf("project no longer exists")
124
+ })
125
+ }
126
+
127
+ func (m *Model) commandEdit(remove bool) tea.Cmd {
128
+ items := m.filteredActions()
129
+ if len(items) == 0 {
130
+ return nil
131
+ }
132
+ item := items[min(m.menuIndex, len(items)-1)]
133
+ if item.ID != "run" {
134
+ return nil
135
+ }
136
+ p, ok := m.current()
137
+ if !ok {
138
+ return nil
139
+ }
140
+ if !remove {
141
+ m.startCustom(item.Value, p.AllCommands()[item.Value])
142
+ return m.commandName.Focus()
143
+ }
144
+ if _, custom := p.Commands[item.Value]; !custom {
145
+ return m.notify("Detected command", fmt.Errorf("detected commands come from project files; Ctrl+E adds an override"))
146
+ }
147
+ return m.save(p.ID, "Custom command removed", func(cfg *config.Config) error {
148
+ for i := range cfg.Projects {
149
+ if cfg.Projects[i].ID == p.ID {
150
+ delete(cfg.Projects[i].Commands, item.Value)
151
+ return nil
152
+ }
153
+ }
154
+ return fmt.Errorf("project no longer exists")
155
+ })
156
+ }
@@ -0,0 +1,220 @@
1
+ package ui
2
+
3
+ import (
4
+ "log"
5
+ "time"
6
+
7
+ "charm.land/bubbles/v2/spinner"
8
+ "charm.land/bubbles/v2/textinput"
9
+ "charm.land/bubbles/v2/viewport"
10
+ tea "charm.land/bubbletea/v2"
11
+
12
+ "rian/internal/config"
13
+ "rian/internal/detect"
14
+ "rian/internal/git"
15
+ "rian/internal/process"
16
+ "rian/internal/project"
17
+ "rian/internal/tools"
18
+ )
19
+
20
+ type mode int
21
+
22
+ const (
23
+ home mode = iota
24
+ add
25
+ addConfirm
26
+ edit
27
+ palette
28
+ commands
29
+ gitMenu
30
+ help
31
+ removeConfirm
32
+ quitConfirm
33
+ prompt
34
+ processView
35
+ )
36
+
37
+ type Action struct{ ID, Label, Detail, Value, Disabled string }
38
+ type formField struct {
39
+ label string
40
+ input textinput.Model
41
+ toggle bool
42
+ checked bool
43
+ }
44
+
45
+ type Model struct {
46
+ store *config.Store
47
+ manager *process.Manager
48
+ launcher tools.System
49
+ logger *log.Logger
50
+ cfg config.Config
51
+ width, height int
52
+ mode mode
53
+ selected int
54
+ search textinput.Model
55
+ searching bool
56
+ query textinput.Model
57
+ menuIndex int
58
+ menu []Action
59
+ available map[string]bool
60
+ statuses map[string]git.Status
61
+ loading map[string]bool
62
+ lastRefresh map[string]time.Time
63
+ spin spinner.Model
64
+ viewport viewport.Model
65
+ toast string
66
+ toastError bool
67
+ toastID int
68
+ busy bool
69
+ pending detect.Result
70
+ fields []formField
71
+ fieldIndex int
72
+ editProject project.Project
73
+ promptAction string
74
+ commandName textinput.Model
75
+ commandValue textinput.Model
76
+ commandEditing bool
77
+ commandField int
78
+ commandOriginal string
79
+ formError string
80
+ processID string
81
+ processState process.Snapshot
82
+ followOutput bool
83
+ }
84
+
85
+ type readyMsg struct {
86
+ cfg config.Config
87
+ available map[string]bool
88
+ err error
89
+ }
90
+ type inspectedMsg struct {
91
+ result detect.Result
92
+ err error
93
+ purpose string
94
+ id string
95
+ }
96
+ type gitMsg struct {
97
+ id string
98
+ status git.Status
99
+ }
100
+ type savedMsg struct {
101
+ cfg config.Config
102
+ id, notice string
103
+ err error
104
+ }
105
+ type actionMsg struct {
106
+ id, notice string
107
+ err error
108
+ refresh bool
109
+ }
110
+ type processMsg struct {
111
+ id string
112
+ state process.Snapshot
113
+ err error
114
+ }
115
+ type tickMsg time.Time
116
+ type toastExpired int
117
+
118
+ func New(store *config.Store, manager *process.Manager, logger *log.Logger) *Model {
119
+ s := spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(highlight))
120
+ return &Model{store: store, manager: manager, logger: logger, search: input("Search projects…"), query: input(""),
121
+ spin: s, viewport: viewport.New(viewport.WithWidth(60), viewport.WithHeight(12)),
122
+ statuses: map[string]git.Status{}, loading: map[string]bool{}, lastRefresh: map[string]time.Time{}, available: map[string]bool{}, followOutput: true}
123
+ }
124
+
125
+ func (m *Model) Init() tea.Cmd {
126
+ store := m.store
127
+ return tea.Batch(func() tea.Msg {
128
+ cfg, err := store.Update(func(*config.Config) error { return nil })
129
+ return readyMsg{cfg, detect.AvailableTools(), err}
130
+ }, tick(), m.spin.Tick)
131
+ }
132
+
133
+ func tick() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) }
134
+
135
+ func (m *Model) projects() []project.Project { return project.Filter(m.cfg.Projects, m.search.Value()) }
136
+
137
+ func (m *Model) current() (project.Project, bool) {
138
+ projects := m.projects()
139
+ if len(projects) == 0 {
140
+ return project.Project{}, false
141
+ }
142
+ return projects[min(m.selected, len(projects)-1)], true
143
+ }
144
+
145
+ func (m *Model) selectID(id string) {
146
+ for i, p := range m.projects() {
147
+ if p.ID == id {
148
+ m.selected = i
149
+ return
150
+ }
151
+ }
152
+ m.selected = min(m.selected, max(0, len(m.projects())-1))
153
+ }
154
+
155
+ func (m *Model) refresh(p project.Project, force bool) tea.Cmd {
156
+ if m.loading[p.ID] || !force && time.Since(m.lastRefresh[p.ID]) < 15*time.Second {
157
+ return nil
158
+ }
159
+ m.loading[p.ID] = true
160
+ return func() tea.Msg { return gitMsg{p.ID, git.Inspect(p.Path)} }
161
+ }
162
+
163
+ func (m *Model) notify(message string, err error) tea.Cmd {
164
+ m.toastID++
165
+ id := m.toastID
166
+ m.toast, m.toastError = message, err != nil
167
+ if err != nil {
168
+ m.toast = err.Error()
169
+ if m.logger != nil {
170
+ m.logger.Printf("%s: %v", message, err)
171
+ }
172
+ }
173
+ return tea.Tick(7*time.Second, func(time.Time) tea.Msg { return toastExpired(id) })
174
+ }
175
+
176
+ func (m *Model) save(id, notice string, change func(*config.Config) error) tea.Cmd {
177
+ m.busy = true
178
+ store := m.store
179
+ return func() tea.Msg { cfg, err := store.Update(change); return savedMsg{cfg, id, notice, err} }
180
+ }
181
+
182
+ func (m *Model) closeModal() {
183
+ m.mode = home
184
+ m.query.Blur()
185
+ m.formError = ""
186
+ m.commandEditing = false
187
+ }
188
+
189
+ func (m *Model) quit() tea.Cmd {
190
+ if m.manager.Active() > 0 {
191
+ m.mode = quitConfirm
192
+ return nil
193
+ }
194
+ return tea.Quit
195
+ }
196
+
197
+ func (m *Model) resize() {
198
+ w := max(10, min(78, m.width-8))
199
+ m.query.SetWidth(w - 7)
200
+ leftWidth := min(44, max(25, (m.width-4)*36/100))
201
+ if m.width < 84 {
202
+ leftWidth = max(23, (m.width-4)/2-1)
203
+ }
204
+ m.search.SetWidth(max(5, min(42, leftWidth-7)))
205
+ for i := range m.fields {
206
+ m.fields[i].input.SetWidth(max(5, w-25))
207
+ }
208
+ m.commandName.SetWidth(max(5, w-8))
209
+ m.commandValue.SetWidth(max(5, w-8))
210
+ m.viewport.SetWidth(max(5, w-6))
211
+ viewHeight := m.height - 11
212
+ if m.mode == processView {
213
+ viewHeight -= 3
214
+ }
215
+ m.viewport.SetHeight(max(1, min(18, viewHeight)))
216
+ m.viewport.SoftWrap = true
217
+ if m.mode == help {
218
+ m.viewport.SetContent(m.helpView(w - 6))
219
+ }
220
+ }