@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.
- package/README.md +7 -7
- package/bin/bridge.js +4 -2
- package/bin/cli.js +63 -5
- package/bin/cp.js +42 -23
- package/bin/lib/catalog.js +3 -0
- package/bin/lib/config.js +51 -8
- package/bin/lib/cpFormat.js +2 -2
- package/bin/lib/create.js +246 -0
- package/bin/lib/desktopConfig.js +48 -8
- package/bin/lib/generateTemplate.js +2 -1
- package/bin/lib/install.js +17 -21
- package/bin/lib/installWizardFlow.js +90 -24
- package/bin/lib/packageSources.js +10 -0
- package/bin/lib/status.js +2 -0
- package/bin/lib/validateModule.js +112 -2
- package/bin/lib/workspace.js +68 -1
- package/go/bridge/bridge.go +20 -0
- package/go/desktop-go +0 -0
- package/go/go.mod +17 -10
- package/go/go.sum +34 -19
- package/go/main.go +7 -3
- package/go/owd-cli +0 -0
- package/go/tui/engine.go +472 -0
- package/go/tui/engine_test.go +40 -0
- package/go/tui/handlers.go +604 -0
- package/go/tui/helpers.go +916 -0
- package/go/tui/helpers_test.go +97 -0
- package/go/tui/modals.go +423 -0
- package/go/tui/resolver.go +377 -0
- package/go/tui/resolver_test.go +86 -0
- package/go/tui/styles.go +153 -0
- package/go/tui/tasks.go +850 -0
- package/go/tui/tui.go +451 -2596
- package/go/tui/tui_test.go +69 -0
- package/go/tui/types.go +230 -0
- package/go/tui/view.go +812 -0
- package/go/tui/workqueue.go +179 -0
- package/go/tui/workqueue_test.go +90 -0
- package/package.json +2 -2
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
package tui
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"fmt"
|
|
8
|
+
"math/rand"
|
|
9
|
+
"net"
|
|
10
|
+
"net/http"
|
|
11
|
+
"net/url"
|
|
12
|
+
"os"
|
|
13
|
+
"os/exec"
|
|
14
|
+
"path/filepath"
|
|
15
|
+
"strings"
|
|
16
|
+
"time"
|
|
17
|
+
|
|
18
|
+
"github.com/charmbracelet/lipgloss"
|
|
19
|
+
|
|
20
|
+
"owd-cli/bridge"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────
|
|
24
|
+
// Helpers & Utilities
|
|
25
|
+
// ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
func isPortOpen(port int) bool {
|
|
28
|
+
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 500*time.Millisecond)
|
|
29
|
+
if err != nil {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
conn.Close()
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func findProcessPID(port int) int {
|
|
37
|
+
// Try lsof first with TCP:LISTEN filter to get only the listening process
|
|
38
|
+
cmd := exec.Command("lsof", "-t", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN")
|
|
39
|
+
var stdout bytes.Buffer
|
|
40
|
+
cmd.Stdout = &stdout
|
|
41
|
+
if err := cmd.Run(); err == nil {
|
|
42
|
+
var pid int
|
|
43
|
+
if _, err := fmt.Sscanf(strings.TrimSpace(stdout.String()), "%d", &pid); err == nil && pid > 0 {
|
|
44
|
+
return pid
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Try pgrep patterns
|
|
49
|
+
patterns := []string{
|
|
50
|
+
"nuxt dev",
|
|
51
|
+
"nuxi dev",
|
|
52
|
+
"nuxt.mjs dev",
|
|
53
|
+
"nuxi.mjs dev",
|
|
54
|
+
"nx run desktop:serve",
|
|
55
|
+
}
|
|
56
|
+
for _, pat := range patterns {
|
|
57
|
+
cmd = exec.Command("pgrep", "-f", pat)
|
|
58
|
+
stdout.Reset()
|
|
59
|
+
cmd.Stdout = &stdout
|
|
60
|
+
if err := cmd.Run(); err == nil {
|
|
61
|
+
lines := strings.Split(stdout.String(), "\n")
|
|
62
|
+
for _, line := range lines {
|
|
63
|
+
line = strings.TrimSpace(line)
|
|
64
|
+
if line == "" {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
var pid int
|
|
68
|
+
if _, err := fmt.Sscanf(line, "%d", &pid); err == nil && pid > 0 {
|
|
69
|
+
return pid
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func isLocallyAvailable(root, shortName string) bool {
|
|
78
|
+
kindDirs := []string{"apps", "packages", "themes"}
|
|
79
|
+
for _, kd := range kindDirs {
|
|
80
|
+
if _, err := os.Stat(filepath.Join(root, kd, shortName)); err == nil {
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func getThemeDependencies(workspaceRoot, themeShortName string) ([]string, error) {
|
|
88
|
+
var pathsToCheck []string
|
|
89
|
+
pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "themes", themeShortName, "package.json"))
|
|
90
|
+
pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "node_modules", "@owdproject", themeShortName, "package.json"))
|
|
91
|
+
pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "desktop", "node_modules", "@owdproject", themeShortName, "package.json"))
|
|
92
|
+
|
|
93
|
+
var data []byte
|
|
94
|
+
var err error
|
|
95
|
+
for _, p := range pathsToCheck {
|
|
96
|
+
data, err = os.ReadFile(p)
|
|
97
|
+
if err == nil {
|
|
98
|
+
break
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if err != nil {
|
|
103
|
+
return nil, err
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
var pkg struct {
|
|
107
|
+
Dependencies map[string]string `json:"dependencies"`
|
|
108
|
+
DevDependencies map[string]string `json:"devDependencies"`
|
|
109
|
+
}
|
|
110
|
+
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
111
|
+
return nil, err
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
var deps []string
|
|
115
|
+
seen := make(map[string]bool)
|
|
116
|
+
for name := range pkg.Dependencies {
|
|
117
|
+
if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
|
|
118
|
+
deps = append(deps, name)
|
|
119
|
+
seen[name] = true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for name := range pkg.DevDependencies {
|
|
123
|
+
if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
|
|
124
|
+
deps = append(deps, name)
|
|
125
|
+
seen[name] = true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return deps, nil
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// getOwdDepsLocal reads package.json for any OWD package (app/module/theme) from the local filesystem.
|
|
132
|
+
// Returns all @owdproject/* dependencies whose shortname starts with "module-" or "kit-".
|
|
133
|
+
// Checks multiple locations: workspace subdirs + node_modules.
|
|
134
|
+
func getOwdDepsLocal(workspaceRoot, shortName, kind string) ([]string, bool) {
|
|
135
|
+
kindDir := ""
|
|
136
|
+
switch kind {
|
|
137
|
+
case "app":
|
|
138
|
+
kindDir = "apps"
|
|
139
|
+
case "module":
|
|
140
|
+
kindDir = "packages"
|
|
141
|
+
case "theme":
|
|
142
|
+
kindDir = "themes"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
var pathsToCheck []string
|
|
146
|
+
if kindDir != "" {
|
|
147
|
+
pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, kindDir, shortName, "package.json"))
|
|
148
|
+
}
|
|
149
|
+
pathsToCheck = append(pathsToCheck,
|
|
150
|
+
filepath.Join(workspaceRoot, "node_modules", "@owdproject", shortName, "package.json"),
|
|
151
|
+
filepath.Join(workspaceRoot, "desktop", "node_modules", "@owdproject", shortName, "package.json"),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for _, p := range pathsToCheck {
|
|
155
|
+
data, err := os.ReadFile(p)
|
|
156
|
+
if err != nil {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
var pkg struct {
|
|
160
|
+
Dependencies map[string]string `json:"dependencies"`
|
|
161
|
+
DevDependencies map[string]string `json:"devDependencies"`
|
|
162
|
+
}
|
|
163
|
+
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
var deps []string
|
|
167
|
+
seen := make(map[string]bool)
|
|
168
|
+
allDeps := make(map[string]string)
|
|
169
|
+
for k, v := range pkg.Dependencies {
|
|
170
|
+
allDeps[k] = v
|
|
171
|
+
}
|
|
172
|
+
for k, v := range pkg.DevDependencies {
|
|
173
|
+
allDeps[k] = v
|
|
174
|
+
}
|
|
175
|
+
for name := range allDeps {
|
|
176
|
+
if !strings.HasPrefix(name, "@owdproject/") || seen[name] {
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
short := name[strings.LastIndex(name, "/")+1:]
|
|
180
|
+
if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") || strings.HasPrefix(short, "app-") || strings.HasPrefix(short, "theme-") {
|
|
181
|
+
deps = append(deps, name)
|
|
182
|
+
seen[name] = true
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return deps, true
|
|
186
|
+
}
|
|
187
|
+
return nil, false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// getOwdDepsFromNpm fetches package.json from npm registry for an @owdproject package
|
|
191
|
+
// and returns @owdproject/* deps whose shortname starts with "module-" or "kit-".
|
|
192
|
+
func getOwdDepsFromNpm(pkgName string) ([]string, error) {
|
|
193
|
+
client := &http.Client{Timeout: 5 * time.Second}
|
|
194
|
+
resp, err := client.Get(fmt.Sprintf("https://registry.npmjs.org/%s/latest", url.PathEscape(pkgName)))
|
|
195
|
+
if err != nil {
|
|
196
|
+
return nil, err
|
|
197
|
+
}
|
|
198
|
+
defer resp.Body.Close()
|
|
199
|
+
if resp.StatusCode != http.StatusOK {
|
|
200
|
+
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
201
|
+
}
|
|
202
|
+
var data struct {
|
|
203
|
+
Dependencies map[string]string `json:"dependencies"`
|
|
204
|
+
DevDependencies map[string]string `json:"devDependencies"`
|
|
205
|
+
}
|
|
206
|
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
207
|
+
return nil, err
|
|
208
|
+
}
|
|
209
|
+
var deps []string
|
|
210
|
+
seen := make(map[string]bool)
|
|
211
|
+
allDeps := make(map[string]string)
|
|
212
|
+
for k, v := range data.Dependencies {
|
|
213
|
+
allDeps[k] = v
|
|
214
|
+
}
|
|
215
|
+
for k, v := range data.DevDependencies {
|
|
216
|
+
allDeps[k] = v
|
|
217
|
+
}
|
|
218
|
+
for name := range allDeps {
|
|
219
|
+
if !strings.HasPrefix(name, "@owdproject/") || seen[name] {
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
short := name[strings.LastIndex(name, "/")+1:]
|
|
223
|
+
if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") || strings.HasPrefix(short, "app-") || strings.HasPrefix(short, "theme-") {
|
|
224
|
+
deps = append(deps, name)
|
|
225
|
+
seen[name] = true
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return deps, nil
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
func wipeDirectory(dir string, preserve map[string]bool) error {
|
|
234
|
+
entries, err := os.ReadDir(dir)
|
|
235
|
+
if err != nil {
|
|
236
|
+
if os.IsNotExist(err) {
|
|
237
|
+
return nil
|
|
238
|
+
}
|
|
239
|
+
return err
|
|
240
|
+
}
|
|
241
|
+
for _, entry := range entries {
|
|
242
|
+
if entry.IsDir() {
|
|
243
|
+
name := entry.Name()
|
|
244
|
+
if preserve != nil && preserve[name] {
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
err := os.RemoveAll(filepath.Join(dir, name))
|
|
248
|
+
if err != nil {
|
|
249
|
+
return err
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return nil
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func runGitStatus(dir string) (added, modified, deleted int, err error) {
|
|
257
|
+
cmd := exec.Command("git", "status", "--porcelain")
|
|
258
|
+
cmd.Dir = dir
|
|
259
|
+
out, err := cmd.Output()
|
|
260
|
+
if err != nil {
|
|
261
|
+
return 0, 0, 0, err
|
|
262
|
+
}
|
|
263
|
+
lines := strings.Split(string(out), "\n")
|
|
264
|
+
for _, line := range lines {
|
|
265
|
+
if len(line) < 3 {
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
status := line[:2]
|
|
269
|
+
if strings.HasPrefix(status, "??") {
|
|
270
|
+
added++
|
|
271
|
+
} else if strings.Contains(status, "D") {
|
|
272
|
+
deleted++
|
|
273
|
+
} else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
|
|
274
|
+
modified++
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return added, modified, deleted, nil
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
func runGitStatusForSubdir(rootDir, subdir string) (added, modified, deleted int, err error) {
|
|
281
|
+
cmd := exec.Command("git", "status", "--porcelain", subdir)
|
|
282
|
+
cmd.Dir = rootDir
|
|
283
|
+
out, err := cmd.Output()
|
|
284
|
+
if err != nil {
|
|
285
|
+
return 0, 0, 0, err
|
|
286
|
+
}
|
|
287
|
+
lines := strings.Split(string(out), "\n")
|
|
288
|
+
for _, line := range lines {
|
|
289
|
+
if len(line) < 3 {
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
status := line[:2]
|
|
293
|
+
if strings.HasPrefix(status, "??") {
|
|
294
|
+
added++
|
|
295
|
+
} else if strings.Contains(status, "D") {
|
|
296
|
+
deleted++
|
|
297
|
+
} else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
|
|
298
|
+
modified++
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return added, modified, deleted, nil
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
func runGitBehindCheck(dir string) (int, error) {
|
|
305
|
+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
306
|
+
defer cancel()
|
|
307
|
+
|
|
308
|
+
cmdFetch := exec.CommandContext(ctx, "git", "fetch")
|
|
309
|
+
cmdFetch.Dir = dir
|
|
310
|
+
_ = cmdFetch.Run() // ignore fetch errors
|
|
311
|
+
|
|
312
|
+
cmdRev := exec.Command("git", "rev-list", "--count", "HEAD..@{u}")
|
|
313
|
+
cmdRev.Dir = dir
|
|
314
|
+
out, err := cmdRev.Output()
|
|
315
|
+
if err != nil {
|
|
316
|
+
// Try with origin/branch
|
|
317
|
+
cmdBranch := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
318
|
+
cmdBranch.Dir = dir
|
|
319
|
+
branchOut, errBranch := cmdBranch.Output()
|
|
320
|
+
if errBranch != nil {
|
|
321
|
+
return 0, errBranch
|
|
322
|
+
}
|
|
323
|
+
branch := strings.TrimSpace(string(branchOut))
|
|
324
|
+
if branch == "" || branch == "HEAD" {
|
|
325
|
+
return 0, err
|
|
326
|
+
}
|
|
327
|
+
cmdRev = exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..origin/%s", branch))
|
|
328
|
+
cmdRev.Dir = dir
|
|
329
|
+
out, err = cmdRev.Output()
|
|
330
|
+
if err != nil {
|
|
331
|
+
return 0, err
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
var behind int
|
|
336
|
+
_, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &behind)
|
|
337
|
+
return behind, err
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// runGitBehindCheckNoFetch checks how many commits behind the remote the local repo is,
|
|
341
|
+
// WITHOUT running git fetch. Uses already-cached remote refs only — no network activity.
|
|
342
|
+
func runGitBehindCheckNoFetch(dir string) (int, error) {
|
|
343
|
+
cmdRev := exec.Command("git", "rev-list", "--count", "HEAD..@{u}")
|
|
344
|
+
cmdRev.Dir = dir
|
|
345
|
+
out, err := cmdRev.Output()
|
|
346
|
+
if err != nil {
|
|
347
|
+
// Fallback: try origin/<branch>
|
|
348
|
+
cmdBranch := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
|
|
349
|
+
cmdBranch.Dir = dir
|
|
350
|
+
branchOut, errBranch := cmdBranch.Output()
|
|
351
|
+
if errBranch != nil {
|
|
352
|
+
return 0, errBranch
|
|
353
|
+
}
|
|
354
|
+
branch := strings.TrimSpace(string(branchOut))
|
|
355
|
+
if branch == "" || branch == "HEAD" {
|
|
356
|
+
return 0, err
|
|
357
|
+
}
|
|
358
|
+
cmdRev = exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..origin/%s", branch))
|
|
359
|
+
cmdRev.Dir = dir
|
|
360
|
+
out, err = cmdRev.Output()
|
|
361
|
+
if err != nil {
|
|
362
|
+
return 0, err
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
var behind int
|
|
366
|
+
_, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &behind)
|
|
367
|
+
return behind, err
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
func fetchLatestNpmVersion(pkgName string) (string, error) {
|
|
372
|
+
client := &http.Client{Timeout: 3 * time.Second}
|
|
373
|
+
resp, err := client.Get(fmt.Sprintf("https://registry.npmjs.org/%s/latest", url.PathEscape(pkgName)))
|
|
374
|
+
if err != nil {
|
|
375
|
+
return "", err
|
|
376
|
+
}
|
|
377
|
+
defer resp.Body.Close()
|
|
378
|
+
if resp.StatusCode != http.StatusOK {
|
|
379
|
+
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
380
|
+
}
|
|
381
|
+
var data struct {
|
|
382
|
+
Version string `json:"version"`
|
|
383
|
+
}
|
|
384
|
+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
|
385
|
+
return "", err
|
|
386
|
+
}
|
|
387
|
+
return data.Version, nil
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
func sparkline(vals []int) string {
|
|
391
|
+
blocks := []rune{'\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'}
|
|
392
|
+
if len(vals) == 0 {
|
|
393
|
+
mutedBar := lipgloss.NewStyle().Foreground(colorDim).Render(string('\u2581'))
|
|
394
|
+
return strings.Repeat(mutedBar, 12)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
min := vals[0]
|
|
398
|
+
max := vals[0]
|
|
399
|
+
for _, v := range vals {
|
|
400
|
+
if v < min {
|
|
401
|
+
min = v
|
|
402
|
+
}
|
|
403
|
+
if v > max {
|
|
404
|
+
max = v
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
var sb strings.Builder
|
|
409
|
+
show := vals
|
|
410
|
+
if len(show) > 12 {
|
|
411
|
+
show = show[len(show)-12:]
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for _, v := range show {
|
|
415
|
+
var idx int
|
|
416
|
+
if max == min {
|
|
417
|
+
if v == 0 {
|
|
418
|
+
idx = 0
|
|
419
|
+
} else {
|
|
420
|
+
idx = 2
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
idx = int(float64(v-min) / float64(max-min) * float64(len(blocks)-1))
|
|
424
|
+
if idx < 0 {
|
|
425
|
+
idx = 0
|
|
426
|
+
}
|
|
427
|
+
if idx >= len(blocks) {
|
|
428
|
+
idx = len(blocks) - 1
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
var color lipgloss.Color
|
|
433
|
+
if v == 0 {
|
|
434
|
+
color = colorDim
|
|
435
|
+
} else {
|
|
436
|
+
if v > 1024 {
|
|
437
|
+
color = colorErr
|
|
438
|
+
} else if v > 512 {
|
|
439
|
+
color = colorWarn
|
|
440
|
+
} else {
|
|
441
|
+
color = colorCyan
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
styledRune := lipgloss.NewStyle().Foreground(color).Render(string(blocks[idx]))
|
|
446
|
+
sb.WriteString(styledRune)
|
|
447
|
+
}
|
|
448
|
+
return sb.String()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
func gitBranch(root string) string {
|
|
452
|
+
out, err := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
|
453
|
+
if err != nil {
|
|
454
|
+
return "—"
|
|
455
|
+
}
|
|
456
|
+
return strings.TrimSpace(string(out))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
func gitChanges(root string) string {
|
|
460
|
+
out, err := exec.Command("git", "-C", root, "status", "--porcelain").Output()
|
|
461
|
+
if err != nil {
|
|
462
|
+
return "unknown"
|
|
463
|
+
}
|
|
464
|
+
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
465
|
+
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
|
|
466
|
+
return "clean"
|
|
467
|
+
}
|
|
468
|
+
mod, add, del := 0, 0, 0
|
|
469
|
+
for _, l := range lines {
|
|
470
|
+
if len(l) < 2 {
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
switch {
|
|
474
|
+
case strings.HasPrefix(l, "??"):
|
|
475
|
+
add++
|
|
476
|
+
case strings.HasPrefix(l, " D") || strings.HasPrefix(l, "D "):
|
|
477
|
+
del++
|
|
478
|
+
default:
|
|
479
|
+
mod++
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
var parts []string
|
|
483
|
+
if add > 0 {
|
|
484
|
+
parts = append(parts, accentStyle.Render(fmt.Sprintf("+%d", add)))
|
|
485
|
+
}
|
|
486
|
+
if mod > 0 {
|
|
487
|
+
parts = append(parts, warnStyle.Render(fmt.Sprintf("~%d", mod)))
|
|
488
|
+
}
|
|
489
|
+
if del > 0 {
|
|
490
|
+
parts = append(parts, errStyle.Render(fmt.Sprintf("-%d", del)))
|
|
491
|
+
}
|
|
492
|
+
if len(parts) == 0 {
|
|
493
|
+
return "clean"
|
|
494
|
+
}
|
|
495
|
+
return strings.Join(parts, " ")
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
func countLocalDirs(root, subdir string, excludePrefixes ...string) int {
|
|
499
|
+
dir := filepath.Join(root, subdir)
|
|
500
|
+
entries, err := os.ReadDir(dir)
|
|
501
|
+
if err != nil {
|
|
502
|
+
return 0
|
|
503
|
+
}
|
|
504
|
+
count := 0
|
|
505
|
+
for _, e := range entries {
|
|
506
|
+
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
|
507
|
+
continue
|
|
508
|
+
}
|
|
509
|
+
skip := false
|
|
510
|
+
for _, pf := range excludePrefixes {
|
|
511
|
+
if strings.HasPrefix(e.Name(), pf) {
|
|
512
|
+
skip = true
|
|
513
|
+
break
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if !skip {
|
|
517
|
+
count++
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return count
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
func truncate(s string, max int) string {
|
|
524
|
+
if max <= 0 {
|
|
525
|
+
return ""
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// If the printable width fits, return as-is
|
|
529
|
+
if lipgloss.Width(s) <= max {
|
|
530
|
+
return s
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
var result strings.Builder
|
|
534
|
+
visibleLen := 0
|
|
535
|
+
inEsc := false
|
|
536
|
+
|
|
537
|
+
runes := []rune(s)
|
|
538
|
+
n := len(runes)
|
|
539
|
+
|
|
540
|
+
for i := 0; i < n; i++ {
|
|
541
|
+
r := runes[i]
|
|
542
|
+
if r == '\x1b' {
|
|
543
|
+
inEsc = true
|
|
544
|
+
result.WriteRune(r)
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
if inEsc {
|
|
548
|
+
result.WriteRune(r)
|
|
549
|
+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
|
550
|
+
inEsc = false
|
|
551
|
+
}
|
|
552
|
+
continue
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
w := 1
|
|
556
|
+
if r > 0x1100 && (r < 0x115f || r > 0x2329) {
|
|
557
|
+
w = 2
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if visibleLen+w > max-1 {
|
|
561
|
+
result.WriteString("\x1b[0m…")
|
|
562
|
+
return result.String()
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
result.WriteRune(r)
|
|
566
|
+
visibleLen += w
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return result.String()
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func padRight(s string, width int) string {
|
|
573
|
+
vis := lipgloss.Width(s)
|
|
574
|
+
if vis >= width {
|
|
575
|
+
return truncate(s, width)
|
|
576
|
+
}
|
|
577
|
+
return s + strings.Repeat(" ", width-vis)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
func padLeft(s string, width int) string {
|
|
581
|
+
vis := lipgloss.Width(s)
|
|
582
|
+
if vis >= width {
|
|
583
|
+
return truncate(s, width)
|
|
584
|
+
}
|
|
585
|
+
return strings.Repeat(" ", width-vis) + s
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
func formatCatalogAge(iso *string) string {
|
|
589
|
+
if iso == nil || *iso == "" {
|
|
590
|
+
return ""
|
|
591
|
+
}
|
|
592
|
+
t, err := time.Parse(time.RFC3339, *iso)
|
|
593
|
+
if err != nil {
|
|
594
|
+
t, err = time.Parse("2006-01-02T15:04:05.000Z", *iso)
|
|
595
|
+
if err != nil {
|
|
596
|
+
t, err = time.Parse("2006-01-02T15:04:05Z", *iso)
|
|
597
|
+
if err != nil {
|
|
598
|
+
return ""
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
duration := time.Since(t)
|
|
603
|
+
days := int(duration.Hours() / 24)
|
|
604
|
+
if days < 0 {
|
|
605
|
+
return "<1d"
|
|
606
|
+
}
|
|
607
|
+
if days < 1 {
|
|
608
|
+
return "<1d"
|
|
609
|
+
}
|
|
610
|
+
if days < 7 {
|
|
611
|
+
return fmt.Sprintf("%dd", days)
|
|
612
|
+
}
|
|
613
|
+
if days < 30 {
|
|
614
|
+
return fmt.Sprintf("%dw", days/7)
|
|
615
|
+
}
|
|
616
|
+
return fmt.Sprintf("%dmo", days/30)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
func overlayCenter(bg, overlay string) string {
|
|
620
|
+
bgLines := strings.Split(bg, "\n")
|
|
621
|
+
ovLines := strings.Split(overlay, "\n")
|
|
622
|
+
bgH := len(bgLines)
|
|
623
|
+
ovH := len(ovLines)
|
|
624
|
+
bgW := 0
|
|
625
|
+
for _, l := range bgLines {
|
|
626
|
+
if w := lipgloss.Width(l); w > bgW {
|
|
627
|
+
bgW = w
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
ovW := 0
|
|
631
|
+
for _, l := range ovLines {
|
|
632
|
+
if w := lipgloss.Width(l); w > ovW {
|
|
633
|
+
ovW = w
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
topPad := (bgH - ovH) / 2
|
|
637
|
+
leftPad := (bgW - ovW) / 2
|
|
638
|
+
if leftPad < 0 {
|
|
639
|
+
leftPad = 0
|
|
640
|
+
}
|
|
641
|
+
out := make([]string, bgH)
|
|
642
|
+
copy(out, bgLines)
|
|
643
|
+
for i, ovLine := range ovLines {
|
|
644
|
+
row := topPad + i
|
|
645
|
+
if row < 0 || row >= bgH {
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
bg := out[row]
|
|
649
|
+
bgVisible := lipgloss.Width(bg)
|
|
650
|
+
if bgVisible < bgW {
|
|
651
|
+
bg = bg + strings.Repeat(" ", bgW-bgVisible)
|
|
652
|
+
}
|
|
653
|
+
prefix := strings.Repeat(" ", leftPad)
|
|
654
|
+
suffix := ""
|
|
655
|
+
rightStart := leftPad + ovW
|
|
656
|
+
if rightStart < bgW {
|
|
657
|
+
suffix = strings.Repeat(" ", bgW-rightStart)
|
|
658
|
+
}
|
|
659
|
+
out[row] = prefix + ovLine + suffix
|
|
660
|
+
}
|
|
661
|
+
return strings.Join(out, "\n")
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
func drawPanel(w, h int, title string, content string, active bool) string {
|
|
665
|
+
style := panelBorderStyle
|
|
666
|
+
if active {
|
|
667
|
+
style = panelBorderActive
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
style = style.Width(w - 2).Height(h - 2)
|
|
671
|
+
rendered := style.Render(content)
|
|
672
|
+
|
|
673
|
+
if title == "" {
|
|
674
|
+
return rendered
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
var styledTitle string
|
|
678
|
+
if strings.Contains(title, "\x1b") {
|
|
679
|
+
styledTitle = title
|
|
680
|
+
} else {
|
|
681
|
+
styledTitle = lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(title)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
titleFmt := " " + styledTitle + " "
|
|
685
|
+
titleWidth := lipgloss.Width(titleFmt)
|
|
686
|
+
|
|
687
|
+
lines := strings.Split(rendered, "\n")
|
|
688
|
+
if len(lines) > 0 {
|
|
689
|
+
borderColor := colorBorder
|
|
690
|
+
if active {
|
|
691
|
+
borderColor = colorBorderAct
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
dashCount := w - 3 - titleWidth
|
|
695
|
+
if dashCount < 2 {
|
|
696
|
+
dashCount = 2
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
borderStyle := lipgloss.NewStyle().Foreground(borderColor)
|
|
700
|
+
topBorder := borderStyle.Render("╭─") +
|
|
701
|
+
titleFmt +
|
|
702
|
+
borderStyle.Render(strings.Repeat("─", dashCount)+"╮")
|
|
703
|
+
|
|
704
|
+
lines[0] = topBorder
|
|
705
|
+
rendered = strings.Join(lines, "\n")
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return rendered
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
type WorkspaceVersions struct {
|
|
712
|
+
Nuxt string
|
|
713
|
+
Pnpm string
|
|
714
|
+
Owd string
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
func getVersions(root string, workspaceRoot string) WorkspaceVersions {
|
|
718
|
+
vers := WorkspaceVersions{Nuxt: "—", Pnpm: "—", Owd: "—"}
|
|
719
|
+
|
|
720
|
+
rootPkgPath := filepath.Join(root, "package.json")
|
|
721
|
+
if data, err := os.ReadFile(rootPkgPath); err == nil {
|
|
722
|
+
lines := strings.Split(string(data), "\n")
|
|
723
|
+
for _, l := range lines {
|
|
724
|
+
if strings.Contains(l, `"packageManager"`) && strings.Contains(l, "pnpm@") {
|
|
725
|
+
parts := strings.Split(l, "pnpm@")
|
|
726
|
+
if len(parts) >= 2 {
|
|
727
|
+
vers.Pnpm = strings.Trim(parts[1], " \t\r\n,\"")
|
|
728
|
+
vers.Pnpm = strings.ReplaceAll(vers.Pnpm, `"`, "")
|
|
729
|
+
vers.Pnpm = strings.TrimSpace(vers.Pnpm)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if strings.Contains(l, `"nuxt"`) {
|
|
733
|
+
parts := strings.Split(l, ":")
|
|
734
|
+
if len(parts) >= 2 {
|
|
735
|
+
v := strings.Trim(parts[1], " \t\r\n,\"^~")
|
|
736
|
+
v = strings.ReplaceAll(v, `"`, "")
|
|
737
|
+
vers.Nuxt = strings.TrimSpace(v)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
corePkgPath := filepath.Join(workspaceRoot, "packages", "core", "package.json")
|
|
744
|
+
if data, err := os.ReadFile(corePkgPath); err == nil {
|
|
745
|
+
lines := strings.Split(string(data), "\n")
|
|
746
|
+
for _, l := range lines {
|
|
747
|
+
if strings.Contains(l, `"version"`) {
|
|
748
|
+
parts := strings.Split(l, ":")
|
|
749
|
+
if len(parts) >= 2 {
|
|
750
|
+
v := strings.Trim(parts[1], " \t\r\n,\"")
|
|
751
|
+
v = strings.ReplaceAll(v, `"`, "")
|
|
752
|
+
vers.Owd = strings.TrimSpace(v)
|
|
753
|
+
break
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return vers
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
func getProcessMemoryMB(pid int) int {
|
|
763
|
+
if pid <= 0 {
|
|
764
|
+
return 0
|
|
765
|
+
}
|
|
766
|
+
path := fmt.Sprintf("/proc/%d/status", pid)
|
|
767
|
+
data, err := os.ReadFile(path)
|
|
768
|
+
if err != nil {
|
|
769
|
+
return 0
|
|
770
|
+
}
|
|
771
|
+
lines := strings.Split(string(data), "\n")
|
|
772
|
+
for _, l := range lines {
|
|
773
|
+
if strings.HasPrefix(l, "VmRSS:") {
|
|
774
|
+
fields := strings.Fields(l)
|
|
775
|
+
if len(fields) >= 2 {
|
|
776
|
+
var kb int
|
|
777
|
+
if _, err := fmt.Sscanf(fields[1], "%d", &kb); err == nil {
|
|
778
|
+
return kb / 1024
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return 0
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
func renderPanelTitle(title string) string {
|
|
787
|
+
return lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(title)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
func kv(label, value string) string {
|
|
791
|
+
return mutedStyle.Render(padRight(label, 10)) + " " + boldStyle.Render(value)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// keep rand used (for future sparkline noise simulation)
|
|
795
|
+
var _ = rand.Float64
|
|
796
|
+
|
|
797
|
+
// catalogSourceLabel returns the SRC column value for a catalog row.
|
|
798
|
+
// It reflects only a known, materialized source — not an assumed install method.
|
|
799
|
+
func catalogSourceLabel(item bridge.CatalogEntry, localGitDirs map[string]bool) string {
|
|
800
|
+
if item.LocalSource {
|
|
801
|
+
if localGitDirs[item.ShortName] {
|
|
802
|
+
return "git"
|
|
803
|
+
}
|
|
804
|
+
return "dev"
|
|
805
|
+
}
|
|
806
|
+
if item.Installed && item.InPackageJson {
|
|
807
|
+
return "npm"
|
|
808
|
+
}
|
|
809
|
+
return "—"
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// activeThemeName returns the currently selected theme (pending overrides saved config).
|
|
813
|
+
func activeThemeName(ctx *bridge.WorkspaceContext, pendingTheme *string) string {
|
|
814
|
+
if pendingTheme != nil {
|
|
815
|
+
return *pendingTheme
|
|
816
|
+
}
|
|
817
|
+
if ctx != nil && ctx.Config.Theme != nil {
|
|
818
|
+
return *ctx.Config.Theme
|
|
819
|
+
}
|
|
820
|
+
return ""
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
func (m *TuiModel) getInstallMethods(pkg *bridge.CatalogEntry) []InstallMethod {
|
|
824
|
+
var methods []InstallMethod
|
|
825
|
+
|
|
826
|
+
// 0. Local folder option if already present in workspace
|
|
827
|
+
short := pkg.ShortName
|
|
828
|
+
if short == "" {
|
|
829
|
+
short = shortNameFromPkg(pkg.Name)
|
|
830
|
+
}
|
|
831
|
+
if pkg.LocalSource || isLocallyAvailable(m.workspaceRoot, short) {
|
|
832
|
+
methods = append(methods, InstallMethod{
|
|
833
|
+
Name: "local",
|
|
834
|
+
Label: "Use Existing Local Folder",
|
|
835
|
+
Desc: "Import/link the existing local folder in your workspace",
|
|
836
|
+
})
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// 1. NPM Registry — only when NOT already configured as workspace:* in package.json
|
|
840
|
+
if !pkg.InPackageJson && pkg.SourcesMeta != nil && pkg.SourcesMeta.Npm != nil {
|
|
841
|
+
methods = append(methods, InstallMethod{
|
|
842
|
+
Name: "npm",
|
|
843
|
+
Label: "NPM Registry",
|
|
844
|
+
Desc: "Install from npm (not recommended)",
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// 2. Git options
|
|
849
|
+
hasFork := false
|
|
850
|
+
forkOwner := ""
|
|
851
|
+
officialOwner := pkg.Org
|
|
852
|
+
if officialOwner == "" || officialOwner == "workspace" {
|
|
853
|
+
officialOwner = "owdproject"
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if pkg.SourcesMeta != nil && pkg.SourcesMeta.Github.Fork != nil && pkg.SourcesMeta.Github.Fork.Exists {
|
|
857
|
+
// Only treat as "your fork" if GitHub confirms it's actually a fork (forked from another repo)
|
|
858
|
+
if pkg.SourcesMeta.Github.Fork.IsFork {
|
|
859
|
+
hasFork = true
|
|
860
|
+
forkOwner = pkg.SourcesMeta.Github.Fork.Owner
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if hasFork {
|
|
865
|
+
methods = append(methods, InstallMethod{
|
|
866
|
+
Name: "git-https",
|
|
867
|
+
Label: "GIT HTTPS (your fork)",
|
|
868
|
+
Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", forkOwner, pkg.ShortName),
|
|
869
|
+
})
|
|
870
|
+
methods = append(methods, InstallMethod{
|
|
871
|
+
Name: "git-ssh",
|
|
872
|
+
Label: "GIT SSH (your fork)",
|
|
873
|
+
Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", forkOwner, pkg.ShortName),
|
|
874
|
+
})
|
|
875
|
+
} else {
|
|
876
|
+
methods = append(methods, InstallMethod{
|
|
877
|
+
Name: "git-https",
|
|
878
|
+
Label: "GIT HTTPS",
|
|
879
|
+
Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", officialOwner, pkg.ShortName),
|
|
880
|
+
})
|
|
881
|
+
methods = append(methods, InstallMethod{
|
|
882
|
+
Name: "git-ssh",
|
|
883
|
+
Label: "GIT SSH",
|
|
884
|
+
Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", officialOwner, pkg.ShortName),
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return methods
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
func (m *TuiModel) resolveOwner(pkgName string) string {
|
|
892
|
+
if m.catalog == nil {
|
|
893
|
+
return "owdproject"
|
|
894
|
+
}
|
|
895
|
+
var entry *bridge.CatalogEntry
|
|
896
|
+
for i := range m.catalog.Entries {
|
|
897
|
+
if m.catalog.Entries[i].Name == pkgName {
|
|
898
|
+
entry = &m.catalog.Entries[i]
|
|
899
|
+
break
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if entry != nil && entry.SourcesMeta != nil && entry.SourcesMeta.Github.Fork != nil && entry.SourcesMeta.Github.Fork.Exists && entry.SourcesMeta.Github.Fork.IsFork {
|
|
903
|
+
return entry.SourcesMeta.Github.Fork.Owner
|
|
904
|
+
}
|
|
905
|
+
if entry != nil && entry.Org != "" && entry.Org != "workspace" {
|
|
906
|
+
return entry.Org
|
|
907
|
+
}
|
|
908
|
+
return "owdproject"
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
func (m *TuiModel) logInfo(msg string) { m.addLog("ℹ " + msg) }
|
|
912
|
+
func (m *TuiModel) logSuccess(msg string) { m.addLog("✔ " + msg) }
|
|
913
|
+
func (m *TuiModel) logWarn(msg string) { m.addLog("⚠ " + msg) }
|
|
914
|
+
func (m *TuiModel) logError(msg string) { m.addLog("✖ " + msg) }
|
|
915
|
+
|
|
916
|
+
|