@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.
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/THIRD_PARTY_NOTICES.txt +673 -0
- package/bin/rian.cjs +12 -0
- package/cmd/rian/main.go +17 -0
- package/dist/binaries/darwin-arm64/rian +0 -0
- package/dist/binaries/darwin-x64/rian +0 -0
- package/dist/binaries/linux-arm64/rian +0 -0
- package/dist/binaries/linux-x64/rian +0 -0
- package/dist/binaries/manifest.json +29 -0
- package/dist/binaries/win32-arm64/rian.exe +0 -0
- package/dist/binaries/win32-x64/rian.exe +0 -0
- package/go.mod +28 -0
- package/go.sum +44 -0
- package/internal/app/app.go +298 -0
- package/internal/app/app_test.go +85 -0
- package/internal/config/config.go +189 -0
- package/internal/config/config_test.go +101 -0
- package/internal/detect/detect.go +252 -0
- package/internal/detect/detect_test.go +86 -0
- package/internal/git/git.go +166 -0
- package/internal/git/git_test.go +73 -0
- package/internal/git/window_unix.go +7 -0
- package/internal/git/window_windows.go +8 -0
- package/internal/process/platform_unix.go +19 -0
- package/internal/process/platform_windows.go +25 -0
- package/internal/process/process.go +172 -0
- package/internal/process/process_test.go +137 -0
- package/internal/project/project.go +180 -0
- package/internal/project/project_test.go +71 -0
- package/internal/tools/lookup.go +41 -0
- package/internal/tools/platform_unix.go +34 -0
- package/internal/tools/platform_windows.go +52 -0
- package/internal/tools/platform_windows_test.go +29 -0
- package/internal/tools/tools.go +152 -0
- package/internal/tools/tools_test.go +56 -0
- package/internal/ui/actions.go +259 -0
- package/internal/ui/forms.go +156 -0
- package/internal/ui/model.go +220 -0
- package/internal/ui/styles.go +85 -0
- package/internal/ui/ui_test.go +170 -0
- package/internal/ui/update.go +470 -0
- package/internal/ui/view.go +459 -0
- package/lib/launcher.cjs +46 -0
- package/lib/platforms.cjs +20 -0
- package/package.json +39 -4
- package/scripts/build-binaries.cjs +60 -0
- package/scripts/check-package.cjs +49 -0
- package/scripts/ensure-binary.cjs +11 -0
- package/test/npm/launcher.test.cjs +45 -0
- 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
|
+
}
|