@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,470 @@
1
+ package ui
2
+
3
+ import (
4
+ "fmt"
5
+ "time"
6
+
7
+ "charm.land/bubbles/v2/spinner"
8
+ tea "charm.land/bubbletea/v2"
9
+
10
+ "rian/internal/config"
11
+ "rian/internal/project"
12
+ )
13
+
14
+ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
15
+ switch msg := msg.(type) {
16
+ case tea.WindowSizeMsg:
17
+ m.width, m.height = msg.Width, msg.Height
18
+ m.resize()
19
+ case readyMsg:
20
+ m.available = msg.available
21
+ if msg.err != nil {
22
+ return m, m.notify("Unable to load projects", msg.err)
23
+ }
24
+ m.cfg = msg.cfg
25
+ cmds := []tea.Cmd{}
26
+ // Initial badges are loaded in small parallel batches; subsequent polls
27
+ // inspect only the active project.
28
+ for i, p := range m.cfg.Projects {
29
+ if i >= 8 {
30
+ break
31
+ }
32
+ cmds = append(cmds, m.refresh(p, true))
33
+ }
34
+ return m, tea.Batch(cmds...)
35
+ case gitMsg:
36
+ m.statuses[msg.id], m.loading[msg.id], m.lastRefresh[msg.id] = msg.status, false, time.Now()
37
+ case inspectedMsg:
38
+ m.busy = false
39
+ if msg.purpose == "add" && m.mode != add || msg.purpose == "edit" && m.mode != edit {
40
+ return m, nil
41
+ }
42
+ if msg.err != nil {
43
+ m.formError = msg.err.Error()
44
+ return m, m.notify("Could not inspect directory", msg.err)
45
+ }
46
+ m.available = msg.result.Available
47
+ if msg.purpose == "add" {
48
+ for _, p := range m.cfg.Projects {
49
+ if project.SamePath(p.Path, msg.result.Project.Path) {
50
+ m.formError = "Already saved as " + p.Name
51
+ return m, nil
52
+ }
53
+ }
54
+ m.pending, m.mode = msg.result, addConfirm
55
+ m.viewport.SetContent(m.detectionSummary(msg.result))
56
+ m.viewport.GotoTop()
57
+ } else if msg.purpose == "edit" {
58
+ p := m.editProject
59
+ p.Path, p.AutoCommands, p.Stack = msg.result.Project.Path, msg.result.Project.AutoCommands, msg.result.Project.Stack
60
+ m.statuses[p.ID] = msg.result.Git
61
+ return m, m.save(p.ID, "Project updated", func(cfg *config.Config) error { return config.Replace(cfg, p) })
62
+ } else {
63
+ m.statuses[msg.id] = msg.result.Git
64
+ return m, m.save(msg.id, "Project rescanned", func(cfg *config.Config) error {
65
+ for i := range cfg.Projects {
66
+ if cfg.Projects[i].ID == msg.id {
67
+ cfg.Projects[i].AutoCommands = msg.result.Project.AutoCommands
68
+ cfg.Projects[i].Stack = msg.result.Project.Stack
69
+ return nil
70
+ }
71
+ }
72
+ return fmt.Errorf("project no longer exists")
73
+ })
74
+ }
75
+ case savedMsg:
76
+ // A recent-use write can finish while an edit is still saving. Only
77
+ // the explicit mutation that set busy may release that input guard.
78
+ if msg.notice != "" {
79
+ m.busy = false
80
+ }
81
+ if msg.err != nil {
82
+ m.formError = msg.err.Error()
83
+ return m, m.notify("Could not save project", msg.err)
84
+ }
85
+ selectedID := msg.id
86
+ if msg.notice == "Project added" || msg.notice == "Project updated" {
87
+ m.search.Reset()
88
+ m.searching = false
89
+ m.search.Blur()
90
+ }
91
+ if msg.notice == "" {
92
+ if p, ok := m.current(); ok {
93
+ selectedID = p.ID
94
+ }
95
+ }
96
+ if msg.cfg.Revision >= m.cfg.Revision {
97
+ m.cfg = msg.cfg
98
+ }
99
+ m.selectID(selectedID)
100
+ if msg.notice != "" {
101
+ m.closeModal()
102
+ return m, m.notify(msg.notice, nil)
103
+ }
104
+ case preparedExecMsg:
105
+ if msg.err != nil {
106
+ return m, m.notify(msg.label, msg.err)
107
+ }
108
+ return m, tea.ExecProcess(msg.cmd, func(err error) tea.Msg {
109
+ if err != nil {
110
+ err = fmt.Errorf("%s failed: %w", msg.label, err)
111
+ }
112
+ return actionMsg{msg.id, msg.label + " finished", err, msg.refresh}
113
+ })
114
+ case actionMsg:
115
+ cmds := []tea.Cmd{m.notify(msg.notice, msg.err)}
116
+ if msg.err == nil && msg.id != "" {
117
+ store := m.store
118
+ cmds = append(cmds, func() tea.Msg {
119
+ cfg, err := store.Update(func(cfg *config.Config) error { return config.Touch(cfg, msg.id) })
120
+ return savedMsg{cfg: cfg, id: msg.id, err: err}
121
+ })
122
+ }
123
+ if msg.refresh {
124
+ for _, p := range m.cfg.Projects {
125
+ if p.ID == msg.id {
126
+ cmds = append(cmds, m.refresh(p, true))
127
+ break
128
+ }
129
+ }
130
+ }
131
+ return m, tea.Batch(cmds...)
132
+ case processMsg:
133
+ m.busy = false
134
+ if msg.err != nil {
135
+ return m, m.notify("Command failed", msg.err)
136
+ }
137
+ if m.processID == msg.id {
138
+ m.processState = msg.state
139
+ }
140
+ store := m.store
141
+ return m, func() tea.Msg {
142
+ cfg, err := store.Update(func(cfg *config.Config) error { return config.Touch(cfg, msg.id) })
143
+ return savedMsg{cfg: cfg, id: msg.id, err: err}
144
+ }
145
+ case tickMsg:
146
+ cmds := []tea.Cmd{tick()}
147
+ if p, ok := m.current(); ok {
148
+ cmds = append(cmds, m.refresh(p, false))
149
+ }
150
+ if m.mode == processView {
151
+ m.processState = m.manager.Snapshot(m.processID)
152
+ m.viewport.SetContent(plain(m.processState.Output))
153
+ if m.followOutput {
154
+ m.viewport.GotoBottom()
155
+ }
156
+ }
157
+ return m, tea.Batch(cmds...)
158
+ case spinner.TickMsg:
159
+ var cmd tea.Cmd
160
+ m.spin, cmd = m.spin.Update(msg)
161
+ return m, cmd
162
+ case toastExpired:
163
+ if int(msg) == m.toastID {
164
+ m.toast = ""
165
+ }
166
+ case tea.FocusMsg:
167
+ if p, ok := m.current(); ok {
168
+ return m, m.refresh(p, true)
169
+ }
170
+ case tea.KeyPressMsg:
171
+ return m, m.key(msg)
172
+ default:
173
+ var cmd tea.Cmd
174
+ if m.mode == edit && len(m.fields) > 0 {
175
+ m.fields[m.fieldIndex].input, cmd = m.fields[m.fieldIndex].input.Update(msg)
176
+ } else if m.commandEditing {
177
+ if m.commandField == 0 {
178
+ m.commandName, cmd = m.commandName.Update(msg)
179
+ } else {
180
+ m.commandValue, cmd = m.commandValue.Update(msg)
181
+ }
182
+ } else if m.searching && m.mode == home {
183
+ m.search, cmd = m.search.Update(msg)
184
+ } else {
185
+ m.query, cmd = m.query.Update(msg)
186
+ }
187
+ return m, cmd
188
+ }
189
+ return m, nil
190
+ }
191
+
192
+ func (m *Model) key(msg tea.KeyPressMsg) tea.Cmd {
193
+ key := msg.String()
194
+ if key == "ctrl+c" {
195
+ return m.quit()
196
+ }
197
+ if m.busy {
198
+ if key == "esc" {
199
+ m.closeModal()
200
+ }
201
+ return nil
202
+ }
203
+ if m.mode == home && m.searching {
204
+ switch key {
205
+ case "esc":
206
+ m.searching = false
207
+ m.search.Reset()
208
+ m.search.Blur()
209
+ m.selected = 0
210
+ return nil
211
+ case "enter":
212
+ m.searching = false
213
+ m.search.Blur()
214
+ if p, ok := m.current(); ok {
215
+ return m.refresh(p, false)
216
+ }
217
+ return nil
218
+ case "up", "down":
219
+ return m.navigate(key)
220
+ default:
221
+ var cmd tea.Cmd
222
+ m.search, cmd = m.search.Update(msg)
223
+ m.selected = 0
224
+ return cmd
225
+ }
226
+ }
227
+ if m.mode != home {
228
+ return m.modalKey(msg)
229
+ }
230
+ switch key {
231
+ case "q":
232
+ return m.quit()
233
+ case "up", "down", "j", "k", "home", "end":
234
+ return m.navigate(key)
235
+ case "/":
236
+ m.searching = true
237
+ return m.search.Focus()
238
+ case "esc":
239
+ m.search.Reset()
240
+ m.selected = 0
241
+ case "ctrl+k":
242
+ return m.openMenu(palette)
243
+ case "?":
244
+ m.mode = help
245
+ m.resize()
246
+ m.viewport.GotoTop()
247
+ case "a", "+":
248
+ m.startAdd()
249
+ return m.query.Focus()
250
+ case "enter":
251
+ if _, ok := m.current(); ok {
252
+ return m.openMenu(palette)
253
+ }
254
+ case "v":
255
+ return m.execute(Action{ID: "vscode", Label: "VS Code"})
256
+ case "o":
257
+ return m.execute(Action{ID: "opencode", Label: "OpenCode"})
258
+ case "K", "shift+k":
259
+ return m.execute(Action{ID: "kilocode", Label: "KiloCode"})
260
+ case "c":
261
+ return m.execute(Action{ID: "chatgpt", Label: "ChatGPT"})
262
+ case "t":
263
+ return m.execute(Action{ID: "terminal", Label: "Terminal"})
264
+ case "g":
265
+ return m.execute(Action{ID: "github", Label: "GitHub"})
266
+ case "G", "shift+g":
267
+ return m.openMenu(gitMenu)
268
+ case "r":
269
+ if p, ok := m.current(); ok && m.manager.Snapshot(p.ID).Running {
270
+ m.showProcess(p.ID)
271
+ return nil
272
+ }
273
+ return m.openMenu(commands)
274
+ case "d":
275
+ return m.openMenu(commands)
276
+ case "p":
277
+ if p, ok := m.current(); ok {
278
+ m.showProcess(p.ID)
279
+ }
280
+ case "f":
281
+ return m.execute(Action{ID: "favorite"})
282
+ case "e":
283
+ return m.execute(Action{ID: "edit"})
284
+ case "delete":
285
+ return m.execute(Action{ID: "remove"})
286
+ case "ctrl+r":
287
+ if _, ok := m.current(); ok {
288
+ return m.execute(Action{ID: "refresh"})
289
+ }
290
+ }
291
+ return nil
292
+ }
293
+
294
+ func (m *Model) navigate(key string) tea.Cmd {
295
+ count := len(m.projects())
296
+ if count == 0 {
297
+ return nil
298
+ }
299
+ switch key {
300
+ case "up", "k":
301
+ m.selected = max(0, m.selected-1)
302
+ case "down", "j":
303
+ m.selected = min(count-1, m.selected+1)
304
+ case "home":
305
+ m.selected = 0
306
+ case "end":
307
+ m.selected = count - 1
308
+ }
309
+ p, _ := m.current()
310
+ return m.refresh(p, false)
311
+ }
312
+
313
+ func (m *Model) modalKey(msg tea.KeyPressMsg) tea.Cmd {
314
+ key := msg.String()
315
+ if key == "esc" {
316
+ m.closeModal()
317
+ return nil
318
+ }
319
+ switch m.mode {
320
+ case help:
321
+ if key == "?" || key == "q" || key == "enter" {
322
+ m.closeModal()
323
+ return nil
324
+ }
325
+ var cmd tea.Cmd
326
+ m.viewport, cmd = m.viewport.Update(msg)
327
+ return cmd
328
+ case add:
329
+ if key == "enter" {
330
+ return m.inspectDirectory("add", m.query.Value(), "")
331
+ }
332
+ var cmd tea.Cmd
333
+ m.query, cmd = m.query.Update(msg)
334
+ return cmd
335
+ case addConfirm:
336
+ if key == "enter" {
337
+ m.statuses[m.pending.Project.ID] = m.pending.Git
338
+ return m.confirmAdd()
339
+ }
340
+ var cmd tea.Cmd
341
+ m.viewport, cmd = m.viewport.Update(msg)
342
+ return cmd
343
+ case edit:
344
+ switch key {
345
+ case "ctrl+s":
346
+ return m.submitEdit()
347
+ case "tab", "down", "enter":
348
+ if key == "enter" && m.fields[m.fieldIndex].toggle {
349
+ m.fields[m.fieldIndex].checked = !m.fields[m.fieldIndex].checked
350
+ return nil
351
+ }
352
+ return m.focusField(m.fieldIndex + 1)
353
+ case "shift+tab", "up":
354
+ return m.focusField(m.fieldIndex - 1)
355
+ case " ", "space":
356
+ if m.fields[m.fieldIndex].toggle {
357
+ m.fields[m.fieldIndex].checked = !m.fields[m.fieldIndex].checked
358
+ return nil
359
+ }
360
+ }
361
+ var cmd tea.Cmd
362
+ m.fields[m.fieldIndex].input, cmd = m.fields[m.fieldIndex].input.Update(msg)
363
+ return cmd
364
+ case palette, commands, gitMenu:
365
+ items := m.filteredActions()
366
+ switch key {
367
+ case "up", "ctrl+p":
368
+ m.menuIndex = max(0, m.menuIndex-1)
369
+ case "down", "ctrl+n":
370
+ if key == "ctrl+n" && m.mode == commands {
371
+ m.startCustom("", "")
372
+ return m.commandName.Focus()
373
+ }
374
+ m.menuIndex = min(max(0, len(items)-1), m.menuIndex+1)
375
+ case "enter":
376
+ if len(items) > 0 {
377
+ return m.execute(items[min(m.menuIndex, len(items)-1)])
378
+ }
379
+ case "ctrl+e":
380
+ if m.mode == commands {
381
+ return m.commandEdit(false)
382
+ }
383
+ case "ctrl+d":
384
+ if m.mode == commands {
385
+ return m.commandEdit(true)
386
+ }
387
+ default:
388
+ var cmd tea.Cmd
389
+ m.query, cmd = m.query.Update(msg)
390
+ m.menuIndex = 0
391
+ return cmd
392
+ }
393
+ case removeConfirm:
394
+ if key == "enter" || key == "y" {
395
+ p := m.editProject
396
+ if m.manager.Snapshot(p.ID).Running {
397
+ return m.notify("Command running", fmt.Errorf("stop this project's command before removing it"))
398
+ }
399
+ return m.save("", "Project removed from Rian", func(cfg *config.Config) error { return config.Remove(cfg, p.ID) })
400
+ }
401
+ if key == "n" {
402
+ m.closeModal()
403
+ }
404
+ case quitConfirm:
405
+ if key == "enter" || key == "y" {
406
+ manager := m.manager
407
+ return tea.Sequence(func() tea.Msg { manager.Close(); return nil }, tea.Quit)
408
+ }
409
+ if key == "n" {
410
+ m.closeModal()
411
+ }
412
+ case prompt:
413
+ if m.commandEditing {
414
+ if key == "ctrl+s" {
415
+ return m.submitCustom()
416
+ }
417
+ if key == "tab" || key == "shift+tab" || key == "enter" {
418
+ if m.commandField == 0 {
419
+ m.commandName.Blur()
420
+ m.commandField = 1
421
+ return m.commandValue.Focus()
422
+ }
423
+ if key == "enter" {
424
+ return m.submitCustom()
425
+ }
426
+ m.commandValue.Blur()
427
+ m.commandField = 0
428
+ return m.commandName.Focus()
429
+ }
430
+ var cmd tea.Cmd
431
+ if m.commandField == 0 {
432
+ m.commandName, cmd = m.commandName.Update(msg)
433
+ } else {
434
+ m.commandValue, cmd = m.commandValue.Update(msg)
435
+ }
436
+ return cmd
437
+ }
438
+ if key == "enter" {
439
+ return m.submitPrompt()
440
+ }
441
+ var cmd tea.Cmd
442
+ m.query, cmd = m.query.Update(msg)
443
+ return cmd
444
+ case processView:
445
+ if key == "s" && m.processState.Running {
446
+ manager, id := m.manager, m.processID
447
+ return func() tea.Msg { err := manager.Stop(id); return actionMsg{notice: "Process stopped", err: err} }
448
+ }
449
+ if key == "r" && !m.processState.Running && m.processState.Command != "" {
450
+ p, ok := m.current()
451
+ if ok {
452
+ return m.runCommand(p, "", m.processState.Command)
453
+ }
454
+ }
455
+ if key == "f" {
456
+ m.followOutput = !m.followOutput
457
+ if m.followOutput {
458
+ m.viewport.GotoBottom()
459
+ }
460
+ return nil
461
+ }
462
+ if key == "up" || key == "k" || key == "pgup" || key == "home" {
463
+ m.followOutput = false
464
+ }
465
+ var cmd tea.Cmd
466
+ m.viewport, cmd = m.viewport.Update(msg)
467
+ return cmd
468
+ }
469
+ return nil
470
+ }