@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,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
|
+
}
|