@owdproject/cli 0.1.0 → 0.2.0

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.
@@ -0,0 +1,97 @@
1
+ package tui
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "owd-cli/bridge"
7
+ )
8
+
9
+ func TestCatalogSourceLabel(t *testing.T) {
10
+ tests := []struct {
11
+ name string
12
+ item bridge.CatalogEntry
13
+ localGitDirs map[string]bool
14
+ want string
15
+ }{
16
+ {
17
+ name: "not installed",
18
+ item: bridge.CatalogEntry{
19
+ ShortName: "module-foo",
20
+ Installed: false,
21
+ },
22
+ want: "—",
23
+ },
24
+ {
25
+ name: "installed via npm",
26
+ item: bridge.CatalogEntry{
27
+ ShortName: "module-foo",
28
+ Installed: true,
29
+ InPackageJson: true,
30
+ },
31
+ want: "npm",
32
+ },
33
+ {
34
+ name: "local source with git",
35
+ item: bridge.CatalogEntry{
36
+ ShortName: "module-foo",
37
+ Installed: true,
38
+ LocalSource: true,
39
+ },
40
+ localGitDirs: map[string]bool{"module-foo": true},
41
+ want: "git",
42
+ },
43
+ {
44
+ name: "local source without git",
45
+ item: bridge.CatalogEntry{
46
+ ShortName: "module-foo",
47
+ Installed: true,
48
+ LocalSource: true,
49
+ },
50
+ localGitDirs: map[string]bool{},
51
+ want: "dev",
52
+ },
53
+ {
54
+ name: "installed in config but missing folder",
55
+ item: bridge.CatalogEntry{
56
+ ShortName: "module-foo",
57
+ Installed: true,
58
+ LocalSource: false,
59
+ InPackageJson: false,
60
+ },
61
+ want: "—",
62
+ },
63
+ }
64
+
65
+ for _, tt := range tests {
66
+ t.Run(tt.name, func(t *testing.T) {
67
+ got := catalogSourceLabel(tt.item, tt.localGitDirs)
68
+ if got != tt.want {
69
+ t.Fatalf("catalogSourceLabel() = %q, want %q", got, tt.want)
70
+ }
71
+ })
72
+ }
73
+ }
74
+
75
+ func TestActiveThemeName(t *testing.T) {
76
+ nova := "@owdproject/theme-nova"
77
+ gnome := "@owdproject/theme-gnome"
78
+ ctx := &bridge.WorkspaceContext{
79
+ Config: bridge.Config{Theme: &nova},
80
+ }
81
+
82
+ if got := activeThemeName(ctx, nil); got != nova {
83
+ t.Fatalf("expected saved theme %q, got %q", nova, got)
84
+ }
85
+
86
+ if got := activeThemeName(ctx, &gnome); got != gnome {
87
+ t.Fatalf("expected pending theme %q, got %q", gnome, got)
88
+ }
89
+
90
+ if got := activeThemeName(nil, &gnome); got != gnome {
91
+ t.Fatalf("expected pending theme without ctx %q, got %q", gnome, got)
92
+ }
93
+
94
+ if got := activeThemeName(nil, nil); got != "" {
95
+ t.Fatalf("expected empty theme, got %q", got)
96
+ }
97
+ }
@@ -0,0 +1,423 @@
1
+ package tui
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+
9
+ "owd-cli/bridge"
10
+
11
+ "github.com/charmbracelet/lipgloss"
12
+ )
13
+
14
+ // ─────────────────────────────────────────────
15
+ // ProgressBar & Modal Renderers
16
+ // ─────────────────────────────────────────────
17
+
18
+ func renderProgressBar(step, total, width int) string {
19
+ if total <= 0 {
20
+ return progressBarEmptyStyle.Render(strings.Repeat("░", width))
21
+ }
22
+ filledWidth := (step * width) / total
23
+ if filledWidth > width {
24
+ filledWidth = width
25
+ }
26
+ if filledWidth < 0 {
27
+ filledWidth = 0
28
+ }
29
+ emptyWidth := width - filledWidth
30
+ filled := progressBarFilledStyle.Render(strings.Repeat("█", filledWidth))
31
+ empty := progressBarEmptyStyle.Render(strings.Repeat("░", emptyWidth))
32
+ return filled + empty
33
+ }
34
+
35
+ func (m *TuiModel) renderModal(prompt PromptType) string {
36
+ pkg := m.promptPkg
37
+ switch prompt {
38
+ case PromptInstallMethod:
39
+ return m.renderInstallMethodModal(pkg)
40
+ case PromptUninstallConfirm:
41
+ return m.renderUninstallConfirmModal(pkg)
42
+ case PromptForceReinstallConfirm:
43
+ return m.renderForceReinstallConfirmModal(pkg)
44
+ case PromptResolveDependency:
45
+ return m.renderResolveDependencyModal()
46
+ case PromptSetupProgress:
47
+ return m.renderSetupProgressModal()
48
+ case PromptManagePackage:
49
+ return m.renderManagePackageModal(pkg)
50
+ case PromptSettings:
51
+ return m.renderSettingsModal()
52
+ case PromptWipeWorkspaceConfirm:
53
+ return m.renderWipeWorkspaceConfirmModal()
54
+ default:
55
+ return ""
56
+ }
57
+ }
58
+
59
+ func (m *TuiModel) renderInstallMethodModal(pkg *bridge.CatalogEntry) string {
60
+ var content strings.Builder
61
+ content.WriteString(boldStyle.Render("Install source for ") + accentStyle.Render(pkg.ShortName) + "\n\n")
62
+ methods := m.getInstallMethods(pkg)
63
+ for i, mth := range methods {
64
+ if i > 0 {
65
+ if mth.Name != "local" && methods[i-1].Name == "local" {
66
+ content.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", 62)) + "\n\n")
67
+ } else {
68
+ content.WriteString("\n")
69
+ }
70
+ }
71
+ var btn string
72
+ if i == m.promptSel {
73
+ btn = modalOptionActive.Render(mth.Label)
74
+ } else {
75
+ btn = modalOptionInactive.Render(mth.Label)
76
+ }
77
+ content.WriteString(" " + btn + "\n")
78
+ content.WriteString(" " + mutedStyle.Render(mth.Desc) + "\n")
79
+ }
80
+ content.WriteString("\n" + subtleStyle.Render("↑↓ select Enter confirm Esc cancel"))
81
+ return modalStyle.Width(72).Render(content.String())
82
+ }
83
+
84
+ func (m *TuiModel) renderUninstallConfirmModal(pkg *bridge.CatalogEntry) string {
85
+ var content strings.Builder
86
+ content.WriteString(boldStyle.Render("Uninstall ") + errStyle.Render(pkg.ShortName) + boldStyle.Render("?") + "\n\n")
87
+ opts := []string{"Yes, uninstall", "No, keep it"}
88
+ for i, opt := range opts {
89
+ if i == m.promptSel {
90
+ bgStyle := modalConfirmActiveErr
91
+ if i == 1 {
92
+ bgStyle = modalConfirmActiveAccent
93
+ }
94
+ content.WriteString(bgStyle.Render(" "+opt+" "))
95
+ } else {
96
+ content.WriteString(modalOptionInactive.Render(" " + opt + " "))
97
+ }
98
+ content.WriteString(" ")
99
+ }
100
+ content.WriteString("\n\n" + subtleStyle.Render("← → select Enter confirm Esc cancel"))
101
+ return modalStyle.Render(content.String())
102
+ }
103
+
104
+ func (m *TuiModel) renderForceReinstallConfirmModal(pkg *bridge.CatalogEntry) string {
105
+ var content strings.Builder
106
+ content.WriteString(boldStyle.Render("Force re-download ") + errStyle.Render(pkg.ShortName) + boldStyle.Render("?") + "\n")
107
+ content.WriteString(warnStyle.Render("⚠ WARNING: This will delete the local directory and lose all modifications!") + "\n\n")
108
+ opts := []string{"Yes, wipe & reinstall", "No, cancel"}
109
+ for i, opt := range opts {
110
+ if i == m.promptSel {
111
+ bgStyle := modalConfirmActiveErr
112
+ if i == 1 {
113
+ bgStyle = modalConfirmActiveAccent
114
+ }
115
+ content.WriteString(bgStyle.Render(" "+opt+" "))
116
+ } else {
117
+ content.WriteString(modalOptionInactive.Render(" " + opt + " "))
118
+ }
119
+ content.WriteString(" ")
120
+ }
121
+ content.WriteString("\n\n" + subtleStyle.Render("← → select Enter confirm Esc cancel"))
122
+ return modalStyle.Render(content.String())
123
+ }
124
+
125
+ func (m *TuiModel) renderResolveDependencyModal() string {
126
+ var content strings.Builder
127
+ item := m.promptItem
128
+ shortName := "package"
129
+ if item != nil {
130
+ shortName = item.ShortName
131
+ }
132
+ if item != nil && item.Discovered {
133
+ content.WriteString(boldStyle.Render(shortName) + " was discovered as a required dependency\n\n")
134
+ } else {
135
+ content.WriteString(boldStyle.Render(shortName) + " is configured as workspace:*\n\n")
136
+ }
137
+ content.WriteString("How do you want to resolve it?\n\n")
138
+
139
+ var options []struct {
140
+ Name string
141
+ Label string
142
+ }
143
+ if isLocallyAvailable(m.workspaceRoot, shortName) {
144
+ options = append(options, struct{ Name, Label string }{"local", "Use Existing Local Folder"})
145
+ }
146
+ options = append(options,
147
+ struct{ Name, Label string }{"git-ssh", "Git SSH"},
148
+ struct{ Name, Label string }{"git-https", "Git HTTPS"},
149
+ struct{ Name, Label string }{"npm", "NPM Package"},
150
+ )
151
+ for i, opt := range options {
152
+ if i == m.promptSel {
153
+ content.WriteString(" " + modalOptionActive.Render(opt.Label) + "\n")
154
+ } else {
155
+ content.WriteString(" " + modalOptionInactive.Render(opt.Label) + "\n")
156
+ }
157
+ }
158
+ content.WriteString("\n" + subtleStyle.Render("↑↓ select Enter confirm Esc cancel"))
159
+ return modalStyle.Width(72).Render(content.String())
160
+ }
161
+
162
+ func (m *TuiModel) renderSetupProgressModal() string {
163
+ var content strings.Builder
164
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
165
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
166
+ spin := spinnerStyle.Render(frame)
167
+
168
+ title := "Installing packages…"
169
+ if strings.Contains(strings.ToLower(m.setupLabel), "clean") || strings.Contains(strings.ToLower(m.setupLabel), "reset") || strings.Contains(strings.ToLower(m.setupLabel), "wipe") {
170
+ title = "Resetting workspace…"
171
+ }
172
+ content.WriteString(boldStyle.Render(title) + "\n\n")
173
+ content.WriteString(" " + spin + " " + boldStyle.Render(m.setupLabel) + "\n\n")
174
+
175
+ if m.setupTotalSteps > 0 {
176
+ bar := renderProgressBar(m.setupStep, m.setupTotalSteps, 40)
177
+ content.WriteString(" " + bar + "\n")
178
+ content.WriteString(fmt.Sprintf(" Progress %d/%d\n", m.setupStep, m.setupTotalSteps))
179
+ if m.enginePhase != PhaseIdle && m.enginePhase != "" {
180
+ content.WriteString(" " + mutedStyle.Render("Phase: "+string(m.enginePhase)) + "\n")
181
+ }
182
+ } else {
183
+ content.WriteString(" " + renderProgressBar(0, 1, 40) + "\n")
184
+ content.WriteString(" Preparing...\n")
185
+ }
186
+ return modalStyle.Width(72).Render(content.String())
187
+ }
188
+
189
+ func (m *TuiModel) renderManagePackageModal(pkg *bridge.CatalogEntry) string {
190
+ var content strings.Builder
191
+ var leftCols []string
192
+ leftCols = append(leftCols, boldStyle.Render("Package:")+" "+accentStyle.Render(pkg.ShortName))
193
+ leftCols = append(leftCols, mutedStyle.Render("Type: ")+" "+boldStyle.Render(strings.ToUpper(pkg.Kind)))
194
+
195
+ status := mutedStyle.Render("Not Installed")
196
+ if pkg.Installed {
197
+ status = accentStyle.Render("Installed")
198
+ if pkg.LocalSource {
199
+ status += " " + localSourceTag.Render("(workspace)")
200
+ }
201
+ }
202
+ leftCols = append(leftCols, mutedStyle.Render("Status: ")+" "+status)
203
+
204
+ src := "NPM"
205
+ if pkg.LocalSource {
206
+ src = "LOC (workspace)"
207
+ } else if pkg.Installed && !pkg.LocalSource && !pkg.InPackageJson {
208
+ src = "GIT"
209
+ }
210
+ leftCols = append(leftCols, mutedStyle.Render("Source: ")+" "+boldStyle.Render(src))
211
+
212
+ // Kits
213
+ var kits []string
214
+ for _, dep := range m.promptPkgDeps {
215
+ if strings.HasPrefix(dep, "@owdproject/kit-") {
216
+ shortKit := dep[strings.LastIndex(dep, "/")+1:]
217
+ kits = append(kits, shortKit)
218
+ }
219
+ }
220
+ kitsStr := "none"
221
+ if len(kits) > 0 {
222
+ var styledKits []string
223
+ for _, k := range kits {
224
+ styledKits = append(styledKits, accentStyle.Render(k))
225
+ }
226
+ kitsStr = strings.Join(styledKits, ", ")
227
+ }
228
+ leftCols = append(leftCols, mutedStyle.Render("Kits: ")+" "+kitsStr)
229
+
230
+ gitStatus := "—"
231
+ if pkg.LocalSource {
232
+ kindDir := ""
233
+ switch pkg.Kind {
234
+ case "app":
235
+ kindDir = "apps"
236
+ case "module":
237
+ kindDir = "packages"
238
+ case "theme":
239
+ kindDir = "themes"
240
+ }
241
+ if kindDir != "" {
242
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, pkg.ShortName)
243
+ gitDir := filepath.Join(pkgPath, ".git")
244
+ if _, err := os.Stat(gitDir); err == nil {
245
+ branch := gitBranch(pkgPath)
246
+ changes := gitChanges(pkgPath)
247
+ gitStatus = fmt.Sprintf("branch: %s (%s)", branch, changes)
248
+ }
249
+ }
250
+ }
251
+ leftCols = append(leftCols, mutedStyle.Render("Git: ")+" "+gitStatus)
252
+
253
+ // NPM & GitHub Links
254
+ npmLink := fmt.Sprintf("https://www.npmjs.com/package/%s", pkg.Name)
255
+ leftCols = append(leftCols, mutedStyle.Render("NPM: ")+" "+cyanStyle.Render(npmLink))
256
+
257
+ ghLink := fmt.Sprintf("https://github.com/owdproject/%s", pkg.ShortName)
258
+ if pkg.HtmlUrl != nil && *pkg.HtmlUrl != "" {
259
+ ghLink = *pkg.HtmlUrl
260
+ }
261
+ leftCols = append(leftCols, mutedStyle.Render("GitHub: ")+" "+cyanStyle.Render(ghLink))
262
+
263
+ // Deps (excluding kits)
264
+ var otherDeps []string
265
+ for _, d := range m.promptPkgDeps {
266
+ if !strings.HasPrefix(d, "@owdproject/kit-") {
267
+ s := d
268
+ if idx := strings.LastIndex(d, "/"); idx >= 0 {
269
+ s = d[idx+1:]
270
+ }
271
+ otherDeps = append(otherDeps, s)
272
+ }
273
+ }
274
+ depsStr := "none"
275
+ if len(otherDeps) > 0 {
276
+ depsStr = strings.Join(otherDeps, ", ")
277
+ }
278
+ leftCols = append(leftCols, mutedStyle.Render("Deps: ")+" "+boldStyle.Render(depsStr))
279
+
280
+ leftPanel := strings.Join(leftCols, "\n")
281
+
282
+ var rightCols []string
283
+ rightCols = append(rightCols, boldStyle.Render("Management Actions:")+"\n")
284
+
285
+ actions := ManagePackageActions
286
+
287
+ for i, act := range actions {
288
+ if i == m.promptSel {
289
+ rightCols = append(rightCols, modalOptionActive.Render(fmt.Sprintf(" %s ", act)))
290
+ } else {
291
+ rightCols = append(rightCols, modalOptionInactive.Render(fmt.Sprintf(" %s ", act)))
292
+ }
293
+ }
294
+
295
+ rightPanel := strings.Join(rightCols, "\n")
296
+
297
+ splitLayout := lipgloss.JoinHorizontal(lipgloss.Top,
298
+ managePackageLeftPanelStyle.Render(leftPanel),
299
+ managePackageRightBorder.Render(rightPanel),
300
+ )
301
+
302
+ content.WriteString(splitLayout)
303
+ content.WriteString("\n\n" + subtleStyle.Render("↑↓ select Enter confirm Esc close"))
304
+
305
+ return modalStyle.Width(110).Render(content.String())
306
+ }
307
+
308
+ func (m *TuiModel) renderSettingsModal() string {
309
+ var content strings.Builder
310
+ content.WriteString(boldStyle.Render("Control Panel Settings") + "\n\n")
311
+
312
+ // 1. Install Mode
313
+ modeLabel := "1. Install Mode"
314
+ var modeVal string
315
+ if m.settingsInstallMode == "npm" {
316
+ modeVal = modalOptionActive.Render(" NPM registry ") + " " + modalOptionInactive.Render(" workspace ")
317
+ } else {
318
+ modeVal = modalOptionInactive.Render(" NPM registry ") + " " + modalOptionActive.Render(" workspace ")
319
+ }
320
+ if m.settingsSel == 0 {
321
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(modeLabel) + "\n " + modeVal + "\n")
322
+ } else {
323
+ content.WriteString(" " + boldStyle.Render(modeLabel) + "\n " + modeVal + "\n")
324
+ }
325
+ content.WriteString("\n")
326
+
327
+ // 2. Catalog Sort
328
+ sortLabel := "2. Catalog Sort"
329
+ var sortVal strings.Builder
330
+ sortModes := []string{"updated", "name", "stars", "installed"}
331
+ for _, sm := range sortModes {
332
+ if sm == m.settingsCatalogSort {
333
+ sortVal.WriteString(modalOptionActive.Render(" "+sm+" ") + " ")
334
+ } else {
335
+ sortVal.WriteString(modalOptionInactive.Render(" "+sm+" ") + " ")
336
+ }
337
+ }
338
+ if m.settingsSel == 1 {
339
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(sortLabel) + "\n " + sortVal.String() + "\n")
340
+ } else {
341
+ content.WriteString(" " + boldStyle.Render(sortLabel) + "\n " + sortVal.String() + "\n")
342
+ }
343
+ content.WriteString("\n")
344
+
345
+ // 3. Trusted Orgs (Authors)
346
+ orgsLabel := "3. Trusted Orgs"
347
+ var orgsValText string
348
+ if m.settingsSel == 2 {
349
+ orgsValText = m.settingsOrgsInput.View()
350
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(orgsLabel) + "\n " + orgsValText + "\n")
351
+ } else {
352
+ orgsValText = subtleStyle.Render(m.settingsOrgsInput.Value())
353
+ content.WriteString(" " + boldStyle.Render(orgsLabel) + "\n " + orgsValText + "\n")
354
+ }
355
+ content.WriteString("\n")
356
+
357
+ // 4. GitHub User
358
+ ghLabel := "4. GitHub User"
359
+ var ghValText string
360
+ if m.settingsSel == 3 {
361
+ ghValText = m.settingsUserInput.View()
362
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(ghLabel) + "\n " + ghValText + "\n")
363
+ } else {
364
+ val := m.settingsUserInput.Value()
365
+ if val == "" {
366
+ val = "(not set)"
367
+ }
368
+ ghValText = subtleStyle.Render(val)
369
+ content.WriteString(" " + boldStyle.Render(ghLabel) + "\n " + ghValText + "\n")
370
+ }
371
+ content.WriteString("\n")
372
+
373
+ // 5. Reset Workspace
374
+ resetLabel := "5. Reset Workspace"
375
+ resetVal := modalOptionInactive.Render(" [ WIPE EVERYTHING ] ")
376
+ if m.settingsSel == 4 {
377
+ resetVal = modalConfirmActiveErr.Render(" [ WIPE EVERYTHING ] ")
378
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(resetLabel) + "\n " + resetVal + "\n")
379
+ } else {
380
+ content.WriteString(" " + boldStyle.Render(resetLabel) + "\n " + resetVal + "\n")
381
+ }
382
+ content.WriteString("\n\n")
383
+
384
+ // Buttons Row: Save and Cancel
385
+ var saveBtn, cancelBtn string
386
+ if m.settingsSel == 5 {
387
+ saveBtn = settingsSaveActive.Render(" SAVE ")
388
+ } else {
389
+ saveBtn = settingsSaveInactive.Render(" SAVE ")
390
+ }
391
+
392
+ if m.settingsSel == 6 {
393
+ cancelBtn = settingsCancelActive.Render(" CANCEL ")
394
+ } else {
395
+ cancelBtn = settingsCancelInactive.Render(" CANCEL ")
396
+ }
397
+
398
+ content.WriteString(" " + saveBtn + " " + cancelBtn + "\n\n")
399
+ content.WriteString(subtleStyle.Render("↑↓ select item ←→ toggle/buttons Enter confirm Esc exit"))
400
+ return modalStyle.Width(72).Render(content.String())
401
+ }
402
+
403
+ func (m *TuiModel) renderWipeWorkspaceConfirmModal() string {
404
+ var content strings.Builder
405
+ content.WriteString(boldStyle.Render("Wipe & Reset Workspace?") + "\n")
406
+ content.WriteString(errStyle.Render("⚠ CAUTION: This will delete ALL non-core applications, modules, and themes!") + "\n")
407
+ content.WriteString(subtleStyle.Render("Your configuration files (desktop.config.ts) and desktop package.json will be reset.") + "\n\n")
408
+ opts := []string{"Yes, wipe everything", "No, cancel"}
409
+ for i, opt := range opts {
410
+ if i == m.promptSel {
411
+ bgStyle := modalConfirmActiveErr
412
+ if i == 1 {
413
+ bgStyle = modalConfirmActiveAccent
414
+ }
415
+ content.WriteString(bgStyle.Render(" " + opt + " "))
416
+ } else {
417
+ content.WriteString(modalOptionInactive.Render(" " + opt + " "))
418
+ }
419
+ content.WriteString(" ")
420
+ }
421
+ content.WriteString("\n\n" + subtleStyle.Render("← → select Enter confirm Esc cancel"))
422
+ return modalStyle.Render(content.String())
423
+ }