@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,85 @@
1
+ package ui
2
+
3
+ import (
4
+ "strings"
5
+ "unicode"
6
+
7
+ "charm.land/bubbles/v2/textinput"
8
+ "charm.land/lipgloss/v2"
9
+ "github.com/charmbracelet/x/ansi"
10
+ )
11
+
12
+ var (
13
+ accent = lipgloss.Color("#fa11b0")
14
+ background = lipgloss.Color("#101015")
15
+ foreground = lipgloss.Color("#e9e6ef")
16
+ mutedColor = lipgloss.Color("#898591")
17
+ borderColor = lipgloss.Color("#37333f")
18
+ textStyle = lipgloss.NewStyle().Foreground(foreground)
19
+ muted = lipgloss.NewStyle().Foreground(mutedColor)
20
+ highlight = lipgloss.NewStyle().Foreground(accent)
21
+ titleStyle = textStyle.Bold(true)
22
+ selectedStyle = lipgloss.NewStyle().Background(lipgloss.Color("#2e182a")).Foreground(foreground)
23
+ good = lipgloss.NewStyle().Foreground(lipgloss.Color("#9bceb0"))
24
+ warning = lipgloss.NewStyle().Foreground(lipgloss.Color("#dfb882"))
25
+ )
26
+
27
+ func input(placeholder string) textinput.Model {
28
+ i := textinput.New()
29
+ i.Prompt = ""
30
+ i.Placeholder = placeholder
31
+ i.CharLimit = 4096
32
+ i.SetWidth(50)
33
+ s := i.Styles()
34
+ s.Focused.Prompt, s.Focused.Text = highlight, textStyle
35
+ s.Focused.Placeholder, s.Blurred.Placeholder = muted, muted
36
+ s.Cursor.Color = accent
37
+ i.SetStyles(s)
38
+ return i
39
+ }
40
+
41
+ // External output is rendered as plain text: OSC hyperlinks, cursor controls,
42
+ // and escape sequences must never be allowed to manipulate the Rian screen.
43
+ func plain(value string) string {
44
+ value = ansi.Strip(value)
45
+ value = strings.ReplaceAll(value, "\r\n", "\n")
46
+ value = strings.ReplaceAll(value, "\r", "\n")
47
+ return strings.Map(func(r rune) rune {
48
+ if r == '\n' {
49
+ return r
50
+ }
51
+ if r == '\t' {
52
+ return ' '
53
+ }
54
+ if unicode.IsControl(r) {
55
+ return -1
56
+ }
57
+ return r
58
+ }, value)
59
+ }
60
+
61
+ func oneLine(value string) string { return strings.ReplaceAll(plain(value), "\n", " ") }
62
+
63
+ func fit(value string, width int) string {
64
+ if width <= 0 {
65
+ return ""
66
+ }
67
+ return ansi.Truncate(value, width, "…")
68
+ }
69
+
70
+ func pad(value string, width int) string {
71
+ value = fit(value, width)
72
+ return value + strings.Repeat(" ", max(0, width-lipgloss.Width(value)))
73
+ }
74
+
75
+ func box(content string, width, height int, active bool) string {
76
+ color := borderColor
77
+ if active {
78
+ color = accent
79
+ }
80
+ return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(color).
81
+ Width(max(1, width)).Height(max(1, height)).Padding(0, 1).
82
+ MaxWidth(width).MaxHeight(height).Render(content)
83
+ }
84
+
85
+ func keyHint(key, action string) string { return highlight.Render(key) + muted.Render(" "+action) }
@@ -0,0 +1,170 @@
1
+ package ui
2
+
3
+ import (
4
+ "io"
5
+ "log"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ tea "charm.land/bubbletea/v2"
13
+ "charm.land/lipgloss/v2"
14
+
15
+ "rian/internal/config"
16
+ "rian/internal/detect"
17
+ "rian/internal/git"
18
+ "rian/internal/process"
19
+ "rian/internal/project"
20
+ )
21
+
22
+ func fixture(t *testing.T) *Model {
23
+ t.Helper()
24
+ dir := t.TempDir()
25
+ m := New(config.New(filepath.Join(dir, "config.json")), process.New(), log.New(io.Discard, "", 0))
26
+ m.width, m.height = 120, 36
27
+ m.resize()
28
+ p := project.New("Cardly", dir)
29
+ p.Favorite = true
30
+ p.Stack = []string{"Node.js", "pnpm", "Next.js"}
31
+ p.AutoCommands = map[string]string{"dev": "pnpm run dev", "build": "pnpm run build", "test": "pnpm run test"}
32
+ p.LastUsed = time.Now().Add(-2 * time.Hour)
33
+ m.cfg = config.Config{Version: 1, Projects: []project.Project{p, project.New("Drift", filepath.Join(dir, "drift")), project.New("Rin", filepath.Join(dir, "rin"))}}
34
+ m.statuses[p.ID] = git.Status{Repository: true, Branch: "main", Modified: 3, Ahead: 1, GitHub: "FatalMistake02/Cardly", Commit: "Fix scanner cache", CommitTime: time.Now().Add(-2 * time.Hour)}
35
+ m.available = map[string]bool{"code": true, "opencode": true, "kilocode": true}
36
+ return m
37
+ }
38
+
39
+ func press(code rune, text string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: code, Text: text} }
40
+
41
+ func TestTextInputTakesPriority(t *testing.T) {
42
+ m := fixture(t)
43
+ m.key(press('/', "/"))
44
+ for _, c := range "car" {
45
+ m.key(press(c, string(c)))
46
+ }
47
+ if !m.searching || m.search.Value() != "car" || m.mode != home || len(m.projects()) != 1 {
48
+ t.Fatalf("searching=%v query=%q mode=%d matches=%d", m.searching, m.search.Value(), m.mode, len(m.projects()))
49
+ }
50
+ m.key(press('q', "q"))
51
+ if m.search.Value() != "carq" {
52
+ t.Fatal("q must type while searching")
53
+ }
54
+ m.key(press(tea.KeyEscape, ""))
55
+ if m.searching || m.search.Value() != "" {
56
+ t.Fatal("Escape did not exit search")
57
+ }
58
+ m.openMenu(palette)
59
+ m.key(press('k', "k"))
60
+ if m.query.Value() != "k" || m.mode != palette {
61
+ t.Fatal("palette input triggered KiloCode")
62
+ }
63
+ m.closeModal()
64
+ m.selected = 1
65
+ m.key(press('k', "k"))
66
+ if m.selected != 0 {
67
+ t.Fatal("k should navigate up")
68
+ }
69
+ }
70
+
71
+ func TestAddConfirmationAndPersistence(t *testing.T) {
72
+ m := fixture(t)
73
+ m.cfg.Projects = nil
74
+ dir := t.TempDir()
75
+ os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.test"), 0600)
76
+ m.startAdd()
77
+ m.query.SetValue(dir)
78
+ inspect := m.key(press(tea.KeyEnter, ""))
79
+ if inspect == nil {
80
+ t.Fatal("inspection missing")
81
+ }
82
+ m.Update(inspect())
83
+ if m.mode != addConfirm || len(m.cfg.Projects) != 0 {
84
+ t.Fatal("add must preview before saving")
85
+ }
86
+ save := m.key(press(tea.KeyEnter, ""))
87
+ m.Update(save())
88
+ cfg, err := m.store.Load()
89
+ if err != nil || len(cfg.Projects) != 1 || m.mode != home || cfg.Projects[0].AutoCommands["test"] != "go test ./..." {
90
+ t.Fatalf("add failed: %+v %v", cfg, err)
91
+ }
92
+ m.execute(Action{ID: "remove"})
93
+ if m.mode != removeConfirm {
94
+ t.Fatal("removal needs confirmation")
95
+ }
96
+ m.key(press(tea.KeyEscape, ""))
97
+ cfg, _ = m.store.Load()
98
+ if len(cfg.Projects) != 1 {
99
+ t.Fatal("cancelled removal changed config")
100
+ }
101
+ }
102
+
103
+ func TestStaleSaveDoesNotRevertNewerState(t *testing.T) {
104
+ m := fixture(t)
105
+ newer := m.cfg
106
+ newer.Revision = 10
107
+ m.Update(savedMsg{cfg: newer})
108
+ m.Update(savedMsg{cfg: config.Config{Version: 1, Revision: 9}})
109
+ if m.cfg.Revision != 10 || len(m.cfg.Projects) != 3 {
110
+ t.Fatal("out-of-order async save reverted the project list")
111
+ }
112
+ }
113
+
114
+ func TestRecentUseWriteDoesNotUnblockPendingSave(t *testing.T) {
115
+ m := fixture(t)
116
+ m.busy = true
117
+ m.Update(savedMsg{cfg: m.cfg})
118
+ if !m.busy {
119
+ t.Fatal("background recent-use write unblocked an unfinished edit")
120
+ }
121
+ }
122
+
123
+ func TestViewsFitTerminal(t *testing.T) {
124
+ for _, size := range [][2]int{{120, 36}, {100, 30}, {80, 24}, {52, 16}, {40, 12}} {
125
+ for _, which := range []mode{home, palette, commands, add, addConfirm, edit, prompt, help, removeConfirm, quitConfirm, processView} {
126
+ m := fixture(t)
127
+ m.width, m.height = size[0], size[1]
128
+ p, _ := m.current()
129
+ m.mode = which
130
+ switch which {
131
+ case palette, commands:
132
+ m.openMenu(which)
133
+ case edit:
134
+ m.startEdit(p)
135
+ case add:
136
+ m.startAdd()
137
+ case addConfirm:
138
+ m.pending = detect.Result{Project: p, Git: m.statuses[p.ID], Available: m.available}
139
+ m.viewport.SetContent(m.detectionSummary(m.pending))
140
+ case removeConfirm:
141
+ m.editProject = p
142
+ case processView:
143
+ m.processState.Command = "pnpm run dev"
144
+ m.viewport.SetContent(strings.Repeat("Recent output\n", 20))
145
+ }
146
+ m.resize()
147
+ view := m.View().Content
148
+ if lipgloss.Width(view) > size[0] || lipgloss.Height(view) > size[1] {
149
+ t.Errorf("mode %d at %v: actual %d × %d", which, size, lipgloss.Width(view), lipgloss.Height(view))
150
+ }
151
+ if size[0] >= 52 && which == home && !strings.Contains(view, "╰") {
152
+ t.Error("bottom panel borders were clipped")
153
+ }
154
+ if size[0] >= 52 && which != home && !strings.Contains(plain(view), "Esc") && which != edit {
155
+ t.Errorf("mode %d at %v: modal footer is hidden", which, size)
156
+ }
157
+ if dir := os.Getenv("RIAN_TEST_RENDER_DIR"); dir != "" && size == [2]int{120, 36} && (which == home || which == palette || which == edit || which == processView) {
158
+ os.MkdirAll(dir, 0700)
159
+ os.WriteFile(filepath.Join(dir, map[mode]string{home: "home", palette: "palette", edit: "edit", processView: "process"}[which]+".ansi"), []byte(view), 0600)
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ func TestExternalOutputCannotControlTerminal(t *testing.T) {
166
+ output := plain("\x1b[31mred\x1b[0m\x1b]0;hijacked\x07\x00\r\nline")
167
+ if strings.ContainsAny(output, "\x1b\x00\x07\r") || !strings.Contains(output, "red") {
168
+ t.Fatalf("unsafe terminal output %q", output)
169
+ }
170
+ }