@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,459 @@
|
|
|
1
|
+
package ui
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"strings"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
tea "charm.land/bubbletea/v2"
|
|
10
|
+
"charm.land/lipgloss/v2"
|
|
11
|
+
|
|
12
|
+
"rian/internal/detect"
|
|
13
|
+
"rian/internal/project"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func (m *Model) View() tea.View {
|
|
17
|
+
if m.width == 0 {
|
|
18
|
+
v := tea.NewView("Starting Rian…")
|
|
19
|
+
v.AltScreen = true
|
|
20
|
+
return v
|
|
21
|
+
}
|
|
22
|
+
var content string
|
|
23
|
+
if m.width < 52 || m.height < 16 {
|
|
24
|
+
content = fit(highlight.Bold(true).Render("RIAN")+"\n\n"+muted.Render("Resize to at least 52 × 16.\nYour projects are ready when you are.\n\nq quit · Ctrl+K actions"), m.width)
|
|
25
|
+
} else {
|
|
26
|
+
content = m.homeView()
|
|
27
|
+
if m.mode != home {
|
|
28
|
+
modal := m.modalView()
|
|
29
|
+
x, y := max(0, (m.width-lipgloss.Width(modal))/2), max(0, (m.height-lipgloss.Height(modal))/2)
|
|
30
|
+
content = lipgloss.NewCompositor(lipgloss.NewLayer(content), lipgloss.NewLayer(modal).X(x).Y(y).Z(1)).Render()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// A toast is also visible over a modal, where most validation happens.
|
|
34
|
+
if m.toast != "" && m.height >= 16 && m.width >= 52 {
|
|
35
|
+
style := muted
|
|
36
|
+
if m.toastError {
|
|
37
|
+
style = warning
|
|
38
|
+
}
|
|
39
|
+
toast := lipgloss.NewStyle().Background(background).Padding(0, 1).Render(style.Render(fit(oneLine(m.toast), m.width-6)))
|
|
40
|
+
content = lipgloss.NewCompositor(lipgloss.NewLayer(content), lipgloss.NewLayer(toast).X(2).Y(m.height-1).Z(2)).Render()
|
|
41
|
+
}
|
|
42
|
+
v := tea.NewView(content)
|
|
43
|
+
v.AltScreen, v.ReportFocus, v.WindowTitle = true, true, "Rian · Projects"
|
|
44
|
+
v.BackgroundColor, v.ForegroundColor = background, foreground
|
|
45
|
+
return v
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (m *Model) homeView() string {
|
|
49
|
+
w, h := m.width-4, m.height-6
|
|
50
|
+
head := highlight.Bold(true).Render("RIAN") + muted.Render(" / your project desk")
|
|
51
|
+
right := keyHint("Ctrl+K", "actions")
|
|
52
|
+
header := " " + head + strings.Repeat(" ", max(1, w-lipgloss.Width(head)-lipgloss.Width(right))) + right
|
|
53
|
+
leftW := min(44, max(25, w*36/100))
|
|
54
|
+
if m.width < 84 {
|
|
55
|
+
leftW = max(23, w/2-1)
|
|
56
|
+
}
|
|
57
|
+
rightW := w - leftW - 1
|
|
58
|
+
left := box(m.listView(leftW-4, h-2), leftW, h, m.mode == home)
|
|
59
|
+
rightPanel := box(m.detailView(rightW-4, h-2), rightW, h, false)
|
|
60
|
+
body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", rightPanel)
|
|
61
|
+
footer := keyHint("↑↓ / jk", "navigate") + " " + keyHint("a", "add") + " " + keyHint("/", "search") + " " + keyHint("?", "help") + " " + keyHint("q", "quit")
|
|
62
|
+
if m.busy {
|
|
63
|
+
footer = m.spin.View() + muted.Render(" Working…")
|
|
64
|
+
}
|
|
65
|
+
return header + "\n\n" + lipgloss.NewStyle().MarginLeft(2).Render(body) + "\n " + fit(footer, m.width-2) + "\n\n"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func (m *Model) listView(width, height int) string {
|
|
69
|
+
projects := m.projects()
|
|
70
|
+
heading := titleStyle.Render("PROJECTS") + muted.Render(fmt.Sprintf(" %d", len(projects)))
|
|
71
|
+
lines := []string{pad(heading, width), ""}
|
|
72
|
+
if m.searching || m.search.Value() != "" {
|
|
73
|
+
lines = append(lines, fit(highlight.Render("/ ")+m.search.View(), width), "")
|
|
74
|
+
}
|
|
75
|
+
if len(projects) == 0 {
|
|
76
|
+
if m.search.Value() != "" {
|
|
77
|
+
lines = append(lines, muted.Render("No matching projects."), "", muted.Render("Esc clears your search."))
|
|
78
|
+
} else {
|
|
79
|
+
lines = append(lines, "", titleStyle.Render("A home for your work."), "", fit(muted.Render("Save a directory once."), width), fit(muted.Render("Pick up where you left off."), width), "", keyHint("a", "Add your first project"))
|
|
80
|
+
}
|
|
81
|
+
return strings.Join(lines, "\n")
|
|
82
|
+
}
|
|
83
|
+
visible := max(1, (height-len(lines)-2)/3)
|
|
84
|
+
start := max(0, m.selected-visible+1)
|
|
85
|
+
end := min(len(projects), start+visible)
|
|
86
|
+
for i := start; i < end; i++ {
|
|
87
|
+
p := projects[i]
|
|
88
|
+
prefix := " "
|
|
89
|
+
if i == m.selected {
|
|
90
|
+
prefix = "› "
|
|
91
|
+
}
|
|
92
|
+
name := oneLine(p.Name)
|
|
93
|
+
if p.Favorite {
|
|
94
|
+
name = "★ " + name
|
|
95
|
+
}
|
|
96
|
+
status := m.statuses[p.ID]
|
|
97
|
+
branch := fit(oneLine(status.Branch), min(11, width/3))
|
|
98
|
+
marker := ""
|
|
99
|
+
if status.Repository {
|
|
100
|
+
if status.Dirty() {
|
|
101
|
+
marker = warning.Render(" ●")
|
|
102
|
+
} else {
|
|
103
|
+
marker = good.Render(" ○")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if m.loading[p.ID] {
|
|
107
|
+
marker = " " + m.spin.View()
|
|
108
|
+
}
|
|
109
|
+
meta := muted.Render(branch) + marker
|
|
110
|
+
nameWidth := max(4, width-lipgloss.Width(meta)-3)
|
|
111
|
+
row := pad(prefix+fit(name, nameWidth-2), width-lipgloss.Width(meta)) + meta
|
|
112
|
+
if i == m.selected {
|
|
113
|
+
row = selectedStyle.Render(row)
|
|
114
|
+
} else {
|
|
115
|
+
row = textStyle.Render(row)
|
|
116
|
+
}
|
|
117
|
+
lines = append(lines, row, muted.Render(pad(" "+shortPath(p.Path), width)), "")
|
|
118
|
+
}
|
|
119
|
+
for len(lines) < height-1 {
|
|
120
|
+
lines = append(lines, "")
|
|
121
|
+
}
|
|
122
|
+
position := fmt.Sprintf("%d of %d", min(m.selected+1, len(projects)), len(projects))
|
|
123
|
+
if len(projects) > visible {
|
|
124
|
+
position += " · scroll ↑↓"
|
|
125
|
+
}
|
|
126
|
+
lines = append(lines, muted.Render(fit(position, width)))
|
|
127
|
+
return strings.Join(lines, "\n")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func (m *Model) detailView(width, height int) string {
|
|
131
|
+
p, ok := m.current()
|
|
132
|
+
if !ok {
|
|
133
|
+
return strings.Join([]string{titleStyle.Render("READY WHEN YOU ARE"), "", "", highlight.Render("Local projects. Less friction."), "", fit(muted.Render("Open your editor, coding tools, and commands"), width), fit(muted.Render("from one keyboard-first workspace."), width), "", keyHint("a", "add a project"), keyHint("Ctrl+K", "browse actions")}, "\n")
|
|
134
|
+
}
|
|
135
|
+
s := m.statuses[p.ID]
|
|
136
|
+
lines := []string{titleStyle.Render(fit(oneLine(p.Name), width)), muted.Render(fit(shortPath(p.Path), width)), ""}
|
|
137
|
+
if len(p.Stack) > 0 {
|
|
138
|
+
lines = append(lines, muted.Render(fit(strings.Join(p.Stack, " · "), width)), "")
|
|
139
|
+
}
|
|
140
|
+
label := func(name, value string) string {
|
|
141
|
+
return muted.Render(pad(name, min(14, width/3))) + textStyle.Render(fit(oneLine(value), max(1, width-min(14, width/3))))
|
|
142
|
+
}
|
|
143
|
+
repo := p.GitHub
|
|
144
|
+
if repo == "" {
|
|
145
|
+
repo = s.GitHub
|
|
146
|
+
}
|
|
147
|
+
if repo != "" {
|
|
148
|
+
lines = append(lines, highlight.Render("GITHUB"), textStyle.Render(fit(oneLine(repo), width)), "")
|
|
149
|
+
}
|
|
150
|
+
if s.Repository {
|
|
151
|
+
state := good.Render("○ clean")
|
|
152
|
+
if s.Dirty() {
|
|
153
|
+
state = warning.Render("● changes")
|
|
154
|
+
}
|
|
155
|
+
lines = append(lines, label("Branch", s.Branch), label("Status", "")+state, label("Modified", fmt.Sprint(s.Modified)), label("Untracked", fmt.Sprint(s.Untracked)), label("Ahead / back", fmt.Sprintf("↑ %d ↓ %d", s.Ahead, s.Behind)), "")
|
|
156
|
+
if s.Commit != "" {
|
|
157
|
+
lines = append(lines, muted.Render("LAST COMMIT"), fit(oneLine(s.Commit), width), muted.Render(relativeTime(s.CommitTime)), "")
|
|
158
|
+
}
|
|
159
|
+
if s.Remote != "" && repo == "" {
|
|
160
|
+
lines = append(lines, label("Remote", s.Remote))
|
|
161
|
+
}
|
|
162
|
+
} else if m.loading[p.ID] {
|
|
163
|
+
lines = append(lines, m.spin.View()+muted.Render(" Reading Git status…"), "")
|
|
164
|
+
} else {
|
|
165
|
+
lines = append(lines, muted.Render("No Git repository detected."), "")
|
|
166
|
+
}
|
|
167
|
+
if s.Error != "" {
|
|
168
|
+
lines = append(lines, fit(warning.Render(s.Error), width))
|
|
169
|
+
}
|
|
170
|
+
state := m.manager.Snapshot(p.ID)
|
|
171
|
+
if state.Command != "" {
|
|
172
|
+
status := "stopped"
|
|
173
|
+
if state.Running {
|
|
174
|
+
status = "running"
|
|
175
|
+
}
|
|
176
|
+
lines = append(lines, highlight.Render("PROCESS · "+status), fit(oneLine(state.Command), width), keyHint("p", "output"), "")
|
|
177
|
+
}
|
|
178
|
+
if !p.LastUsed.IsZero() {
|
|
179
|
+
lines = append(lines, muted.Render("Used "+relativeTime(p.LastUsed)), "")
|
|
180
|
+
}
|
|
181
|
+
// Keep the action hints anchored below the details at every terminal size.
|
|
182
|
+
hints := []string{keyHint("v", "VS Code") + " " + keyHint("o", "OpenCode"), keyHint("K", "KiloCode") + " " + keyHint("c", "ChatGPT"), keyHint("t", "Terminal") + " " + keyHint("g", "GitHub"), keyHint("r", "Run") + " " + keyHint("d", "Commands") + " " + keyHint("G", "Git")}
|
|
183
|
+
space := max(2, height-len(hints)-1)
|
|
184
|
+
if len(lines) > space {
|
|
185
|
+
lines = lines[:space]
|
|
186
|
+
}
|
|
187
|
+
for len(lines) < space {
|
|
188
|
+
lines = append(lines, "")
|
|
189
|
+
}
|
|
190
|
+
lines = append(lines, muted.Render(strings.Repeat("─", width)))
|
|
191
|
+
for _, hint := range hints {
|
|
192
|
+
lines = append(lines, fit(hint, width))
|
|
193
|
+
}
|
|
194
|
+
return strings.Join(lines, "\n")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func (m *Model) modalView() string {
|
|
198
|
+
w := max(20, min(78, m.width-8))
|
|
199
|
+
inner := w - 6
|
|
200
|
+
var body, title, footer string
|
|
201
|
+
switch m.mode {
|
|
202
|
+
case add:
|
|
203
|
+
title = "ADD PROJECT"
|
|
204
|
+
body = muted.Render("Choose a directory. Rian will inspect it first.") + "\n\n" + m.query.View()
|
|
205
|
+
footer = keyHint("Enter", "inspect") + " " + keyHint("Esc", "cancel")
|
|
206
|
+
case addConfirm:
|
|
207
|
+
title = "PROJECT FOUND"
|
|
208
|
+
body = m.viewport.View()
|
|
209
|
+
footer = keyHint("Enter", "save project") + " " + keyHint("↑↓", "scroll") + " " + keyHint("Esc", "cancel")
|
|
210
|
+
case edit:
|
|
211
|
+
title = "EDIT PROJECT"
|
|
212
|
+
lines := []string{muted.Render("Commands: press d, then Ctrl+N / Ctrl+E."), ""}
|
|
213
|
+
visible := max(3, min(len(m.fields), m.height-13))
|
|
214
|
+
start := max(0, m.fieldIndex-visible+1)
|
|
215
|
+
for i := start; i < min(len(m.fields), start+visible); i++ {
|
|
216
|
+
field := m.fields[i]
|
|
217
|
+
label := muted.Render(pad(field.label, 16))
|
|
218
|
+
value := field.input.View()
|
|
219
|
+
if field.toggle {
|
|
220
|
+
value = muted.Render("[ ] disabled")
|
|
221
|
+
if field.checked {
|
|
222
|
+
value = good.Render("[✓] enabled")
|
|
223
|
+
}
|
|
224
|
+
if field.label == "Favorite" {
|
|
225
|
+
value = "[ ]"
|
|
226
|
+
if field.checked {
|
|
227
|
+
value = highlight.Render("[★]")
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
prefix := " "
|
|
232
|
+
if i == m.fieldIndex {
|
|
233
|
+
prefix = highlight.Render("› ")
|
|
234
|
+
label = textStyle.Render(pad(field.label, 16))
|
|
235
|
+
}
|
|
236
|
+
lines = append(lines, fit(prefix+label+value, inner))
|
|
237
|
+
}
|
|
238
|
+
body = strings.Join(lines, "\n")
|
|
239
|
+
footer = keyHint("Tab / ↑↓", "field") + " " + keyHint("Space", "toggle") + " " + keyHint("Ctrl+S", "save")
|
|
240
|
+
if inner < 54 {
|
|
241
|
+
footer = keyHint("Tab", "field") + " " + keyHint("Space", "toggle") + " " + keyHint("Ctrl+S", "save")
|
|
242
|
+
}
|
|
243
|
+
case palette, commands, gitMenu:
|
|
244
|
+
title = "COMMAND PALETTE"
|
|
245
|
+
if m.mode == commands {
|
|
246
|
+
title = "PROJECT COMMANDS"
|
|
247
|
+
}
|
|
248
|
+
if m.mode == gitMenu {
|
|
249
|
+
title = "GIT ACTIONS"
|
|
250
|
+
}
|
|
251
|
+
items := m.filteredActions()
|
|
252
|
+
lines := []string{m.query.View(), muted.Render(strings.Repeat("─", inner)), ""}
|
|
253
|
+
bodyCapacity := m.height - 11
|
|
254
|
+
if m.mode == commands {
|
|
255
|
+
bodyCapacity--
|
|
256
|
+
}
|
|
257
|
+
stride := 2
|
|
258
|
+
if m.height < 24 {
|
|
259
|
+
stride = 1
|
|
260
|
+
}
|
|
261
|
+
visible := max(1, min(8, (bodyCapacity-3)/stride))
|
|
262
|
+
start := max(0, m.menuIndex-visible+1)
|
|
263
|
+
if len(items) == 0 {
|
|
264
|
+
lines = append(lines, muted.Render("No matching actions."))
|
|
265
|
+
}
|
|
266
|
+
for i := start; i < min(len(items), start+visible); i++ {
|
|
267
|
+
item := items[i]
|
|
268
|
+
prefix := " "
|
|
269
|
+
if i == m.menuIndex {
|
|
270
|
+
prefix = "› "
|
|
271
|
+
}
|
|
272
|
+
row := pad(prefix+oneLine(item.Label), inner)
|
|
273
|
+
if i == m.menuIndex {
|
|
274
|
+
row = selectedStyle.Render(row)
|
|
275
|
+
} else if item.Disabled != "" {
|
|
276
|
+
row = muted.Render(row)
|
|
277
|
+
}
|
|
278
|
+
detail := item.Detail
|
|
279
|
+
if item.Disabled != "" {
|
|
280
|
+
detail = item.Disabled
|
|
281
|
+
}
|
|
282
|
+
lines = append(lines, row)
|
|
283
|
+
if stride == 2 {
|
|
284
|
+
lines = append(lines, muted.Render(fit(" "+oneLine(detail), inner)))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
body = strings.Join(lines, "\n")
|
|
288
|
+
footer = keyHint("↑↓", "choose") + " " + keyHint("Enter", "select") + " " + keyHint("Esc", "close")
|
|
289
|
+
if m.mode == commands {
|
|
290
|
+
footer += "\n" + keyHint("Ctrl+N", "new") + " " + keyHint("Ctrl+E", "edit") + " " + keyHint("Ctrl+D", "remove")
|
|
291
|
+
}
|
|
292
|
+
case help:
|
|
293
|
+
title = "KEYBOARD SHORTCUTS"
|
|
294
|
+
body = m.viewport.View()
|
|
295
|
+
footer = keyHint("↑↓", "scroll") + " " + keyHint("Esc / ?", "close")
|
|
296
|
+
case removeConfirm:
|
|
297
|
+
title = "REMOVE PROJECT?"
|
|
298
|
+
body = titleStyle.Render(fit(oneLine(m.editProject.Name), inner)) + "\n\n" + muted.Render("Only the Rian entry will be removed.\nYour project directory and files stay in place.")
|
|
299
|
+
footer = keyHint("Enter / y", "remove entry") + " " + keyHint("Esc / n", "keep project")
|
|
300
|
+
case quitConfirm:
|
|
301
|
+
title = "COMMANDS ARE STILL RUNNING"
|
|
302
|
+
body = fmt.Sprintf("%d active process(es).\n\nQuitting will stop their process trees.", m.manager.Active())
|
|
303
|
+
footer = keyHint("Enter / y", "stop and quit") + " " + keyHint("Esc / n", "stay")
|
|
304
|
+
case prompt:
|
|
305
|
+
title = "RUN COMMAND"
|
|
306
|
+
if m.promptAction == "commit" {
|
|
307
|
+
title = "COMMIT STAGED CHANGES"
|
|
308
|
+
}
|
|
309
|
+
if m.promptAction == "branch" {
|
|
310
|
+
title = "SWITCH BRANCH"
|
|
311
|
+
}
|
|
312
|
+
body = m.query.View()
|
|
313
|
+
footer = keyHint("Enter", "continue") + " " + keyHint("Esc", "cancel")
|
|
314
|
+
if m.commandEditing {
|
|
315
|
+
title = "CUSTOM COMMAND"
|
|
316
|
+
body = muted.Render("Name (an existing name overrides detection)") + "\n" + m.commandName.View() + "\n\n" + muted.Render("Shell command, run in the project directory") + "\n" + m.commandValue.View()
|
|
317
|
+
footer = keyHint("Tab", "field") + " " + keyHint("Ctrl+S", "save") + " " + keyHint("Esc", "cancel")
|
|
318
|
+
}
|
|
319
|
+
case processView:
|
|
320
|
+
title = "PROCESS OUTPUT"
|
|
321
|
+
s := m.processState
|
|
322
|
+
state := muted.Render("STOPPED")
|
|
323
|
+
if s.Error != "" {
|
|
324
|
+
state = warning.Render("FAILED")
|
|
325
|
+
}
|
|
326
|
+
if s.Running {
|
|
327
|
+
state = good.Render("● RUNNING")
|
|
328
|
+
}
|
|
329
|
+
if s.Command == "" {
|
|
330
|
+
state = muted.Render("No command has run yet.")
|
|
331
|
+
}
|
|
332
|
+
meta := state
|
|
333
|
+
if s.PID > 0 {
|
|
334
|
+
meta += muted.Render(fmt.Sprintf(" PID %d started %s", s.PID, s.Started.Local().Format("15:04:05")))
|
|
335
|
+
}
|
|
336
|
+
body = fit(oneLine(s.Command), inner) + "\n" + fit(meta, inner) + "\n\n" + m.viewport.View()
|
|
337
|
+
if s.Error != "" {
|
|
338
|
+
body += "\n" + warning.Render(fit(oneLine(s.Error), inner))
|
|
339
|
+
}
|
|
340
|
+
follow := "paused"
|
|
341
|
+
if m.followOutput {
|
|
342
|
+
follow = "live"
|
|
343
|
+
}
|
|
344
|
+
footer = keyHint("s", "stop") + " " + keyHint("r", "rerun") + " " + keyHint("f", "follow: "+follow) + " " + keyHint("Esc", "back")
|
|
345
|
+
if inner < 60 {
|
|
346
|
+
footer = keyHint("s", "stop") + " " + keyHint("r", "rerun") + " " + keyHint("f", "follow") + " " + keyHint("Esc", "back")
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if m.formError != "" {
|
|
350
|
+
body += "\n\n" + warning.Render(fit(oneLine(m.formError), inner))
|
|
351
|
+
}
|
|
352
|
+
if m.busy {
|
|
353
|
+
footer = m.spin.View() + muted.Render(" Working…")
|
|
354
|
+
}
|
|
355
|
+
bodyLines := strings.Split(body, "\n")
|
|
356
|
+
footerLines := strings.Split(footer, "\n")
|
|
357
|
+
allowed := max(1, m.height-10-len(footerLines))
|
|
358
|
+
if len(bodyLines) > allowed {
|
|
359
|
+
bodyLines = bodyLines[:allowed]
|
|
360
|
+
}
|
|
361
|
+
for i := range bodyLines {
|
|
362
|
+
bodyLines[i] = fit(bodyLines[i], inner)
|
|
363
|
+
}
|
|
364
|
+
for i := range footerLines {
|
|
365
|
+
footerLines[i] = fit(footerLines[i], inner)
|
|
366
|
+
}
|
|
367
|
+
content := titleStyle.Render(title) + "\n\n" + strings.Join(bodyLines, "\n") + "\n\n" + strings.Join(footerLines, "\n")
|
|
368
|
+
return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).
|
|
369
|
+
Background(background).Foreground(foreground).Padding(1, 2).Width(w).
|
|
370
|
+
MaxWidth(w).MaxHeight(m.height - 2).Render(content)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
func (m *Model) detectionSummary(result detect.Result) string {
|
|
374
|
+
p := result.Project
|
|
375
|
+
lines := []string{titleStyle.Render(oneLine(p.Name)), muted.Render(oneLine(p.Path)), ""}
|
|
376
|
+
if len(p.Stack) > 0 {
|
|
377
|
+
lines = append(lines, "Stack "+strings.Join(p.Stack, " · "))
|
|
378
|
+
}
|
|
379
|
+
if result.Git.Repository {
|
|
380
|
+
lines = append(lines, "Branch "+oneLine(result.Git.Branch))
|
|
381
|
+
}
|
|
382
|
+
if result.Git.GitHub != "" {
|
|
383
|
+
lines = append(lines, "GitHub "+oneLine(result.Git.GitHub))
|
|
384
|
+
}
|
|
385
|
+
lines = append(lines, "", highlight.Render("DETECTED COMMANDS"))
|
|
386
|
+
if len(p.AutoCommands) == 0 {
|
|
387
|
+
lines = append(lines, muted.Render("None yet. Add custom commands with d."))
|
|
388
|
+
}
|
|
389
|
+
for _, name := range detect.CommandNames(p.AutoCommands) {
|
|
390
|
+
lines = append(lines, pad(oneLine(name), 14)+oneLine(p.AutoCommands[name]))
|
|
391
|
+
}
|
|
392
|
+
lines = append(lines, "", highlight.Render("TOOLS"))
|
|
393
|
+
for _, tool := range []string{"code", "opencode", "kilocode", "gh", "lazygit"} {
|
|
394
|
+
status := "not in PATH"
|
|
395
|
+
if result.Available[tool] {
|
|
396
|
+
status = "available"
|
|
397
|
+
}
|
|
398
|
+
lines = append(lines, pad(tool, 14)+status)
|
|
399
|
+
}
|
|
400
|
+
for _, warning := range result.Warnings {
|
|
401
|
+
lines = append(lines, "", oneLine(warning))
|
|
402
|
+
}
|
|
403
|
+
return strings.Join(lines, "\n")
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
func (m *Model) helpView(width int) string {
|
|
407
|
+
rows := []struct{ label, keys string }{
|
|
408
|
+
{"NAVIGATION", "↑↓ / j k projects / fuzzy search Enter actions"},
|
|
409
|
+
{"OPEN", "v VS Code o OpenCode K KiloCode\nt shell c ChatGPT g GitHub"},
|
|
410
|
+
{"RUN", "r run / active process d commands p output\nProcess: s stop r rerun f follow output"},
|
|
411
|
+
{"GIT", "G Git menu Ctrl+R refresh status"},
|
|
412
|
+
{"PROJECT", "a add e edit f favorite Delete remove\nCommands: Ctrl+N new Ctrl+E edit Ctrl+D remove"},
|
|
413
|
+
{"GLOBAL", "Ctrl+K palette ? help q / Ctrl+C quit"},
|
|
414
|
+
}
|
|
415
|
+
var lines []string
|
|
416
|
+
for _, row := range rows {
|
|
417
|
+
lines = append(lines, highlight.Render(row.label))
|
|
418
|
+
for _, line := range strings.Split(row.keys, "\n") {
|
|
419
|
+
lines = append(lines, fit(line, width))
|
|
420
|
+
}
|
|
421
|
+
lines = append(lines, "")
|
|
422
|
+
}
|
|
423
|
+
if m.height < 33 {
|
|
424
|
+
lines = []string{keyHint("↑↓ / jk", "navigate") + " " + keyHint("/", "search"), keyHint("Ctrl+K / Enter", "actions"), keyHint("v / o / K", "VS Code / OpenCode / KiloCode"), keyHint("t / c / g", "shell / ChatGPT / GitHub"), keyHint("r / d / p", "run / commands / output"), keyHint("G / Ctrl+R", "Git menu / refresh"), keyHint("a / e / f", "add / edit / favorite"), keyHint("Delete", "remove entry"), keyHint("q / Ctrl+C", "quit")}
|
|
425
|
+
}
|
|
426
|
+
return strings.Join(lines, "\n")
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
func shortPath(path string) string {
|
|
430
|
+
if home, err := os.UserHomeDir(); err == nil {
|
|
431
|
+
if project.SamePath(path, home) {
|
|
432
|
+
return "~"
|
|
433
|
+
}
|
|
434
|
+
if len(path) > len(home) && project.SamePath(path[:len(home)], home) && (path[len(home)] == '/' || path[len(home)] == '\\') {
|
|
435
|
+
return "~" + oneLine(path[len(home):])
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return oneLine(path)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
func relativeTime(t time.Time) string {
|
|
442
|
+
if t.IsZero() {
|
|
443
|
+
return "—"
|
|
444
|
+
}
|
|
445
|
+
d := time.Since(t)
|
|
446
|
+
if d < time.Minute {
|
|
447
|
+
return "just now"
|
|
448
|
+
}
|
|
449
|
+
if d < time.Hour {
|
|
450
|
+
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
|
451
|
+
}
|
|
452
|
+
if d < 24*time.Hour {
|
|
453
|
+
return fmt.Sprintf("%d hr ago", int(d.Hours()))
|
|
454
|
+
}
|
|
455
|
+
if d < 30*24*time.Hour {
|
|
456
|
+
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
|
457
|
+
}
|
|
458
|
+
return t.Format("Jan 2, 2006")
|
|
459
|
+
}
|
package/lib/launcher.cjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
const { targetFor } = require('./platforms.cjs');
|
|
7
|
+
const { name: packageName } = require('../package.json');
|
|
8
|
+
|
|
9
|
+
function binaryPath(root, platform = process.platform, arch = process.arch) {
|
|
10
|
+
const target = targetFor(platform, arch);
|
|
11
|
+
const binary = path.join(root, 'dist', 'binaries', target.key, target.filename);
|
|
12
|
+
if (!fs.existsSync(binary)) {
|
|
13
|
+
throw new Error(`The Rian executable is missing. Reinstall with: npm install -g ${packageName}`);
|
|
14
|
+
}
|
|
15
|
+
if (platform !== 'win32' && !(fs.statSync(binary).mode & 0o111)) {
|
|
16
|
+
try { fs.chmodSync(binary, 0o755); } catch {
|
|
17
|
+
throw new Error(`The Rian executable needs permission to run. Repair the installation with: npm rebuild -g ${packageName}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return binary;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function launch(root, args = process.argv.slice(2), spawnProcess = spawn) {
|
|
24
|
+
const binary = binaryPath(root);
|
|
25
|
+
// Native stdio must pass straight through: Bubble Tea owns the terminal.
|
|
26
|
+
const child = spawnProcess(binary, args, { stdio: 'inherit', shell: false, windowsHide: false });
|
|
27
|
+
const handlers = new Map();
|
|
28
|
+
for (const signal of ['SIGINT', 'SIGTERM', ...(process.platform === 'win32' ? [] : ['SIGHUP'])]) {
|
|
29
|
+
const handler = () => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
|
|
30
|
+
handlers.set(signal, handler);
|
|
31
|
+
process.on(signal, handler);
|
|
32
|
+
}
|
|
33
|
+
const cleanup = () => { for (const [signal, handler] of handlers) process.removeListener(signal, handler); };
|
|
34
|
+
child.once('error', err => {
|
|
35
|
+
cleanup();
|
|
36
|
+
console.error(`rian: could not start the executable: ${err.message}`);
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
});
|
|
39
|
+
child.once('exit', (code, signal) => {
|
|
40
|
+
cleanup();
|
|
41
|
+
process.exitCode = code ?? ({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }[signal] ?? 1);
|
|
42
|
+
});
|
|
43
|
+
return child;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { binaryPath, launch };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const targets = Object.freeze([
|
|
4
|
+
{ platform: 'darwin', arch: 'arm64', goos: 'darwin', goarch: 'arm64' },
|
|
5
|
+
{ platform: 'darwin', arch: 'x64', goos: 'darwin', goarch: 'amd64' },
|
|
6
|
+
{ platform: 'linux', arch: 'arm64', goos: 'linux', goarch: 'arm64' },
|
|
7
|
+
{ platform: 'linux', arch: 'x64', goos: 'linux', goarch: 'amd64' },
|
|
8
|
+
{ platform: 'win32', arch: 'arm64', goos: 'windows', goarch: 'arm64' },
|
|
9
|
+
{ platform: 'win32', arch: 'x64', goos: 'windows', goarch: 'amd64' },
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function targetFor(platform = process.platform, arch = process.arch) {
|
|
13
|
+
const target = targets.find(item => item.platform === platform && item.arch === arch);
|
|
14
|
+
if (!target) {
|
|
15
|
+
throw new Error(`Rian does not support ${platform}/${arch}. Use macOS, Linux, or Windows on x64 or arm64.`);
|
|
16
|
+
}
|
|
17
|
+
return { ...target, key: `${platform}-${arch}`, filename: platform === 'win32' ? 'rian.exe' : 'rian' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { targets, targetFor };
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fatalmistake02/rian",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Rian: your local project desk. A keyboard-first terminal app for Git, coding tools, and project commands.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"rian": "bin/rian.cjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22"
|
|
11
|
+
},
|
|
12
|
+
"os": ["darwin", "linux", "win32"],
|
|
13
|
+
"cpu": ["arm64", "x64"],
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/rian.cjs",
|
|
16
|
+
"lib/",
|
|
17
|
+
"dist/binaries/",
|
|
18
|
+
"cmd/",
|
|
19
|
+
"internal/",
|
|
20
|
+
"go.mod",
|
|
21
|
+
"go.sum",
|
|
22
|
+
"scripts/",
|
|
23
|
+
"test/npm/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"THIRD_PARTY_NOTICES.txt"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "node scripts/build-binaries.cjs",
|
|
30
|
+
"test": "node --test test/npm/launcher.test.cjs test/npm/package.test.cjs",
|
|
31
|
+
"check:package": "node scripts/check-package.cjs",
|
|
32
|
+
"postinstall": "node scripts/ensure-binary.cjs",
|
|
33
|
+
"prepack": "npm run check:package",
|
|
34
|
+
"prepublishOnly": "npm test && npm run check:package"
|
|
35
|
+
},
|
|
36
|
+
"keywords": ["rian", "tui", "terminal", "projects", "git", "developer-tools", "bubbletea"],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public",
|
|
39
|
+
"registry": "https://registry.npmjs.org/"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const { targets, targetFor } = require('../lib/platforms.cjs');
|
|
8
|
+
|
|
9
|
+
const root = path.resolve(__dirname, '..');
|
|
10
|
+
const pkg = require('../package.json');
|
|
11
|
+
const output = path.join(root, 'dist', 'binaries');
|
|
12
|
+
|
|
13
|
+
function runGo(args, env = {}, capture = false) {
|
|
14
|
+
const result = spawnSync('go', args, { cwd: root, env: { ...process.env, ...env },
|
|
15
|
+
encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', maxBuffer: 10 * 1024 * 1024 });
|
|
16
|
+
if (result.error || result.status !== 0) {
|
|
17
|
+
throw new Error(result.error?.message || result.stderr?.trim() || `go ${args[0]} failed (${result.status})`);
|
|
18
|
+
}
|
|
19
|
+
return result.stdout || '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function collectLicenses() {
|
|
23
|
+
const goroot = runGo(['env', 'GOROOT'], {}, true).trim();
|
|
24
|
+
const parts = ['Rian bundles the Go runtime and the following third-party modules.\nTheir original copyright and license notices are reproduced below.\n',
|
|
25
|
+
`Go runtime\n${runGo(['version'], {}, true).trim()}\n\n${fs.readFileSync(path.join(goroot, 'LICENSE'), 'utf8')}`];
|
|
26
|
+
const modules = runGo(['list', '-m', '-f', '{{.Path}}|{{.Version}}|{{.Dir}}', 'all'], {}, true).trim().split(/\r?\n/);
|
|
27
|
+
for (const entry of modules) {
|
|
28
|
+
const [name, version, dir] = entry.split('|');
|
|
29
|
+
if (!version || !dir) continue;
|
|
30
|
+
const files = fs.readdirSync(dir).filter(name => /^(LICENSE|COPYING|NOTICE)([.-].*)?$/i.test(name)).sort();
|
|
31
|
+
if (!files.length) throw new Error(`Missing license notice for ${name}@${version}`);
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
if (!fs.statSync(path.join(dir, file)).isFile()) continue;
|
|
34
|
+
parts.push(`${name}@${version} — ${file}\n\n${fs.readFileSync(path.join(dir, file), 'utf8')}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
fs.writeFileSync(path.join(root, 'THIRD_PARTY_NOTICES.txt'), parts.join('\n\n' + '='.repeat(72) + '\n\n'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function build() {
|
|
41
|
+
if (!/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(pkg.version)) throw new Error('Invalid package version');
|
|
42
|
+
fs.mkdirSync(output, { recursive: true });
|
|
43
|
+
const manifest = { version: pkg.version, targets: {} };
|
|
44
|
+
for (const item of targets) {
|
|
45
|
+
const target = targetFor(item.platform, item.arch);
|
|
46
|
+
const relative = `${target.key}/${target.filename}`;
|
|
47
|
+
const file = path.join(output, relative);
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
console.log(`Building Rian ${pkg.version} for ${target.key}…`);
|
|
50
|
+
runGo(['build', '-trimpath', '-buildvcs=false', '-ldflags', `-s -w -X main.version=${pkg.version}`, '-o', file, './cmd/rian'],
|
|
51
|
+
{ GOOS: target.goos, GOARCH: target.goarch, CGO_ENABLED: '0' });
|
|
52
|
+
fs.chmodSync(file, 0o755);
|
|
53
|
+
manifest.targets[target.key] = { file: relative, sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') };
|
|
54
|
+
}
|
|
55
|
+
collectLicenses();
|
|
56
|
+
fs.writeFileSync(path.join(output, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
57
|
+
console.log('All six executables and dependency licenses are ready.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try { build(); } catch (err) { console.error(`rian build: ${err.message}`); process.exitCode = 1; }
|