@owdproject/cli 0.1.0 → 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/go/tui/tui.go CHANGED
@@ -1,232 +1,21 @@
1
1
  package tui
2
2
 
3
3
  import (
4
- "bufio"
5
- "context"
6
4
  "encoding/json"
7
5
  "fmt"
8
- "io"
9
- "math/rand"
10
6
  "net/http"
11
- "net/url"
12
7
  "os"
13
- "os/exec"
14
8
  "path/filepath"
15
9
  "runtime"
16
10
  "strings"
17
- "syscall"
18
11
  "time"
19
12
 
20
13
  "owd-cli/bridge"
21
14
 
15
+ "github.com/charmbracelet/bubbles/textinput"
22
16
  tea "github.com/charmbracelet/bubbletea"
23
- "github.com/charmbracelet/lipgloss"
24
17
  )
25
18
 
26
- // ─────────────────────────────────────────────
27
- // Colors & Styles
28
- // ─────────────────────────────────────────────
29
-
30
- var (
31
- colorAccent = lipgloss.Color("#7ee787")
32
- colorCyan = lipgloss.Color("#24ebff")
33
- colorDim = lipgloss.Color("#3a3a4a")
34
- colorMuted = lipgloss.Color("#6c7086")
35
- colorWarn = lipgloss.Color("#f9e2af")
36
- colorErr = lipgloss.Color("#f38ba8")
37
- colorNpm = lipgloss.Color("#7ee787")
38
- colorGit = lipgloss.Color("#89dceb")
39
- colorLocal = lipgloss.Color("#ff79c6")
40
- colorBorder = lipgloss.Color("#4a4a6a")
41
- colorBorderAct = lipgloss.Color("#7ee787")
42
- colorWhite = lipgloss.Color("#cdd6f4")
43
- colorSubtle = lipgloss.Color("#585b70")
44
- colorBarBg = lipgloss.Color("#11111b")
45
- colorBarFg = lipgloss.Color("#cdd6f4")
46
- )
47
-
48
- var (
49
- panelBorderStyle = lipgloss.NewStyle().
50
- Border(lipgloss.RoundedBorder()).
51
- BorderForeground(colorBorder)
52
-
53
- panelBorderActive = lipgloss.NewStyle().
54
- Border(lipgloss.RoundedBorder()).
55
- BorderForeground(colorBorderAct)
56
-
57
- accentStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
58
- cyanStyle = lipgloss.NewStyle().Foreground(colorCyan).Bold(true)
59
- mutedStyle = lipgloss.NewStyle().Foreground(colorMuted)
60
- warnStyle = lipgloss.NewStyle().Foreground(colorWarn)
61
- errStyle = lipgloss.NewStyle().Foreground(colorErr)
62
- boldStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
63
- dimStyle = lipgloss.NewStyle().Foreground(colorDim)
64
- subtleStyle = lipgloss.NewStyle().Foreground(colorSubtle)
65
-
66
- tabActiveStyle = lipgloss.NewStyle().
67
- Bold(true).
68
- Background(colorAccent).
69
- Foreground(lipgloss.Color("#1e1e2e")).
70
- Padding(0, 2)
71
-
72
- tabInactiveStyle = lipgloss.NewStyle().
73
- Foreground(colorMuted).
74
- Padding(0, 2)
75
-
76
- selectedRowStyle = lipgloss.NewStyle().
77
- Bold(true).
78
- Foreground(colorAccent)
79
-
80
- installedBadge = lipgloss.NewStyle().Foreground(colorAccent).Render("●")
81
- uninstalledBadge = lipgloss.NewStyle().Foreground(colorSubtle).Render("○")
82
-
83
- // bottom bar — no border, gemini-style
84
- barStyle = lipgloss.NewStyle().
85
- Foreground(colorBarFg).
86
- PaddingLeft(2)
87
-
88
- barKeyStyle = lipgloss.NewStyle().
89
- Foreground(colorAccent).
90
- Bold(true)
91
-
92
- barSepStyle = lipgloss.NewStyle().
93
- Foreground(colorSubtle)
94
-
95
- modalStyle = lipgloss.NewStyle().
96
- Padding(1, 3)
97
-
98
- modalOptionActive = lipgloss.NewStyle().
99
- Background(colorCyan).
100
- Foreground(lipgloss.Color("#000000")).
101
- Bold(true).
102
- Padding(0, 2)
103
-
104
- modalOptionInactive = lipgloss.NewStyle().
105
- Background(colorDim).
106
- Foreground(colorMuted).
107
- Padding(0, 2)
108
- )
109
-
110
- // ─────────────────────────────────────────────
111
- // Msg types
112
- // ─────────────────────────────────────────────
113
-
114
- type contextLoadedMsg struct {
115
- Ctx *bridge.WorkspaceContext
116
- Err error
117
- }
118
-
119
- type catalogLoadedMsg struct {
120
- Cat *bridge.CatalogResponse
121
- Err error
122
- }
123
-
124
- type logLineMsg string
125
- type clearLogsMsg struct{}
126
-
127
- type taskFinishedMsg struct {
128
- Success bool
129
- Err error
130
- }
131
-
132
- type serverStatusMsg struct {
133
- Running bool
134
- }
135
-
136
- type tickMsg time.Time
137
-
138
- type memTickMsg struct {
139
- MemMB int
140
- }
141
-
142
- type GitChanges struct {
143
- Added int
144
- Modified int
145
- Deleted int
146
- }
147
-
148
- type UpdateInfo struct {
149
- LocalGit bool
150
- BehindCount int
151
- Npm bool
152
- }
153
-
154
- type updatesLoadedMsg struct {
155
- GitChanges map[string]GitChanges
156
- Updates map[string]UpdateInfo
157
- }
158
-
159
- // ─────────────────────────────────────────────
160
- // Prompt states
161
- // ─────────────────────────────────────────────
162
-
163
- type PromptType int
164
-
165
- const (
166
- PromptNone PromptType = iota
167
- PromptInstallMethod
168
- PromptUninstallConfirm
169
- PromptManagePackage
170
- PromptForceReinstallConfirm
171
- PromptSettings
172
- )
173
-
174
- var Program *tea.Program
175
- var ServerCmd *exec.Cmd
176
-
177
- // ─────────────────────────────────────────────
178
- // Model
179
- // ─────────────────────────────────────────────
180
-
181
- type TuiModel struct {
182
- workspaceRoot string
183
- ctx *bridge.WorkspaceContext
184
- catalog *bridge.CatalogResponse
185
-
186
- activeTab int // 0: Internal, 1: Apps, 2: Modules, 3: Themes
187
- err error
188
- loading bool
189
- statusMsg string
190
- logLines []string
191
- memHistory []int
192
-
193
- // Selection
194
- selectedIndex int
195
- scrollOffset int
196
-
197
- // Prompts/Modals
198
- activePrompt PromptType
199
- promptPkg *bridge.CatalogEntry
200
- promptSel int
201
-
202
- promptPkgDeps []string
203
- promptPkgHasPrimevue bool
204
- promptPkgHasTailwind bool
205
-
206
- gitChangesMap map[string]GitChanges
207
- updatesMap map[string]UpdateInfo
208
-
209
- settingsSel int
210
- settingsInstallMode string
211
- settingsCatalogSort string
212
-
213
- // Server status
214
- serverRunning bool
215
- taskActive bool
216
- checkingUpdates bool
217
-
218
- // Animation
219
- blink bool
220
- tickCount int
221
-
222
- // Layout
223
- termWidth int
224
- termHeight int
225
-
226
- lastInstallMethod string
227
- logTailerStarted bool
228
- }
229
-
230
19
  func NewModel(root string) TuiModel {
231
20
  return TuiModel{
232
21
  workspaceRoot: root,
@@ -237,20 +26,46 @@ func NewModel(root string) TuiModel {
237
26
  memHistory: []int{},
238
27
  gitChangesMap: make(map[string]GitChanges),
239
28
  updatesMap: make(map[string]UpdateInfo),
29
+ localGitDirs: make(map[string]bool),
240
30
  settingsSel: 0,
241
31
  termWidth: 160,
242
32
  termHeight: 40,
243
33
  lastInstallMethod: "npm",
244
34
  logTailerStarted: false,
35
+ pendingPackages: make(map[string]bool),
36
+ pendingTheme: nil,
37
+ workQueue: NewWorkQueue(),
38
+ enginePhase: PhaseIdle,
39
+ runtime: &RuntimeState{
40
+ msgChan: make(chan tea.Msg, 500),
41
+ engineResume: make(chan string, 1),
42
+ },
43
+ startServerAfterSetup: false,
44
+ setupStep: 0,
45
+ setupTotalSteps: 0,
46
+ setupLabel: "",
47
+ activeTask: TaskNone,
48
+ workspaceBranch: "—",
49
+ workspaceChanges: "clean",
50
+ clientStars: -1,
51
+ }
52
+ }
53
+
54
+ func (m *TuiModel) listenToChannel() tea.Cmd {
55
+ return func() tea.Msg {
56
+ return <-m.runtime.msgChan
245
57
  }
246
58
  }
247
59
 
248
- func (m TuiModel) Init() tea.Cmd {
60
+ func (m *TuiModel) Init() tea.Cmd {
249
61
  return tea.Batch(
250
62
  m.loadContextCmd(),
251
63
  m.loadCatalogCmd(false),
252
64
  m.checkServerStatusCmd(),
65
+ m.updateWorkspaceGitStatusCmd(),
66
+ m.listenToChannel(),
253
67
  tickCmd(),
68
+ m.fetchClientStarsCmd(),
254
69
  )
255
70
  }
256
71
 
@@ -273,67 +88,150 @@ func memSampleCmd() tea.Cmd {
273
88
  }
274
89
  }
275
90
 
276
- func (m TuiModel) loadContextCmd() tea.Cmd {
91
+ func (m *TuiModel) loadContextCmd() tea.Cmd {
277
92
  return func() tea.Msg {
278
93
  ctx, err := bridge.ReadWorkspaceContext(m.workspaceRoot)
279
94
  return contextLoadedMsg{Ctx: ctx, Err: err}
280
95
  }
281
96
  }
282
97
 
283
- func (m TuiModel) loadCatalogCmd(force bool) tea.Cmd {
98
+ func (m *TuiModel) loadCatalogCmd(force bool) tea.Cmd {
284
99
  return func() tea.Msg {
285
100
  cat, err := bridge.ReadCatalog(m.workspaceRoot, force)
286
101
  return catalogLoadedMsg{Cat: cat, Err: err}
287
102
  }
288
103
  }
289
104
 
290
- func (m TuiModel) checkServerStatusCmd() tea.Cmd {
105
+ type clientStarsMsg int
106
+
107
+ func (m *TuiModel) fetchClientStarsCmd() tea.Cmd {
108
+ return func() tea.Msg {
109
+ client := &http.Client{Timeout: 3 * time.Second}
110
+ req, err := http.NewRequest("GET", "https://api.github.com/repos/owdproject/client", nil)
111
+ if err != nil {
112
+ return clientStarsMsg(-1)
113
+ }
114
+ req.Header.Set("User-Agent", "owd-cli")
115
+ resp, err := client.Do(req)
116
+ if err != nil {
117
+ return clientStarsMsg(-1)
118
+ }
119
+ defer resp.Body.Close()
120
+
121
+ var res map[string]interface{}
122
+ if err := json.NewDecoder(resp.Body).Decode(&res); err == nil {
123
+ if starsVal, ok := res["stargazers_count"]; ok {
124
+ if starsFloat, ok := starsVal.(float64); ok {
125
+ return clientStarsMsg(int(starsFloat))
126
+ }
127
+ }
128
+ }
129
+ return clientStarsMsg(-1)
130
+ }
131
+ }
132
+
133
+ func (m *TuiModel) checkServerStatusCmd() tea.Cmd {
291
134
  return func() tea.Msg {
292
135
  if m.ctx == nil {
293
136
  return serverStatusMsg{Running: false}
294
137
  }
295
138
 
296
- pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
297
- data, err := os.ReadFile(pidPath)
298
- if err != nil {
299
- return serverStatusMsg{Running: false}
139
+ port := m.ctx.Settings.DevPort
140
+ if port == 0 {
141
+ port = 3000
300
142
  }
301
143
 
302
- var pid int
303
- if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err != nil {
144
+ open := isPortOpen(port)
145
+ if !open {
146
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
147
+ _ = os.Remove(pidPath)
304
148
  return serverStatusMsg{Running: false}
305
149
  }
306
150
 
307
- process, err := os.FindProcess(pid)
308
- if err != nil {
309
- return serverStatusMsg{Running: false}
151
+ pid := findProcessPID(port)
152
+ if pid > 0 {
153
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
154
+ _ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", pid)), 0644)
310
155
  }
311
156
 
312
- err = process.Signal(syscall.Signal(0))
313
- return serverStatusMsg{Running: err == nil}
157
+ return serverStatusMsg{Running: true}
314
158
  }
315
159
  }
316
160
 
317
- // ─────────────────────────────────────────────
318
- // Update
319
- // ─────────────────────────────────────────────
161
+ func (m *TuiModel) updateWorkspaceGitStatusCmd() tea.Cmd {
162
+ return func() tea.Msg {
163
+ branch := gitBranch(m.workspaceRoot)
164
+ changes := gitChanges(m.workspaceRoot)
165
+ return workspaceGitStatusMsg{Branch: branch, Changes: changes}
166
+ }
167
+ }
320
168
 
321
- func (m TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169
+ func (m *TuiModel) hasPendingChanges() bool {
170
+ if len(m.pendingPackages) > 0 {
171
+ return true
172
+ }
173
+ if m.pendingTheme != nil {
174
+ if m.ctx != nil && m.ctx.Config.Theme != nil {
175
+ return *m.ctx.Config.Theme != *m.pendingTheme
176
+ }
177
+ return true
178
+ }
179
+ return false
180
+ }
181
+
182
+ func (m *TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
322
183
  switch msg := msg.(type) {
323
184
 
185
+ case tea.MouseMsg:
186
+ if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft {
187
+ if m.isClearLogsClick(msg) {
188
+ m.logLines = []string{}
189
+ m.statusMsg = "Logs cleared."
190
+ return m, nil
191
+ }
192
+ if msg.Y == 10 {
193
+ if msg.X <= 13 {
194
+ m.activeTab, m.selectedIndex, m.scrollOffset = 0, 0, 0
195
+ return m, nil
196
+ }
197
+ if msg.X >= 14 && msg.X <= 22 {
198
+ m.activeTab, m.selectedIndex, m.scrollOffset = 1, 0, 0
199
+ return m, nil
200
+ }
201
+ if msg.X >= 23 && msg.X <= 34 {
202
+ m.activeTab, m.selectedIndex, m.scrollOffset = 2, 0, 0
203
+ return m, nil
204
+ }
205
+ if msg.X >= 35 {
206
+ m.activeTab, m.selectedIndex, m.scrollOffset = 3, 0, 0
207
+ return m, nil
208
+ }
209
+ }
210
+ }
211
+ return m, nil
212
+
324
213
  case tea.WindowSizeMsg:
325
214
  m.termWidth = msg.Width
326
215
  m.termHeight = msg.Height
327
216
  return m, nil
328
217
 
218
+ case clientStarsMsg:
219
+ if int(msg) >= 0 {
220
+ m.clientStars = int(msg)
221
+ }
222
+ return m, nil
223
+
329
224
  case tickMsg:
330
225
  m.tickCount++
331
226
  if m.tickCount%4 == 0 {
332
227
  m.blink = !m.blink
333
228
  }
334
-
229
+
230
+ var cmds []tea.Cmd
231
+ cmds = append(cmds, tickCmd())
232
+
233
+ // Every ~1.5s: sample memory + check server status
335
234
  if m.tickCount%10 == 0 {
336
- // Sample Nuxt process memory stats
337
235
  var mb int
338
236
  if m.ctx != nil {
339
237
  pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
@@ -348,17 +246,32 @@ func (m TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
348
246
  if len(m.memHistory) > 30 {
349
247
  m.memHistory = m.memHistory[1:]
350
248
  }
249
+ if m.ctx != nil {
250
+ cmds = append(cmds, m.checkServerStatusCmd())
251
+ }
351
252
  }
352
-
353
- if m.tickCount%100 == 0 && !m.loading && !m.taskActive && !m.checkingUpdates {
253
+
254
+ // Every ~15s: fast local git status check (no network)
255
+ if m.tickCount%100 == 0 && !m.loading && m.activeTask == TaskNone && !m.checkingUpdates {
354
256
  m.checkingUpdates = true
355
- return m, tea.Batch(tickCmd(), m.checkForUpdatesCmd())
257
+ cmds = append(cmds, m.checkLocalChangesCmd(), m.updateWorkspaceGitStatusCmd())
258
+ }
259
+
260
+ // Every ~5min: slow remote check (git behind without fetch + npm versions)
261
+ if m.tickCount%2000 == 0 && !m.loading && m.activeTask == TaskNone {
262
+ cmds = append(cmds, m.checkRemoteUpdatesCmd())
356
263
  }
357
-
358
- return m, tickCmd()
264
+
265
+ return m, tea.Batch(cmds...)
359
266
 
360
267
  case tea.KeyMsg:
361
- if m.activePrompt != PromptNone {
268
+ promptToShow := m.activePrompt
269
+ if (m.activeTask == TaskSetup || m.activeTask == TaskWipe) &&
270
+ m.activePrompt != PromptResolveDependency &&
271
+ (promptToShow == PromptNone || promptToShow == PromptUninstallConfirm || promptToShow == PromptInstallMethod || promptToShow == PromptWipeWorkspaceConfirm) {
272
+ promptToShow = PromptSetupProgress
273
+ }
274
+ if promptToShow != PromptNone {
362
275
  return m.handlePromptKeys(msg)
363
276
  }
364
277
  switch msg.String() {
@@ -403,20 +316,33 @@ func (m TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
403
316
  m.statusMsg = "Refreshing package catalog…"
404
317
  return m, m.loadCatalogCmd(true)
405
318
  case "s":
406
- if !m.serverRunning && !m.taskActive {
407
- m.statusMsg = "Starting dev server…"
408
- m.taskActive = true
409
- if Program != nil {
410
- m.RunServeTask(Program)
319
+ if m.hasPendingChanges() {
320
+ return m, m.startWizard(WizardSave)
321
+ }
322
+ cmd := m.startWizard(WizardStartupCheck)
323
+ if cmd != nil {
324
+ return m, cmd
325
+ }
326
+ case "d":
327
+ if !m.serverRunning && m.activeTask == TaskNone {
328
+ if m.hasPendingChanges() {
329
+ return m, m.startWizard(WizardSaveAndServe)
411
330
  }
331
+ cmd := m.startWizard(WizardStartupCheck)
332
+ if cmd != nil {
333
+ m.startServerAfterSetup = true
334
+ return m, cmd
335
+ }
336
+
337
+ m.statusMsg = "Starting dev server…"
338
+ m.activeTask = TaskServe
339
+ m.RunServeTask()
412
340
  }
413
341
  case "x":
414
342
  if m.serverRunning {
415
343
  m.statusMsg = "Stopping dev server…"
416
- m.taskActive = true
417
- if Program != nil {
418
- m.StopServeTask(Program)
419
- }
344
+ m.activeTask = TaskServe
345
+ m.StopServeTask()
420
346
  }
421
347
  case "c":
422
348
  items := m.getActiveItems()
@@ -431,35 +357,73 @@ func (m TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
431
357
  items := m.getActiveItems()
432
358
  if len(items) > 0 && m.selectedIndex < len(items) {
433
359
  pkg := items[m.selectedIndex]
434
- m.promptPkg = &pkg
435
- if pkg.Installed {
436
- m.activePrompt = PromptUninstallConfirm
437
- m.promptSel = 1
438
- } else {
439
- m.activePrompt = PromptInstallMethod
440
- methods := m.getInstallMethods(&pkg)
441
- selIdx := 0
442
- for idx, mth := range methods {
443
- if mth.Name == m.lastInstallMethod {
444
- selIdx = idx
445
- break
360
+ if m.activeTab == 3 { // Themes (radiobox)
361
+ current := activeThemeName(m.ctx, m.pendingTheme)
362
+ if current == pkg.Name && m.pendingTheme != nil {
363
+ m.pendingTheme = nil
364
+ } else if current != pkg.Name {
365
+ themeName := pkg.Name
366
+ m.pendingTheme = &themeName
367
+ }
368
+ } else { // Apps or Modules (checkbox)
369
+ if pkg.Installed {
370
+ if _, exists := m.pendingPackages[pkg.Name]; exists {
371
+ delete(m.pendingPackages, pkg.Name)
372
+ } else {
373
+ m.pendingPackages[pkg.Name] = false
374
+ }
375
+ } else {
376
+ if _, exists := m.pendingPackages[pkg.Name]; exists {
377
+ delete(m.pendingPackages, pkg.Name)
378
+ } else {
379
+ m.pendingPackages[pkg.Name] = true
446
380
  }
447
381
  }
448
- m.promptSel = selIdx
449
382
  }
450
383
  }
451
384
  case "u":
452
- m.loading = true
453
- m.statusMsg = "Checking for updates…"
454
- m.checkingUpdates = true
455
- return m, m.checkForUpdatesCmd()
385
+ items := m.getActiveItems()
386
+ if len(items) > 0 && m.selectedIndex < len(items) {
387
+ pkg := items[m.selectedIndex]
388
+ if pkg.Installed && m.activeTask == TaskNone {
389
+ m.activeTask = TaskSetup
390
+ m.statusMsg = fmt.Sprintf("Updating %s…", pkg.ShortName)
391
+ m.RunUpdatePackageTask(pkg.Name, pkg.ShortName, pkg.Kind, pkg.LocalSource)
392
+ }
393
+ }
456
394
  case "g":
457
395
  if m.ctx != nil {
458
396
  m.settingsInstallMode = m.ctx.Settings.InstallMode
459
397
  m.settingsCatalogSort = m.ctx.Settings.CatalogSort
460
398
  m.settingsSel = 0
461
399
  m.activePrompt = PromptSettings
400
+
401
+ // Initialize text inputs
402
+ tiUser := textinput.New()
403
+ tiUser.Placeholder = "github_user"
404
+ tiUser.CharLimit = 64
405
+ tiUser.Width = 30
406
+ if m.ctx.Settings.GithubUser != nil {
407
+ tiUser.SetValue(*m.ctx.Settings.GithubUser)
408
+ }
409
+ m.settingsUserInput = tiUser
410
+
411
+ tiOrgs := textinput.New()
412
+ tiOrgs.Placeholder = "org1, org2"
413
+ tiOrgs.CharLimit = 256
414
+ tiOrgs.Width = 40
415
+ if len(m.ctx.Settings.GithubOrgs) > 0 {
416
+ tiOrgs.SetValue(strings.Join(m.ctx.Settings.GithubOrgs, ", "))
417
+ } else {
418
+ tiOrgs.SetValue("owdproject, atproto-os")
419
+ }
420
+ m.settingsOrgsInput = tiOrgs
421
+
422
+ m.updateSettingsFocus()
462
423
  }
424
+ case "n":
425
+ m.ExitCode = 10
426
+ return m, tea.Quit
463
427
  }
464
428
  return m, nil
465
429
 
@@ -475,8 +439,10 @@ func (m TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
475
439
  if !m.logTailerStarted && m.ctx != nil {
476
440
  m.logTailerStarted = true
477
441
  logPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.log")
478
- StartLogTailer(logPath, Program)
442
+ m.StartLogTailer(logPath)
479
443
  }
444
+
445
+ return m, m.checkServerStatusCmd()
480
446
  }
481
447
 
482
448
  case catalogLoadedMsg:
@@ -487,2271 +453,272 @@ func (m TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
487
453
  } else {
488
454
  m.catalog = msg.Cat
489
455
  m.statusMsg = fmt.Sprintf("Catalog loaded (%s).", msg.Cat.CacheAge)
456
+
457
+ // Run fast local check immediately; schedule remote check too
490
458
  m.checkingUpdates = true
491
- return m, m.checkForUpdatesCmd()
459
+ return m, tea.Batch(m.checkLocalChangesCmd(), m.checkRemoteUpdatesCmd())
492
460
  }
493
461
 
494
- case updatesLoadedMsg:
462
+ case localChangesMsg:
495
463
  m.gitChangesMap = msg.GitChanges
496
- m.updatesMap = msg.Updates
464
+ m.localGitDirs = msg.LocalGitDirs
497
465
  m.checkingUpdates = false
498
466
  m.loading = false
499
- count := 0
500
- for _, up := range msg.Updates {
501
- if up.LocalGit || up.Npm {
502
- count++
467
+
468
+ case remoteUpdatesMsg:
469
+ m.updatesMap = msg.Updates
470
+ if msg.Err == nil {
471
+ count := 0
472
+ for _, up := range msg.Updates {
473
+ if up.LocalGit || up.Npm {
474
+ count++
475
+ }
476
+ }
477
+ if count > 0 {
478
+ m.statusMsg = fmt.Sprintf("%d update(s) available.", count)
503
479
  }
504
480
  }
505
- if count > 0 {
506
- m.statusMsg = fmt.Sprintf("Updates check completed: %d update(s) available.", count)
507
- } else {
508
- m.statusMsg = "All packages are up to date."
509
- }
510
- return m, nil
481
+
482
+ case setupProgressMsg:
483
+ m.setupStep = msg.Step
484
+ m.setupTotalSteps = msg.Total
485
+ m.setupLabel = msg.Label
486
+ return m, m.listenToChannel()
487
+
488
+ case engineQueueUpdatedMsg:
489
+ q := msg.Queue
490
+ m.workQueue = &q
491
+ completed, total := q.Progress()
492
+ m.setupStep = completed
493
+ m.setupTotalSteps = total
494
+ return m, m.listenToChannel()
495
+
496
+ case enginePhaseMsg:
497
+ m.enginePhase = msg.Phase
498
+ return m, m.listenToChannel()
499
+
500
+ case engineNeedsPromptMsg:
501
+ m.promptItem = &msg.Item
502
+ m.promptPkg = &msg.Item.Entry
503
+ m.activePrompt = PromptResolveDependency
504
+ m.promptSel = 0
505
+ m.statusMsg = fmt.Sprintf("Resolve dependency: %s", msg.Item.ShortName)
506
+ return m, m.listenToChannel()
511
507
 
512
508
  case logLineMsg:
513
509
  line := string(msg)
514
510
  if strings.Contains(line, "█") || strings.Contains(line, "▄") || strings.Contains(line, "▀") {
515
- break
511
+ return m, m.listenToChannel()
516
512
  }
517
513
  if strings.Contains(line, "Local:") || strings.Contains(line, "Network:") || strings.Contains(line, "expose") || strings.Contains(line, "➜") {
518
- break
514
+ return m, m.listenToChannel()
519
515
  }
520
516
  m.addLog(line)
517
+ return m, m.listenToChannel()
521
518
 
522
519
  case clearLogsMsg:
523
520
  m.logLines = []string{}
524
- return m, nil
521
+ return m, m.listenToChannel()
525
522
 
526
523
  case taskFinishedMsg:
527
- m.taskActive = false
524
+ m.activeTask = TaskNone
525
+ m.enginePhase = PhaseIdle
526
+ m.activePrompt = PromptNone
527
+ m.promptItem = nil
528
528
  if msg.Success {
529
529
  m.statusMsg = "Task completed."
530
- m.addLog(">>> Task completed successfully.")
530
+ m.addLog("✓ Wizard completed successfully")
531
+ if m.serverRunning {
532
+ m.addLog("ℹ desktop.config.ts updated — Nuxt restarts automatically (see dev server log)")
533
+ }
534
+
535
+ if m.startServerAfterSetup {
536
+ m.startServerAfterSetup = false
537
+ m.statusMsg = "Starting dev server…"
538
+ m.addLog("ℹ Starting development server…")
539
+ m.activeTask = TaskServe
540
+ m.RunServeTask()
541
+ }
531
542
  } else {
532
543
  m.statusMsg = fmt.Sprintf("Task failed: %v", msg.Err)
533
- m.addLog(fmt.Sprintf(">>> Task failed: %v", msg.Err))
544
+ m.addLog(fmt.Sprintf("✖ Wizard failed: %v", msg.Err))
545
+ m.startServerAfterSetup = false
534
546
  }
535
- return m, tea.Batch(m.loadContextCmd(), m.loadCatalogCmd(false))
547
+ return m, tea.Batch(m.loadContextCmd(), m.loadCatalogCmd(false), m.updateWorkspaceGitStatusCmd(), m.listenToChannel())
536
548
 
537
549
  case serverStatusMsg:
538
550
  m.serverRunning = msg.Running
539
- m.taskActive = false
551
+ if m.activeTask == TaskServe {
552
+ m.activeTask = TaskNone
553
+ }
540
554
  if !msg.Running {
541
555
  m.statusMsg = "Dev server stopped."
542
556
  } else {
543
557
  m.statusMsg = "Dev server running."
544
558
  }
559
+ return m, m.listenToChannel()
560
+ case workspaceGitStatusMsg:
561
+ m.workspaceBranch = msg.Branch
562
+ m.workspaceChanges = msg.Changes
563
+ return m, nil
545
564
  }
546
565
 
547
566
  return m, nil
548
567
  }
549
568
 
550
569
  func (m *TuiModel) addLog(line string) {
551
- m.logLines = append(m.logLines, line)
570
+ // Split on embedded newlines — Nuxt can emit multi-line chunks as a single string.
571
+ // If we store them unsplit, the panel renders 1 counted line but N terminal lines → layout breaks.
572
+ line = strings.ReplaceAll(line, "\r\n", "\n")
573
+ sublines := strings.Split(line, "\n")
574
+ for _, sl := range sublines {
575
+ sl = strings.TrimRight(sl, "\r")
576
+ if sl == "" {
577
+ continue
578
+ }
579
+ m.logLines = append(m.logLines, sl)
580
+ }
552
581
  if len(m.logLines) > 200 {
553
- m.logLines = m.logLines[1:]
582
+ m.logLines = m.logLines[len(m.logLines)-200:]
554
583
  }
555
584
  }
556
585
 
557
- // ─────────────────────────────────────────────
558
- // Prompt keys
559
- // ─────────────────────────────────────────────
560
586
 
561
- func (m TuiModel) handlePromptKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
562
- switch msg.String() {
563
- case "esc", "q":
564
- m.activePrompt = PromptNone
565
- m.promptPkg = nil
566
- return m, nil
567
- case "up", "k":
568
- if m.activePrompt == PromptManagePackage {
569
- if m.promptSel > 0 {
570
- m.promptSel--
571
- }
572
- } else if m.activePrompt == PromptSettings {
573
- if m.settingsSel == 4 || m.settingsSel == 5 {
574
- m.settingsSel = 3
575
- } else if m.settingsSel > 0 {
576
- m.settingsSel--
577
- }
578
- } else if m.promptSel > 0 {
579
- m.promptSel--
580
- }
581
- case "down", "j":
582
- if m.activePrompt == PromptManagePackage {
583
- if m.promptSel < 5 {
584
- m.promptSel++
585
- }
586
- } else if m.activePrompt == PromptSettings {
587
- if m.settingsSel == 3 {
588
- m.settingsSel = 4
589
- } else if m.settingsSel < 3 {
590
- m.settingsSel++
591
- }
592
- } else {
593
- limit := 2
594
- if m.activePrompt == PromptUninstallConfirm || m.activePrompt == PromptForceReinstallConfirm {
595
- limit = 1
596
- } else if m.activePrompt == PromptInstallMethod && m.promptPkg != nil {
597
- limit = len(m.getInstallMethods(m.promptPkg)) - 1
598
- }
599
- if m.promptSel < limit {
600
- m.promptSel++
601
- }
602
- }
603
- case "left", "h":
604
- if m.activePrompt == PromptSettings {
605
- if m.settingsSel == 5 {
606
- m.settingsSel = 4
607
- }
608
- } else if m.activePrompt != PromptManagePackage && m.activePrompt != PromptInstallMethod {
609
- if m.promptSel > 0 {
610
- m.promptSel--
611
- }
612
- }
613
- case "right", "l":
614
- if m.activePrompt == PromptSettings {
615
- if m.settingsSel == 4 {
616
- m.settingsSel = 5
617
- }
618
- } else if m.activePrompt != PromptManagePackage && m.activePrompt != PromptInstallMethod {
619
- limit := 2
620
- if m.activePrompt == PromptUninstallConfirm || m.activePrompt == PromptForceReinstallConfirm {
621
- limit = 1
622
- }
623
- if m.promptSel < limit {
624
- m.promptSel++
625
- }
587
+ // checkLocalChangesCmd runs a fast local-only git status check for every catalog entry.
588
+ // No network activity — only reads the local working tree.
589
+ // Runs every ~15s. Also detects which packages have their own .git folder.
590
+ func (m *TuiModel) checkLocalChangesCmd() tea.Cmd {
591
+ return func() tea.Msg {
592
+ gitChangesMap := make(map[string]GitChanges)
593
+ localGitDirs := make(map[string]bool)
594
+
595
+ if m.catalog == nil {
596
+ return localChangesMsg{GitChanges: gitChangesMap, LocalGitDirs: localGitDirs}
626
597
  }
627
- case "enter":
628
- pkg := m.promptPkg
629
- prompt := m.activePrompt
630
-
631
- if prompt == PromptUninstallConfirm {
632
- m.activePrompt = PromptNone
633
- m.promptPkg = nil
634
- if m.promptSel == 0 {
635
- return m.triggerUninstall(pkg)
636
- }
637
- } else if prompt == PromptInstallMethod {
638
- m.activePrompt = PromptNone
639
- m.promptPkg = nil
640
- methods := m.getInstallMethods(pkg)
641
- if m.promptSel >= 0 && m.promptSel < len(methods) {
642
- m.lastInstallMethod = methods[m.promptSel].Name
643
- return m.triggerInstall(pkg, methods[m.promptSel].Name)
644
- }
645
- return m, nil
646
- } else if prompt == PromptForceReinstallConfirm {
647
- m.activePrompt = PromptNone
648
- m.promptPkg = nil
649
- if m.promptSel == 0 {
650
- return m.triggerForceReinstall(pkg)
598
+
599
+ for _, entry := range m.catalog.Entries {
600
+ short := entry.ShortName
601
+ var kindDir string
602
+ switch entry.Kind {
603
+ case "app":
604
+ kindDir = "apps"
605
+ case "module":
606
+ kindDir = "packages"
607
+ case "theme":
608
+ kindDir = "themes"
651
609
  }
652
- } else if prompt == PromptManagePackage {
653
- m.activePrompt = PromptNone
654
- m.promptPkg = nil
655
- switch m.promptSel {
656
- case 0: // Update Package
657
- return m.triggerUpdate(pkg)
658
- case 1: // Switch to NPM
659
- m.lastInstallMethod = "npm"
660
- return m.triggerInstall(pkg, "npm")
661
- case 2: // Switch to Git HTTPS
662
- m.lastInstallMethod = "git-https"
663
- return m.triggerInstall(pkg, "git-https")
664
- case 3: // Switch to Git SSH
665
- m.lastInstallMethod = "git-ssh"
666
- return m.triggerInstall(pkg, "git-ssh")
667
- case 4: // Force Re-download
668
- m.promptPkg = pkg
669
- m.activePrompt = PromptForceReinstallConfirm
670
- m.promptSel = 1 // default to No
671
- return m, nil
672
- case 5: // Back
673
- return m, nil
610
+ if kindDir == "" {
611
+ continue
674
612
  }
675
- } else if prompt == PromptSettings {
676
- switch m.settingsSel {
677
- case 0: // Install Mode
678
- if m.settingsInstallMode == "npm" {
679
- m.settingsInstallMode = "workspace"
680
- } else {
681
- m.settingsInstallMode = "npm"
682
- }
683
- m.activePrompt = PromptSettings
684
- return m, nil
685
- case 1: // Catalog Sort
686
- modes := []string{"updated", "name", "stars", "installed"}
687
- idx := 0
688
- for i, mth := range modes {
689
- if mth == m.settingsCatalogSort {
690
- idx = (i + 1) % len(modes)
691
- break
692
- }
613
+
614
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
615
+ gitDir := filepath.Join(pkgPath, ".git")
616
+
617
+ if _, err := os.Stat(gitDir); err == nil {
618
+ // Package has its own .git repo
619
+ localGitDirs[short] = true
620
+ added, modified, deleted, err := runGitStatus(pkgPath)
621
+ if err == nil && (added > 0 || modified > 0 || deleted > 0) {
622
+ gitChangesMap[short] = GitChanges{Added: added, Modified: modified, Deleted: deleted}
693
623
  }
694
- m.settingsCatalogSort = modes[idx]
695
- m.activePrompt = PromptSettings
696
- return m, nil
697
- case 2: // Trusted Orgs
698
- m.addLog(">>> To configure Trusted Orgs, modify .desktop/settings.json (githubOrgs field).")
699
- m.activePrompt = PromptSettings
700
- return m, nil
701
- case 3: // GitHub User
702
- m.addLog(">>> To configure GitHub User, modify .desktop/settings.json or use OWD_GITHUB_USER env var.")
703
- m.activePrompt = PromptSettings
704
- return m, nil
705
- case 4: // Save
706
- m.activePrompt = PromptNone
707
- if m.ctx != nil {
708
- settings := m.ctx.Settings
709
- settings.InstallMode = m.settingsInstallMode
710
- settings.CatalogSort = m.settingsCatalogSort
711
- payload := &bridge.WritePayload{
712
- Settings: &settings,
713
- }
714
- m.statusMsg = "Saving settings…"
715
- m.addLog(">>> Saving settings configuration…")
716
- if err := bridge.WriteChanges(m.workspaceRoot, payload); err != nil {
717
- m.statusMsg = fmt.Sprintf("Save failed: %v", err)
718
- m.addLog(fmt.Sprintf(">>> Save settings failed: %v", err))
719
- } else {
720
- m.statusMsg = "Settings saved successfully."
721
- m.addLog(">>> Settings saved successfully.")
624
+ } else {
625
+ // Fallback: check monorepo git for this subdirectory
626
+ parentGitDir := filepath.Join(m.workspaceRoot, ".git")
627
+ if _, err := os.Stat(parentGitDir); err == nil {
628
+ relPath := filepath.Join(kindDir, short)
629
+ added, modified, deleted, err := runGitStatusForSubdir(m.workspaceRoot, relPath)
630
+ if err == nil && (added > 0 || modified > 0 || deleted > 0) {
631
+ gitChangesMap[short] = GitChanges{Added: added, Modified: modified, Deleted: deleted}
722
632
  }
723
633
  }
724
- m.loading = true
725
- return m, tea.Batch(m.loadContextCmd(), m.loadCatalogCmd(false))
726
- case 5: // Cancel
727
- m.activePrompt = PromptNone
728
- return m, nil
729
634
  }
730
635
  }
636
+
637
+ return localChangesMsg{GitChanges: gitChangesMap, LocalGitDirs: localGitDirs}
731
638
  }
732
- return m, nil
733
639
  }
734
640
 
735
- // ─────────────────────────────────────────────
736
- // Actions
737
- // ─────────────────────────────────────────────
738
-
739
- func (m TuiModel) triggerUninstall(pkg *bridge.CatalogEntry) (tea.Model, tea.Cmd) {
740
- m.taskActive = true
741
- m.statusMsg = fmt.Sprintf("Uninstalling %s…", pkg.ShortName)
742
- m.addLog(fmt.Sprintf(">>> Uninstalling %s", pkg.Name))
641
+ // checkRemoteUpdatesCmd runs the slow network-dependent checks:
642
+ // - git behind count (uses cached remote refs, NO git fetch)
643
+ // - npm latest version check
644
+ // Runs every ~5 minutes.
645
+ func (m *TuiModel) checkRemoteUpdatesCmd() tea.Cmd {
646
+ return func() tea.Msg {
647
+ updatesMap := make(map[string]UpdateInfo)
743
648
 
744
- payload := &bridge.WritePayload{
745
- Config: &bridge.Config{Theme: m.ctx.Config.Theme, Apps: m.ctx.Config.Apps, Modules: m.ctx.Config.Modules},
746
- DepsToRemove: []string{pkg.Name},
747
- }
748
- if pkg.Kind == "app" {
749
- var next []string
750
- for _, a := range m.ctx.Config.Apps {
751
- if a != pkg.Name {
752
- next = append(next, a)
753
- }
649
+ if m.catalog == nil {
650
+ return remoteUpdatesMsg{Updates: updatesMap}
754
651
  }
755
- payload.Config.Apps = next
756
- } else if pkg.Kind == "module" {
757
- var next []string
758
- for _, mod := range m.ctx.Config.Modules {
759
- if mod != pkg.Name {
760
- next = append(next, mod)
761
- }
652
+
653
+ type result struct {
654
+ shortName string
655
+ update *UpdateInfo
656
+ err error
762
657
  }
763
- payload.Config.Modules = next
764
- } else if pkg.Kind == "theme" {
765
- e := ""
766
- payload.Config.Theme = &e
767
- }
768
658
 
769
- if err := bridge.WriteChanges(m.workspaceRoot, payload); err != nil {
770
- m.taskActive = false
771
- m.statusMsg = fmt.Sprintf("Uninstall failed: %v", err)
772
- return m, nil
773
- }
774
- if Program != nil {
775
- m.RunUninstallTask(Program)
776
- }
777
- return m, nil
778
- }
659
+ resultsChan := make(chan result, len(m.catalog.Entries))
660
+ sem := make(chan struct{}, 5) // modest concurrency — these are network ops
779
661
 
780
- func (m TuiModel) triggerInstall(pkg *bridge.CatalogEntry, method string) (tea.Model, tea.Cmd) {
781
- m.taskActive = true
782
- m.statusMsg = fmt.Sprintf("Installing %s via %s…", pkg.ShortName, method)
783
- m.addLog(fmt.Sprintf(">>> Installing %s via %s", pkg.Name, method))
662
+ for _, e := range m.catalog.Entries {
663
+ sem <- struct{}{}
664
+ go func(entry bridge.CatalogEntry) {
665
+ defer func() { <-sem }()
784
666
 
785
- payload := &bridge.WritePayload{
786
- Config: &bridge.Config{Theme: m.ctx.Config.Theme, Apps: m.ctx.Config.Apps, Modules: m.ctx.Config.Modules},
787
- DepsToAdd: make(map[string]string),
788
- }
789
- version := "latest"
790
- if pkg.Version != nil {
791
- version = *pkg.Version
792
- }
667
+ res := result{shortName: entry.ShortName}
793
668
 
794
- if method == "npm" {
795
- payload.DepsToAdd[pkg.Name] = version
796
- } else if method == "local" {
797
- payload.DepsToAdd[pkg.Name] = "workspace:*"
798
- } else {
799
- user := "owdproject"
800
- if m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
801
- user = *m.ctx.Settings.GithubUser
802
- }
803
- var gitUrl string
804
- if method == "git-ssh" {
805
- gitUrl = fmt.Sprintf("git@github.com:%s/%s.git", user, pkg.ShortName)
806
- } else {
807
- gitUrl = fmt.Sprintf("https://github.com/%s/%s.git", user, pkg.ShortName)
808
- }
809
- payload.DepsToAdd[pkg.Name] = "workspace:*"
810
- settings := m.ctx.Settings
811
- if settings.LastInstallChoices == nil {
812
- settings.LastInstallChoices = make(map[string]interface{})
813
- }
814
- settings.LastInstallChoices[pkg.Name] = map[string]string{"type": "git", "gitUrl": gitUrl}
815
- payload.Settings = &settings
816
- }
669
+ short := entry.ShortName
670
+ var kindDir string
671
+ switch entry.Kind {
672
+ case "app":
673
+ kindDir = "apps"
674
+ case "module":
675
+ kindDir = "packages"
676
+ case "theme":
677
+ kindDir = "themes"
678
+ }
817
679
 
818
- if pkg.Kind == "app" {
819
- payload.Config.Apps = append(payload.Config.Apps, pkg.Name)
820
- } else if pkg.Kind == "module" {
821
- payload.Config.Modules = append(payload.Config.Modules, pkg.Name)
822
- } else if pkg.Kind == "theme" {
823
- payload.Config.Theme = &pkg.Name
824
- }
680
+ // 1. Git behind check (NO fetch — uses cached remote refs)
681
+ if kindDir != "" {
682
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
683
+ gitDir := filepath.Join(pkgPath, ".git")
684
+ if _, err := os.Stat(gitDir); err == nil {
685
+ behind, err := runGitBehindCheckNoFetch(pkgPath)
686
+ if err == nil && behind > 0 {
687
+ res.update = &UpdateInfo{LocalGit: true, BehindCount: behind}
688
+ }
689
+ }
690
+ }
825
691
 
826
- if err := bridge.WriteChanges(m.workspaceRoot, payload); err != nil {
827
- m.taskActive = false
828
- m.statusMsg = fmt.Sprintf("Install failed: %v", err)
829
- return m, nil
830
- }
831
- if Program != nil {
832
- m.RunInstallTask(pkg.Name, method, Program)
833
- }
834
- return m, nil
835
- }
836
-
837
- func (m TuiModel) triggerForceReinstall(pkg *bridge.CatalogEntry) (tea.Model, tea.Cmd) {
838
- m.taskActive = true
839
- m.statusMsg = fmt.Sprintf("Force reinstalling %s…", pkg.ShortName)
840
- m.addLog(fmt.Sprintf(">>> Deleting local folder and reinstalling %s", pkg.Name))
841
-
842
- // Delete local folder
843
- short := pkg.ShortName
844
- kindDir := ""
845
- switch pkg.Kind {
846
- case "app":
847
- kindDir = "apps"
848
- case "module":
849
- kindDir = "packages"
850
- case "theme":
851
- kindDir = "themes"
852
- }
853
- if kindDir != "" {
854
- pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
855
- m.addLog(fmt.Sprintf(">>> Removing directory: %s", pkgPath))
856
- _ = os.RemoveAll(pkgPath)
857
- }
858
-
859
- // Reinstall using the original source method
860
- method := "npm"
861
- if pkg.LocalSource {
862
- if m.ctx != nil && m.ctx.Settings.LastInstallChoices != nil {
863
- if choice, exists := m.ctx.Settings.LastInstallChoices[pkg.Name]; exists {
864
- if choiceMap, ok := choice.(map[string]interface{}); ok {
865
- if gitUrl, hasGit := choiceMap["gitUrl"].(string); hasGit {
866
- if strings.HasPrefix(gitUrl, "git@") {
867
- method = "git-ssh"
868
- } else {
869
- method = "git-https"
870
- }
871
- }
872
- }
873
- }
874
- }
875
- }
876
-
877
- if Program != nil {
878
- m.RunInstallTask(pkg.Name, method, Program)
879
- }
880
- return m, nil
881
- }
882
-
883
- func (m TuiModel) triggerUpdate(pkg *bridge.CatalogEntry) (tea.Model, tea.Cmd) {
884
- m.taskActive = true
885
- m.statusMsg = fmt.Sprintf("Updating %s…", pkg.ShortName)
886
- m.addLog(fmt.Sprintf(">>> Updating %s", pkg.Name))
887
-
888
- if Program != nil {
889
- go func() {
890
- short := pkg.ShortName
891
- kindDir := ""
892
- switch pkg.Kind {
893
- case "app":
894
- kindDir = "apps"
895
- case "module":
896
- kindDir = "packages"
897
- case "theme":
898
- kindDir = "themes"
899
- }
900
- if kindDir != "" {
901
- pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
902
- gitDir := filepath.Join(pkgPath, ".git")
903
- if _, err := os.Stat(gitDir); err == nil {
904
- Program.Send(logLineMsg(">>> Local Git repository detected. Running git pull…"))
905
- runProcessAndStreamLogs(pkgPath, "git", []string{"pull"}, Program)
906
- Program.Send(taskFinishedMsg{Success: true})
907
- return
908
- }
909
- }
910
-
911
- Program.Send(logLineMsg(fmt.Sprintf(">>> Running pnpm install %s@latest…", pkg.Name)))
912
- runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"install", pkg.Name + "@latest"}, Program)
913
- Program.Send(logLineMsg(">>> Preparing workspace modules…"))
914
- runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"run", "prepare:modules"}, Program)
915
- Program.Send(taskFinishedMsg{Success: true})
916
- }()
917
- }
918
- return m, nil
919
- }
920
-
921
- // ─────────────────────────────────────────────
922
- // Helpers
923
- // ─────────────────────────────────────────────
924
-
925
- func (m TuiModel) getActiveItems() []bridge.CatalogEntry {
926
- if m.catalog == nil {
927
- return nil
928
- }
929
- var res []bridge.CatalogEntry
930
- for _, e := range m.catalog.Entries {
931
- switch m.activeTab {
932
- case 0: // Internal
933
- if e.Kind == "module" && !strings.HasPrefix(e.ShortName, "module-") {
934
- res = append(res, e)
935
- }
936
- case 1: // Apps
937
- if e.Kind == "app" {
938
- res = append(res, e)
939
- }
940
- case 2: // Modules
941
- if e.Kind == "module" && strings.HasPrefix(e.ShortName, "module-") {
942
- res = append(res, e)
943
- }
944
- case 3: // Themes
945
- if e.Kind == "theme" {
946
- res = append(res, e)
947
- }
948
- }
949
- }
950
- return res
951
- }
952
-
953
- func sparkline(vals []int) string {
954
- blocks := []rune{'\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'}
955
- if len(vals) == 0 {
956
- mutedBar := lipgloss.NewStyle().Foreground(colorDim).Render(string('\u2581'))
957
- return strings.Repeat(mutedBar, 12)
958
- }
959
-
960
- min := vals[0]
961
- max := vals[0]
962
- for _, v := range vals {
963
- if v < min {
964
- min = v
965
- }
966
- if v > max {
967
- max = v
968
- }
969
- }
970
-
971
- var sb strings.Builder
972
- show := vals
973
- if len(show) > 12 {
974
- show = show[len(show)-12:]
975
- }
976
-
977
- for _, v := range show {
978
- var idx int
979
- if max == min {
980
- if v == 0 {
981
- idx = 0
982
- } else {
983
- idx = 2
984
- }
985
- } else {
986
- idx = int(float64(v-min) / float64(max-min) * float64(len(blocks)-1))
987
- if idx < 0 {
988
- idx = 0
989
- }
990
- if idx >= len(blocks) {
991
- idx = len(blocks) - 1
992
- }
993
- }
994
-
995
- var color lipgloss.Color
996
- if v == 0 {
997
- color = colorDim
998
- } else {
999
- if v > 1024 {
1000
- color = colorErr
1001
- } else if v > 512 {
1002
- color = colorWarn
1003
- } else {
1004
- color = colorCyan
1005
- }
1006
- }
1007
-
1008
- styledRune := lipgloss.NewStyle().Foreground(color).Render(string(blocks[idx]))
1009
- sb.WriteString(styledRune)
1010
- }
1011
- return sb.String()
1012
- }
1013
-
1014
- func gitBranch(root string) string {
1015
- out, err := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD").Output()
1016
- if err != nil {
1017
- return "—"
1018
- }
1019
- return strings.TrimSpace(string(out))
1020
- }
1021
-
1022
- func gitChanges(root string) string {
1023
- out, err := exec.Command("git", "-C", root, "status", "--porcelain").Output()
1024
- if err != nil {
1025
- return "unknown"
1026
- }
1027
- lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1028
- if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
1029
- return "clean"
1030
- }
1031
- mod, add, del := 0, 0, 0
1032
- for _, l := range lines {
1033
- if len(l) < 2 {
1034
- continue
1035
- }
1036
- switch {
1037
- case strings.HasPrefix(l, "??"):
1038
- add++
1039
- case strings.HasPrefix(l, " D") || strings.HasPrefix(l, "D "):
1040
- del++
1041
- default:
1042
- mod++
1043
- }
1044
- }
1045
- var parts []string
1046
- if add > 0 {
1047
- parts = append(parts, accentStyle.Render(fmt.Sprintf("+%d", add)))
1048
- }
1049
- if mod > 0 {
1050
- parts = append(parts, warnStyle.Render(fmt.Sprintf("~%d", mod)))
1051
- }
1052
- if del > 0 {
1053
- parts = append(parts, errStyle.Render(fmt.Sprintf("-%d", del)))
1054
- }
1055
- if len(parts) == 0 {
1056
- return "clean"
1057
- }
1058
- return strings.Join(parts, " ")
1059
- }
1060
-
1061
- func countLocalDirs(root, subdir string, excludePrefixes ...string) int {
1062
- dir := filepath.Join(root, subdir)
1063
- entries, err := os.ReadDir(dir)
1064
- if err != nil {
1065
- return 0
1066
- }
1067
- count := 0
1068
- for _, e := range entries {
1069
- if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
1070
- continue
1071
- }
1072
- skip := false
1073
- for _, pf := range excludePrefixes {
1074
- if strings.HasPrefix(e.Name(), pf) {
1075
- skip = true
1076
- break
1077
- }
1078
- }
1079
- if !skip {
1080
- count++
1081
- }
1082
- }
1083
- return count
1084
- }
1085
-
1086
- func truncate(s string, max int) string {
1087
- if len(s) <= max {
1088
- return s
1089
- }
1090
- return s[:max-1] + "…"
1091
- }
1092
-
1093
- func padRight(s string, width int) string {
1094
- if len(s) >= width {
1095
- return s[:width]
1096
- }
1097
- return s + strings.Repeat(" ", width-len(s))
1098
- }
1099
-
1100
- func padLeft(s string, width int) string {
1101
- if len(s) >= width {
1102
- return s[:width]
1103
- }
1104
- return strings.Repeat(" ", width-len(s)) + s
1105
- }
1106
-
1107
- func formatCatalogAge(iso *string) string {
1108
- if iso == nil || *iso == "" {
1109
- return ""
1110
- }
1111
- t, err := time.Parse(time.RFC3339, *iso)
1112
- if err != nil {
1113
- t, err = time.Parse("2006-01-02T15:04:05.000Z", *iso)
1114
- if err != nil {
1115
- t, err = time.Parse("2006-01-02T15:04:05Z", *iso)
1116
- if err != nil {
1117
- return ""
1118
- }
1119
- }
1120
- }
1121
- duration := time.Since(t)
1122
- days := int(duration.Hours() / 24)
1123
- if days < 0 {
1124
- return "<1d"
1125
- }
1126
- if days < 1 {
1127
- return "<1d"
1128
- }
1129
- if days < 7 {
1130
- return fmt.Sprintf("%dd", days)
1131
- }
1132
- if days < 30 {
1133
- return fmt.Sprintf("%dw", days/7)
1134
- }
1135
- return fmt.Sprintf("%dmo", days/30)
1136
- }
1137
-
1138
- // overlayCenter renders `overlay` centered on top of `bg` (both already rendered, ANSI-aware widths).
1139
- func overlayCenter(bg, overlay string) string {
1140
- bgLines := strings.Split(bg, "\n")
1141
- ovLines := strings.Split(overlay, "\n")
1142
- bgH := len(bgLines)
1143
- ovH := len(ovLines)
1144
- bgW := 0
1145
- for _, l := range bgLines {
1146
- if w := lipgloss.Width(l); w > bgW {
1147
- bgW = w
1148
- }
1149
- }
1150
- ovW := 0
1151
- for _, l := range ovLines {
1152
- if w := lipgloss.Width(l); w > ovW {
1153
- ovW = w
1154
- }
1155
- }
1156
- topPad := (bgH - ovH) / 2
1157
- leftPad := (bgW - ovW) / 2
1158
- if leftPad < 0 {
1159
- leftPad = 0
1160
- }
1161
- out := make([]string, bgH)
1162
- copy(out, bgLines)
1163
- for i, ovLine := range ovLines {
1164
- row := topPad + i
1165
- if row < 0 || row >= bgH {
1166
- continue
1167
- }
1168
- bg := out[row]
1169
- bgVisible := lipgloss.Width(bg)
1170
- // pad bg line to bgW if shorter
1171
- if bgVisible < bgW {
1172
- bg = bg + strings.Repeat(" ", bgW-bgVisible)
1173
- }
1174
- // split bg into runes accounting for ANSI — use a simple approach:
1175
- // write leftPad chars of bg, then overlay line, then rest of bg
1176
- bgStripped := lipgloss.NewStyle().Render(bg) // no-op, keeps ansi
1177
- // For simplicity: rebuild as left + overlay + right using byte slicing on stripped
1178
- // We use lipgloss.Width-aware padding approach
1179
- prefix := strings.Repeat(" ", leftPad)
1180
- suffix := ""
1181
- rightStart := leftPad + ovW
1182
- if rightStart < bgW {
1183
- suffix = strings.Repeat(" ", bgW-rightStart)
1184
- }
1185
- _ = bgStripped
1186
- out[row] = prefix + ovLine + suffix
1187
- }
1188
- return strings.Join(out, "\n")
1189
- }
1190
-
1191
- // ─────────────────────────────────────────────
1192
- // Rendering helpers
1193
- // ─────────────────────────────────────────────
1194
-
1195
- // Panel drawing helper with border labels
1196
- func drawPanel(w, h int, title string, content string, active bool) string {
1197
- style := panelBorderStyle
1198
- if active {
1199
- style = panelBorderActive
1200
- }
1201
-
1202
- // Ensure the style has the correct dimensions
1203
- style = style.Width(w - 2).Height(h - 2)
1204
-
1205
- rendered := style.Render(content)
1206
-
1207
- if title == "" {
1208
- return rendered
1209
- }
1210
-
1211
- // If the title contains escape sequences, use it as is; otherwise format as bold white
1212
- var styledTitle string
1213
- if strings.Contains(title, "\x1b") {
1214
- styledTitle = title
1215
- } else {
1216
- styledTitle = lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(title)
1217
- }
1218
-
1219
- titleFmt := " " + styledTitle + " "
1220
- titleWidth := lipgloss.Width(titleFmt)
1221
-
1222
- // Replace the first line's center with the title
1223
- lines := strings.Split(rendered, "\n")
1224
- if len(lines) > 0 {
1225
- borderColor := colorBorder
1226
- if active {
1227
- borderColor = colorBorderAct
1228
- }
1229
-
1230
- dashCount := w - 3 - titleWidth
1231
- if dashCount < 2 {
1232
- dashCount = 2
1233
- }
1234
-
1235
- borderStyle := lipgloss.NewStyle().Foreground(borderColor)
1236
-
1237
- topBorder := borderStyle.Render("╭─") +
1238
- titleFmt +
1239
- borderStyle.Render(strings.Repeat("─", dashCount)+"╮")
1240
-
1241
- lines[0] = topBorder
1242
- rendered = strings.Join(lines, "\n")
1243
- }
1244
-
1245
- return rendered
1246
- }
1247
-
1248
- type WorkspaceVersions struct {
1249
- Nuxt string
1250
- Pnpm string
1251
- Owd string
1252
- }
1253
-
1254
- func getVersions(root string, workspaceRoot string) WorkspaceVersions {
1255
- vers := WorkspaceVersions{Nuxt: "—", Pnpm: "—", Owd: "—"}
1256
-
1257
- // Read root package.json (monorepo or playground package)
1258
- rootPkgPath := filepath.Join(root, "package.json")
1259
- if data, err := os.ReadFile(rootPkgPath); err == nil {
1260
- lines := strings.Split(string(data), "\n")
1261
- for _, l := range lines {
1262
- if strings.Contains(l, `"packageManager"`) && strings.Contains(l, "pnpm@") {
1263
- parts := strings.Split(l, "pnpm@")
1264
- if len(parts) >= 2 {
1265
- vers.Pnpm = strings.Trim(parts[1], " \t\r\n,\"")
1266
- vers.Pnpm = strings.ReplaceAll(vers.Pnpm, `"`, "")
1267
- vers.Pnpm = strings.TrimSpace(vers.Pnpm)
1268
- }
1269
- }
1270
- if strings.Contains(l, `"nuxt"`) {
1271
- parts := strings.Split(l, ":")
1272
- if len(parts) >= 2 {
1273
- v := strings.Trim(parts[1], " \t\r\n,\"^~")
1274
- v = strings.ReplaceAll(v, `"`, "")
1275
- vers.Nuxt = strings.TrimSpace(v)
1276
- }
1277
- }
1278
- }
1279
- }
1280
-
1281
- // Read OWD Core version from workspaceRoot/packages/core/package.json
1282
- corePkgPath := filepath.Join(workspaceRoot, "packages", "core", "package.json")
1283
- if data, err := os.ReadFile(corePkgPath); err == nil {
1284
- lines := strings.Split(string(data), "\n")
1285
- for _, l := range lines {
1286
- if strings.Contains(l, `"version"`) {
1287
- parts := strings.Split(l, ":")
1288
- if len(parts) >= 2 {
1289
- v := strings.Trim(parts[1], " \t\r\n,\"")
1290
- v = strings.ReplaceAll(v, `"`, "")
1291
- vers.Owd = strings.TrimSpace(v)
1292
- break
1293
- }
1294
- }
1295
- }
1296
- }
1297
-
1298
- return vers
1299
- }
1300
-
1301
- func getProcessMemoryMB(pid int) int {
1302
- if pid <= 0 {
1303
- return 0
1304
- }
1305
- path := fmt.Sprintf("/proc/%d/status", pid)
1306
- data, err := os.ReadFile(path)
1307
- if err != nil {
1308
- return 0
1309
- }
1310
- lines := strings.Split(string(data), "\n")
1311
- for _, l := range lines {
1312
- if strings.HasPrefix(l, "VmRSS:") {
1313
- fields := strings.Fields(l)
1314
- if len(fields) >= 2 {
1315
- var kb int
1316
- if _, err := fmt.Sscanf(fields[1], "%d", &kb); err == nil {
1317
- return kb / 1024
1318
- }
1319
- }
1320
- }
1321
- }
1322
- return 0
1323
- }
1324
-
1325
- func renderPanelTitle(title string) string {
1326
- return lipgloss.NewStyle().
1327
- Foreground(colorWhite).
1328
- Bold(true).
1329
- Render(title)
1330
- }
1331
-
1332
- func kv(label, value string) string {
1333
- return mutedStyle.Render(padRight(label, 10)) + " " + boldStyle.Render(value)
1334
- }
1335
-
1336
- func kvRaw(label, value string) string {
1337
- return mutedStyle.Render(padRight(label, 10)) + " " + value
1338
- }
1339
-
1340
- func pill(text string, color lipgloss.Color) string {
1341
- return lipgloss.NewStyle().Foreground(color).Bold(true).Render(text)
1342
- }
1343
-
1344
- // ─────────────────────────────────────────────
1345
- // View
1346
- // ─────────────────────────────────────────────
1347
-
1348
- func (m TuiModel) View() string {
1349
- w := m.termWidth
1350
- h := m.termHeight
1351
- if w < 40 {
1352
- w = 40
1353
- }
1354
- if h < 10 {
1355
- h = 10
1356
- }
1357
-
1358
- showLogs := (m.serverRunning || m.taskActive) && w >= 120
1359
-
1360
- // Heights
1361
- topH := 9 // top panels row (with borders = 9 rows)
1362
- barH := 4 // status bar lines (including prepended/trailing newlines)
1363
- // catalog gets the remaining height
1364
- catalogH := h - topH - barH
1365
- if catalogH < 4 {
1366
- catalogH = 4
1367
- }
1368
-
1369
- // Column widths (including borders each panel takes +2 cols)
1370
- var leftW, midW, rightW int
1371
- if showLogs {
1372
- // 40% | 28% | 32% — roughly matching original blessed layout
1373
- rightW = w * 32 / 100
1374
- leftW = w * 40 / 100
1375
- midW = w - leftW - rightW
1376
- } else {
1377
- leftW = w * 58 / 100
1378
- midW = w - leftW
1379
- rightW = 0
1380
- }
1381
-
1382
- // ── Render panel contents ─────────────────────────────
1383
- leftContent := m.renderClientPanel(leftW-4, topH-2)
1384
- midContent := m.renderMetricsPanel(midW-4, topH-2)
1385
-
1386
- desktopTitle := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("Desktop") + " " +
1387
- lipgloss.NewStyle().Foreground(colorMuted).Render("control panel")
1388
-
1389
- var topRow, mainContent, catalogPanel string
1390
-
1391
- if showLogs {
1392
- catW := leftW + midW
1393
- logContent := m.renderLogsPanel(rightW-4, h-barH-2)
1394
-
1395
- // Top 2-panel row (left + mid)
1396
- topTwoPanels := lipgloss.JoinHorizontal(lipgloss.Top,
1397
- drawPanel(leftW-1, topH, desktopTitle, leftContent, false),
1398
- drawPanel(midW, topH, "Metrics", midContent, false),
1399
- )
1400
-
1401
- // Catalog panel — always rendered; modal overlaid on top if active
1402
- catalogContent := m.renderCatalogPanel(catW-4, catalogH-2, showLogs)
1403
- catalogPanel = drawPanel(catW-1, catalogH, "Catalog", catalogContent, true)
1404
- if m.activePrompt != PromptNone {
1405
- modal := m.renderModal()
1406
- styledModal := lipgloss.NewStyle().
1407
- Border(lipgloss.RoundedBorder()).
1408
- BorderForeground(colorCyan).
1409
- Padding(1, 3).
1410
- Render(modal)
1411
- catalogPanel = overlayCenter(catalogPanel, styledModal)
1412
- }
1413
-
1414
- // Left main block = top panels + catalog stacked
1415
- leftMain := lipgloss.JoinVertical(lipgloss.Left, topTwoPanels, catalogPanel)
1416
-
1417
- // Right logs panel, spans full height
1418
- logsPanel := drawPanel(rightW, h-barH, "Logs", logContent, false)
1419
-
1420
- topRow = lipgloss.JoinHorizontal(lipgloss.Top, leftMain, logsPanel)
1421
- mainContent = topRow
1422
- } else {
1423
- // No logs: top row = left + right panels side by side
1424
- topBlock := lipgloss.JoinHorizontal(lipgloss.Top,
1425
- drawPanel(leftW-1, topH, desktopTitle, leftContent, false),
1426
- drawPanel(midW, topH, "Metrics", midContent, false),
1427
- )
1428
-
1429
- // Catalog full width — always rendered; modal overlaid on top if active
1430
- catalogContent := m.renderCatalogPanel(w-4, catalogH-2, showLogs)
1431
- catalogPanel = drawPanel(w, catalogH, "Catalog", catalogContent, true)
1432
- if m.activePrompt != PromptNone {
1433
- modal := m.renderModal()
1434
- styledModal := lipgloss.NewStyle().
1435
- Border(lipgloss.RoundedBorder()).
1436
- BorderForeground(colorCyan).
1437
- Padding(1, 3).
1438
- Render(modal)
1439
- catalogPanel = overlayCenter(catalogPanel, styledModal)
1440
- }
1441
-
1442
- mainContent = lipgloss.JoinVertical(lipgloss.Left, topBlock, catalogPanel)
1443
- }
1444
-
1445
- // ── Status bar (borderless) ───────────────────────────
1446
- statusBar := m.renderStatusBar(w)
1447
-
1448
- return mainContent + statusBar
1449
- }
1450
-
1451
- // ─────────────────────────────────────────────
1452
- // Panel: Desktop Control Panel (left)
1453
- // ─────────────────────────────────────────────
1454
-
1455
- func (m TuiModel) renderClientPanel(w, h int) string {
1456
- var lines []string
1457
-
1458
- // Server status line
1459
- var statusLine string
1460
- if m.taskActive && strings.Contains(strings.ToLower(m.statusMsg), "stopping") {
1461
- spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1462
- frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
1463
- dot := lipgloss.NewStyle().Foreground(colorWarn).Bold(true).Render(frame)
1464
- statusLine = dot + " " + boldStyle.Render("STOPPING") + " " + mutedStyle.Render("http://localhost:3000")
1465
- } else if m.taskActive && strings.Contains(strings.ToLower(m.statusMsg), "starting") {
1466
- spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1467
- frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
1468
- dot := lipgloss.NewStyle().Foreground(colorCyan).Bold(true).Render(frame)
1469
- statusLine = dot + " " + boldStyle.Render("STARTING") + " " + mutedStyle.Render("http://localhost:3000")
1470
- } else if m.serverRunning {
1471
- dot := accentStyle.Render("●")
1472
- if m.blink {
1473
- dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")).Bold(true).Render("●")
1474
- }
1475
- url := lipgloss.NewStyle().Foreground(colorCyan).Render("http://localhost:3000")
1476
- statusLine = dot + " " + boldStyle.Render("RUNNING") + " " + url
1477
- if m.ctx != nil {
1478
- _ = m.ctx
1479
- }
1480
- } else if m.taskActive {
1481
- spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1482
- frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
1483
- dot := lipgloss.NewStyle().Foreground(colorWarn).Bold(true).Render(frame)
1484
- statusLine = dot + " " + boldStyle.Render("STARTING") + " " + mutedStyle.Render("http://localhost:3000")
1485
- } else {
1486
- dot := errStyle.Render("○")
1487
- statusLine = dot + " " + boldStyle.Render("STOPPED") + " " + mutedStyle.Render("http://localhost:3000")
1488
- }
1489
- lines = append(lines, " "+statusLine)
1490
- lines = append(lines, "") // Space out server status line
1491
- lines = append(lines, mutedStyle.Render(" System ")+boldStyle.Render("ready"))
1492
-
1493
- // Theme
1494
- theme := "—"
1495
- if m.ctx != nil && m.ctx.Config.Theme != nil && *m.ctx.Config.Theme != "" {
1496
- t := *m.ctx.Config.Theme
1497
- if idx := strings.LastIndex(t, "/"); idx >= 0 {
1498
- t = t[idx+1:]
1499
- }
1500
- theme = t
1501
- }
1502
- lines = append(lines, mutedStyle.Render(" Theme ")+boldStyle.Render(theme))
1503
-
1504
- // Workspace
1505
- wsPath := "—"
1506
- if m.ctx != nil {
1507
- if m.ctx.Paths.IsPlayground {
1508
- wsPath = strings.Replace(m.ctx.Paths.Desktop, os.Getenv("HOME"), "~", 1)
1509
- } else {
1510
- wsPath = strings.Replace(m.ctx.WorkspaceRoot, os.Getenv("HOME"), "~", 1)
1511
- }
1512
- } else if m.workspaceRoot != "" {
1513
- wsPath = strings.Replace(m.workspaceRoot, os.Getenv("HOME"), "~", 1)
1514
- }
1515
- wsPath = truncate(wsPath, w-14)
1516
- lines = append(lines, mutedStyle.Render(" Workspace ")+boldStyle.Render(wsPath))
1517
-
1518
- // Git
1519
- root := m.workspaceRoot
1520
- workspaceRoot := m.workspaceRoot
1521
- if m.ctx != nil {
1522
- workspaceRoot = m.ctx.WorkspaceRoot
1523
- if m.ctx.Paths.IsPlayground {
1524
- root = m.ctx.Paths.PackageDir
1525
- } else {
1526
- root = m.ctx.WorkspaceRoot
1527
- }
1528
- }
1529
- branch := gitBranch(root)
1530
- changes := gitChanges(root)
1531
- gitLine := "branch: " + accentStyle.Render(branch) + " · changes: " + changes
1532
- lines = append(lines, mutedStyle.Render(" Git ")+gitLine)
1533
-
1534
- // Versions
1535
- vers := getVersions(root, workspaceRoot)
1536
- lines = append(lines, mutedStyle.Render(" Runtime ")+boldStyle.Render(fmt.Sprintf("Nuxt v%s · OWD v%s · pnpm v%s", vers.Nuxt, vers.Owd, vers.Pnpm)))
1537
-
1538
- content := strings.Join(lines, "\n")
1539
- return content
1540
- }
1541
-
1542
- // ─────────────────────────────────────────────
1543
- // Panel: Metrics (right of control panel)
1544
- // ─────────────────────────────────────────────
1545
-
1546
- func (m TuiModel) renderMetricsPanel(w, h int) string {
1547
- var lines []string
1548
-
1549
- // Memory sparkline
1550
- spark := sparkline(m.memHistory)
1551
- memVal := "—"
1552
- if len(m.memHistory) > 0 && m.memHistory[len(m.memHistory)-1] > 0 {
1553
- memVal = fmt.Sprintf("%d MiB", m.memHistory[len(m.memHistory)-1])
1554
- }
1555
- memLine := mutedStyle.Render(padRight("Memory", 9)) + " " + boldStyle.Render(memVal) + " " + spark
1556
- lines = append(lines, " "+memLine)
1557
-
1558
- // Local stats
1559
- root := m.workspaceRoot
1560
- if m.ctx != nil {
1561
- root = m.ctx.WorkspaceRoot
1562
- }
1563
- localApps := countLocalDirs(root, "apps")
1564
- localMods := countLocalDirs(root, "packages", "core", "nx", "kit-", "cli")
1565
- localThemes := countLocalDirs(root, "themes")
1566
- localLine := mutedStyle.Render(padRight("Local", 9)) + " " +
1567
- boldStyle.Render(fmt.Sprintf("%d", localApps)) + mutedStyle.Render(" apps · ") +
1568
- boldStyle.Render(fmt.Sprintf("%d", localMods)) + mutedStyle.Render(" modules · ") +
1569
- boldStyle.Render(fmt.Sprintf("%d", localThemes)) + mutedStyle.Render(" themes")
1570
- lines = append(lines, " "+localLine)
1571
-
1572
- // Registry stats
1573
- totalStars := 0
1574
- if m.catalog != nil {
1575
- for _, e := range m.catalog.Entries {
1576
- totalStars += e.Stars
1577
- }
1578
- }
1579
- catCount := "loading…"
1580
- cacheAge := ""
1581
- if m.catalog != nil {
1582
- catCount = fmt.Sprintf("%d pkgs", len(m.catalog.Entries))
1583
- cacheAge = m.catalog.CacheAge
1584
- }
1585
- regLine := mutedStyle.Render(padRight("Registry", 9)) + " " +
1586
- boldStyle.Render(fmt.Sprintf("%d", totalStars)) + mutedStyle.Render(" stars")
1587
- lines = append(lines, " "+regLine)
1588
-
1589
- // GitHub user
1590
- ghUser := "(not set)"
1591
- sshText := warnStyle.Render("HTTPS")
1592
- if m.ctx != nil && m.ctx.Settings.GithubUser != nil {
1593
- ghUser = *m.ctx.Settings.GithubUser
1594
- sshText = mutedStyle.Render("SSH ok")
1595
- }
1596
- ghLine := mutedStyle.Render(padRight("GitHub", 9)) + " " + boldStyle.Render(ghUser) + " · " + sshText
1597
- lines = append(lines, " "+ghLine)
1598
-
1599
- // Catalog
1600
- catalogLine := mutedStyle.Render(padRight("Catalog", 9)) + " " + boldStyle.Render(catCount)
1601
- if cacheAge != "" {
1602
- catalogLine += " " + mutedStyle.Render("("+cacheAge+")")
1603
- }
1604
- lines = append(lines, " "+catalogLine)
1605
-
1606
- // Divider + tip
1607
- lines = append(lines, " "+subtleStyle.Render(strings.Repeat("─", w-2)))
1608
-
1609
- tips := []string{
1610
- "Press 1, 2, or 3 to switch catalog tabs",
1611
- "Press [s] to start the dev server",
1612
- "Press [Enter] to install / uninstall a package",
1613
- "Press [c] to change the install source",
1614
- "Press [r] to refresh the package catalog",
1615
- }
1616
- tipIdx := (m.tickCount / 6) % len(tips)
1617
- tipText := "Tip: " + tips[tipIdx]
1618
- tipLine := subtleStyle.Render(truncate(tipText, w-4))
1619
- lines = append(lines, " "+tipLine)
1620
-
1621
- return strings.Join(lines, "\n")
1622
- }
1623
-
1624
- // ─────────────────────────────────────────────
1625
- // Panel: Catalog
1626
- // ─────────────────────────────────────────────
1627
-
1628
- func (m TuiModel) renderCatalogPanel(w, h int, _ bool) string {
1629
- var lines []string
1630
-
1631
- // Tabs row
1632
- var tabLine strings.Builder
1633
- tabLine.WriteString(" ")
1634
- if m.activeTab == 0 {
1635
- tabLine.WriteString(tabActiveStyle.Render("0 Internal") + " ")
1636
- }
1637
- tabs := []string{"Apps", "Modules", "Themes"}
1638
- for i, t := range tabs {
1639
- tabIdx := i + 1
1640
- num := fmt.Sprintf("%d", tabIdx)
1641
- if tabIdx == m.activeTab {
1642
- tabLine.WriteString(tabActiveStyle.Render(num + " " + t))
1643
- } else {
1644
- tabLine.WriteString(tabInactiveStyle.Render(num + " " + t))
1645
- }
1646
- if i < len(tabs)-1 {
1647
- tabLine.WriteString(" ")
1648
- }
1649
- }
1650
- sortLabel := " " + mutedStyle.Render("│") + " " + mutedStyle.Render("Sort: Updated")
1651
- if m.loading {
1652
- spinFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1653
- spin := lipgloss.NewStyle().Foreground(colorCyan).Render(spinFrames[m.tickCount%len(spinFrames)])
1654
- sortLabel += " " + spin
1655
- }
1656
- tabLine.WriteString(sortLabel)
1657
- lines = append(lines, tabLine.String())
1658
-
1659
- // Column header (proportional to width)
1660
- nameW := 26
1661
- if w > 120 {
1662
- nameW = 30
1663
- }
1664
- colHeader := " " + lipgloss.NewStyle().Foreground(colorSubtle).Render(
1665
- padRight("", 2) + " " + // badge space
1666
- padRight("NAME", nameW) + " " +
1667
- padRight("SRC", 5) + " " +
1668
- padRight("DIR", 5) + " " +
1669
- padRight("SYNC", 6) + " " +
1670
- padRight("PUBLISHER", 14) + " " +
1671
- padLeft("STARS/AGE", 14),
1672
- )
1673
- lines = append(lines, colHeader)
1674
- lines = append(lines, " "+subtleStyle.Render(strings.Repeat("─", max(w-2, 4))))
1675
-
1676
- // Item list — reserve 4 rows for: header, col header, divider above detail, detail itself
1677
- detailLines := 2
1678
- fixedRows := 4 // tabrow + colheader + topdivider + bottomdivider
1679
- listH := h - fixedRows - detailLines
1680
- if listH < 1 {
1681
- listH = 1
1682
- }
1683
-
1684
- items := m.getActiveItems()
1685
-
1686
- // Clamp scroll
1687
- scrollOff := m.scrollOffset
1688
- if m.selectedIndex >= scrollOff+listH {
1689
- scrollOff = m.selectedIndex - listH + 1
1690
- }
1691
- if scrollOff < 0 {
1692
- scrollOff = 0
1693
- }
1694
-
1695
- if len(items) == 0 {
1696
- if m.loading {
1697
- lines = append(lines, " "+mutedStyle.Render("Loading packages…"))
1698
- } else {
1699
- lines = append(lines, " "+mutedStyle.Render("No packages found."))
1700
- }
1701
- } else {
1702
- start := scrollOff
1703
- end := start + listH
1704
- if end > len(items) {
1705
- end = len(items)
1706
- }
1707
- for i := start; i < end; i++ {
1708
- item := items[i]
1709
- line := m.renderCatalogRow(item, i == m.selectedIndex, w, nameW)
1710
- lines = append(lines, line)
1711
- }
1712
- // Padding to fill listH
1713
- for i := end - start; i < listH; i++ {
1714
- lines = append(lines, "")
1715
- }
1716
- }
1717
-
1718
- // Detail panel at bottom
1719
- lines = append(lines, " "+subtleStyle.Render(strings.Repeat("─", max(w-2, 4))))
1720
- detail := m.renderDetailRow(items)
1721
- lines = append(lines, detail)
1722
-
1723
- return strings.Join(lines, "\n")
1724
- }
1725
-
1726
- func max(a, b int) int {
1727
- if a > b {
1728
- return a
1729
- }
1730
- return b
1731
- }
1732
-
1733
- func (m TuiModel) renderCatalogRow(item bridge.CatalogEntry, selected bool, w, nameW int) string {
1734
- badge := uninstalledBadge + " "
1735
- if item.Installed {
1736
- badge = installedBadge + " "
1737
- }
1738
-
1739
- name := truncate(item.ShortName, nameW)
1740
-
1741
- // Source badge
1742
- src := "NPM"
1743
- srcColor := colorNpm
1744
- if item.LocalSource {
1745
- src = "LOC"
1746
- srcColor = colorLocal
1747
- } else if item.Installed && !item.LocalSource {
1748
- src = "GIT"
1749
- srcColor = colorGit
1750
- }
1751
- srcStr := lipgloss.NewStyle().Foreground(srcColor).Bold(true).Render(src)
1752
- srcPad := strings.Repeat(" ", max(0, 5-lipgloss.Width(srcStr)))
1753
-
1754
- dir := subtleStyle.Render("---")
1755
- if item.Installed && !item.LocalSource {
1756
- dir = errStyle.Bold(true).Render("MISS")
1757
- } else if item.LocalSource {
1758
- dir = accentStyle.Render("OK")
1759
- }
1760
- dirPad := strings.Repeat(" ", max(0, 5-lipgloss.Width(dir)))
1761
-
1762
- sync := subtleStyle.Render("---")
1763
- var syncParts []string
1764
- if item.LocalSource {
1765
- if ch, hasChanges := m.gitChangesMap[item.ShortName]; hasChanges {
1766
- if ch.Added > 0 {
1767
- syncParts = append(syncParts, accentStyle.Render(fmt.Sprintf("+%d", ch.Added)))
1768
- }
1769
- if ch.Modified > 0 {
1770
- syncParts = append(syncParts, warnStyle.Render(fmt.Sprintf("~%d", ch.Modified)))
1771
- }
1772
- if ch.Deleted > 0 {
1773
- syncParts = append(syncParts, errStyle.Render(fmt.Sprintf("-%d", ch.Deleted)))
1774
- }
1775
- }
1776
- }
1777
- if up, hasUpdate := m.updatesMap[item.ShortName]; hasUpdate {
1778
- if up.LocalGit {
1779
- countStr := ""
1780
- if up.BehindCount > 0 {
1781
- countStr = fmt.Sprintf("%d", up.BehindCount)
1782
- }
1783
- syncParts = append(syncParts, cyanStyle.Render("↓"+countStr))
1784
- } else if up.Npm {
1785
- syncParts = append(syncParts, accentStyle.Render("↑"))
1786
- }
1787
- }
1788
- if len(syncParts) > 0 {
1789
- sync = strings.Join(syncParts, " ")
1790
- }
1791
- syncPad := strings.Repeat(" ", max(0, 6-lipgloss.Width(sync)))
1792
-
1793
- pub := item.Org
1794
- if pub == "workspace" || pub == "" {
1795
- pub = "owdproject"
1796
- }
1797
- pubFmt := padRight(truncate(pub, 14), 14)
1798
-
1799
- age := formatCatalogAge(item.UpdatedAt)
1800
- starsStr := ""
1801
- if item.Stars > 0 {
1802
- starsStr = fmt.Sprintf("★%d", item.Stars)
1803
- }
1804
-
1805
- metaStr := ""
1806
- if starsStr != "" && age != "" {
1807
- metaStr = warnStyle.Render(starsStr) + " " + subtleStyle.Render(age)
1808
- } else if starsStr != "" {
1809
- metaStr = warnStyle.Render(starsStr)
1810
- } else if age != "" {
1811
- metaStr = subtleStyle.Render(age)
1812
- } else {
1813
- metaStr = subtleStyle.Render("—")
1814
- }
1815
-
1816
- metaW := lipgloss.Width(metaStr)
1817
- metaPad := ""
1818
- if metaW < 14 {
1819
- metaPad = strings.Repeat(" ", 14-metaW)
1820
- }
1821
- metaFmt := metaPad + metaStr
1822
-
1823
- nameFmt := padRight(name, nameW)
1824
-
1825
- row := fmt.Sprintf(" %s %s %s%s %s%s %s%s %s %s",
1826
- badge,
1827
- nameFmt,
1828
- srcStr,
1829
- srcPad,
1830
- dir,
1831
- dirPad,
1832
- sync,
1833
- syncPad,
1834
- pubFmt,
1835
- metaFmt,
1836
- )
1837
-
1838
- if selected {
1839
- return selectedRowStyle.Render("▶ " + strings.TrimPrefix(row, " "))
1840
- }
1841
- return row
1842
- }
1843
-
1844
- func (m TuiModel) renderDetailRow(items []bridge.CatalogEntry) string {
1845
- if len(items) == 0 || m.selectedIndex >= len(items) {
1846
- return " " + mutedStyle.Render("Select a package for install preview")
1847
- }
1848
- item := items[m.selectedIndex]
1849
-
1850
- status := mutedStyle.Render("Not Installed")
1851
- if item.Installed {
1852
- status = accentStyle.Render("Installed")
1853
- if item.LocalSource {
1854
- status += " " + lipgloss.NewStyle().Foreground(colorLocal).Render("(workspace)")
1855
- }
1856
- }
1857
-
1858
- desc := item.Description
1859
- if desc == "" {
1860
- desc = "—"
1861
- }
1862
- desc = truncate(desc, 60)
1863
-
1864
- org := item.Org
1865
- if org == "workspace" || org == "" {
1866
- org = "owdproject"
1867
- }
1868
-
1869
- stars := fmt.Sprintf("%d", item.Stars)
1870
- kind := strings.ToUpper(item.Kind)
1871
-
1872
- parts := []string{
1873
- accentStyle.Render(item.ShortName),
1874
- " " + mutedStyle.Render("Publisher:") + " " + boldStyle.Render(org),
1875
- " " + mutedStyle.Render("Stars:") + " " + boldStyle.Render(stars),
1876
- " " + mutedStyle.Render("Status:") + " " + status,
1877
- " " + mutedStyle.Render("Type:") + " " + boldStyle.Render(kind),
1878
- " " + mutedStyle.Render("—") + " " + mutedStyle.Render(desc),
1879
- }
1880
-
1881
- return " " + strings.Join(parts, " ")
1882
- }
1883
-
1884
- // ─────────────────────────────────────────────
1885
- // Panel: Logs (right column, when server running)
1886
- // ─────────────────────────────────────────────
1887
-
1888
- func (m TuiModel) renderLogsPanel(w, h int) string {
1889
- var lines []string
1890
-
1891
- lines = append(lines, "")
1892
-
1893
- // Server URLs
1894
- if m.serverRunning {
1895
- dot := accentStyle.Render("●")
1896
- if !m.blink {
1897
- dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")).Render("●")
1898
- }
1899
-
1900
- lines = append(lines, " "+dot+" "+lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("Dev server started"))
1901
- lines = append(lines, "")
1902
- lines = append(lines, " "+accentStyle.Render("✓")+" "+mutedStyle.Render("Listening on http://localhost:3000"))
1903
- lines = append(lines, "")
1904
- }
1905
-
1906
- // Log lines — show last N that fit
1907
- logH := h - len(lines) - 1
1908
- if logH < 1 {
1909
- logH = 1
1910
- }
1911
- logStart := len(m.logLines) - logH
1912
- if logStart < 0 {
1913
- logStart = 0
1914
- }
1915
- for i := logStart; i < len(m.logLines); i++ {
1916
- line := m.logLines[i]
1917
- line = truncate(line, w)
1918
- // Colorize log levels
1919
- switch {
1920
- case strings.Contains(line, "WARN") || strings.Contains(line, "Warning"):
1921
- lines = append(lines, warnStyle.Render(" "+line))
1922
- case strings.Contains(line, "ERROR") || strings.Contains(line, "Error") || strings.Contains(line, "failed"):
1923
- lines = append(lines, errStyle.Render(" "+line))
1924
- case strings.HasPrefix(line, ">>>"):
1925
- lines = append(lines, accentStyle.Render(" "+line))
1926
- case strings.Contains(line, "✓") || strings.Contains(line, "ready") || strings.Contains(line, "started"):
1927
- lines = append(lines, lipgloss.NewStyle().Foreground(colorAccent).Render(" "+line))
1928
- default:
1929
- lines = append(lines, mutedStyle.Render(" "+line))
1930
- }
1931
- }
1932
-
1933
- if len(m.logLines) == 0 {
1934
- if m.serverRunning && ServerCmd == nil {
1935
- lines = append(lines, mutedStyle.Render(" Logs unavailable (external server)"))
1936
- } else {
1937
- lines = append(lines, mutedStyle.Render(" Waiting for logs…"))
1938
- }
1939
- }
1940
-
1941
- return strings.Join(lines, "\n")
1942
- }
1943
-
1944
- // ─────────────────────────────────────────────
1945
- // Status Bar (borderless, Gemini-style)
1946
- // ─────────────────────────────────────────────
1947
-
1948
- func (m TuiModel) renderStatusBar(w int) string {
1949
- // Line 1: current mode + status
1950
- mode := ""
1951
- switch m.activeTab {
1952
- case 0:
1953
- mode = "Apps"
1954
- case 1:
1955
- mode = "Modules"
1956
- case 2:
1957
- mode = "Themes"
1958
- }
1959
-
1960
- statusIcon := ""
1961
- if m.serverRunning {
1962
- if m.blink {
1963
- statusIcon = lipgloss.NewStyle().Foreground(colorAccent).Render("● ")
1964
- } else {
1965
- statusIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")).Render("● ")
1966
- }
1967
- }
1968
-
1969
- line1Parts := []string{
1970
- statusIcon + barKeyStyle.Render("Select packages") + barStyle.Render(" · "),
1971
- barKeyStyle.Render("u") + barStyle.Render(" updates · "),
1972
- barKeyStyle.Render("g") + barStyle.Render(" settings"),
1973
- }
1974
-
1975
- var serverShortcut string
1976
- if m.serverRunning {
1977
- serverShortcut = barKeyStyle.Render("x") + barStyle.Render(" stop server")
1978
- } else {
1979
- serverShortcut = barKeyStyle.Render("s") + barStyle.Render(" start server")
1980
- }
1981
-
1982
- if m.taskActive {
1983
- spinFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1984
- spin := lipgloss.NewStyle().Foreground(colorCyan).Render(spinFrames[m.tickCount%len(spinFrames)])
1985
- line1Parts = []string{spin + " " + barStyle.Render(m.statusMsg)}
1986
- }
1987
-
1988
- _ = mode
1989
- line1 := barStyle.Width(w).Render(strings.Join(line1Parts, ""))
1990
-
1991
- // Line 2: shortcuts
1992
- sep := barSepStyle.Render(" │ ")
1993
- shortcutParts := []string{
1994
- barKeyStyle.Render("↑↓") + barStyle.Render(" move"),
1995
- barKeyStyle.Render("Space") + barStyle.Render(" toggle"),
1996
- barKeyStyle.Render("c") + barStyle.Render(" manage"),
1997
- serverShortcut,
1998
- barKeyStyle.Render("r") + barStyle.Render(" refresh"),
1999
- barKeyStyle.Render("q") + barStyle.Render(" quit"),
2000
- }
2001
- line2 := barStyle.Width(w).Render(strings.Join(shortcutParts, sep))
2002
-
2003
- return "\n\n" + line1 + "\n" + line2 + "\n"
2004
- }
2005
-
2006
- type InstallMethod struct {
2007
- Name string
2008
- Label string
2009
- Desc string
2010
- }
2011
-
2012
- func (m TuiModel) getInstallMethods(pkg *bridge.CatalogEntry) []InstallMethod {
2013
- var methods []InstallMethod
2014
-
2015
- // 0. Local folder option if already present in workspace (pkg.LocalSource is true)
2016
- if pkg.LocalSource {
2017
- methods = append(methods, InstallMethod{
2018
- Name: "local",
2019
- Label: "Use Existing Local Folder",
2020
- Desc: "Import/link the existing local folder in your workspace",
2021
- })
2022
- }
2023
-
2024
- // 1. NPM Registry
2025
- if pkg.SourcesMeta != nil && pkg.SourcesMeta.Npm != nil {
2026
- methods = append(methods, InstallMethod{
2027
- Name: "npm",
2028
- Label: "NPM Registry",
2029
- Desc: "Install from npm (default)",
2030
- })
2031
- }
2032
-
2033
- // 2. Git options
2034
- hasFork := false
2035
- forkOwner := ""
2036
- officialOwner := pkg.Org
2037
- if officialOwner == "" || officialOwner == "workspace" {
2038
- officialOwner = "owdproject"
2039
- }
2040
-
2041
- if pkg.SourcesMeta != nil && pkg.SourcesMeta.Github.Fork != nil && pkg.SourcesMeta.Github.Fork.Exists {
2042
- // Only treat as "your fork" if GitHub confirms it's actually a fork (forked from another repo)
2043
- if pkg.SourcesMeta.Github.Fork.IsFork {
2044
- hasFork = true
2045
- forkOwner = pkg.SourcesMeta.Github.Fork.Owner
2046
- }
2047
- }
2048
-
2049
- if hasFork {
2050
- methods = append(methods, InstallMethod{
2051
- Name: "git-https",
2052
- Label: "GIT HTTPS (your fork)",
2053
- Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", forkOwner, pkg.ShortName),
2054
- })
2055
- methods = append(methods, InstallMethod{
2056
- Name: "git-ssh",
2057
- Label: "GIT SSH (your fork)",
2058
- Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", forkOwner, pkg.ShortName),
2059
- })
2060
- } else {
2061
- methods = append(methods, InstallMethod{
2062
- Name: "git-https",
2063
- Label: "GIT HTTPS",
2064
- Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", officialOwner, pkg.ShortName),
2065
- })
2066
- methods = append(methods, InstallMethod{
2067
- Name: "git-ssh",
2068
- Label: "GIT SSH",
2069
- Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", officialOwner, pkg.ShortName),
2070
- })
2071
- }
2072
-
2073
- return methods
2074
- }
2075
-
2076
- // ─────────────────────────────────────────────
2077
- // Modal
2078
- // ─────────────────────────────────────────────
2079
-
2080
- func (m TuiModel) renderModal() string {
2081
- pkg := m.promptPkg
2082
- var content strings.Builder
2083
-
2084
- if m.activePrompt == PromptInstallMethod {
2085
- content.WriteString(boldStyle.Render("Install source for ") + accentStyle.Render(pkg.ShortName) + "\n\n")
2086
- methods := m.getInstallMethods(pkg)
2087
- for i, mth := range methods {
2088
- if i > 0 {
2089
- if mth.Name != "local" && methods[i-1].Name == "local" {
2090
- content.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", 62)) + "\n\n")
2091
- } else {
2092
- content.WriteString("\n")
2093
- }
2094
- }
2095
- var btn string
2096
- if i == m.promptSel {
2097
- btn = modalOptionActive.Render(mth.Label)
2098
- } else {
2099
- btn = modalOptionInactive.Render(mth.Label)
2100
- }
2101
- content.WriteString(" " + btn + "\n")
2102
- content.WriteString(" " + mutedStyle.Render(mth.Desc) + "\n")
2103
- }
2104
- content.WriteString("\n" + subtleStyle.Render("↑↓ select Enter confirm Esc cancel"))
2105
- return modalStyle.Width(72).Render(content.String())
2106
- } else if m.activePrompt == PromptUninstallConfirm {
2107
- content.WriteString(boldStyle.Render("Uninstall ") + errStyle.Render(pkg.ShortName) + boldStyle.Render("?") + "\n\n")
2108
- opts := []string{"Yes, uninstall", "No, keep it"}
2109
- for i, opt := range opts {
2110
- if i == m.promptSel {
2111
- bg := colorErr
2112
- if i == 1 {
2113
- bg = colorAccent
2114
- }
2115
- content.WriteString(lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" "+opt+" "))
2116
- } else {
2117
- content.WriteString(modalOptionInactive.Render(" " + opt + " "))
2118
- }
2119
- content.WriteString(" ")
2120
- }
2121
- content.WriteString("\n\n" + subtleStyle.Render("← → select Enter confirm Esc cancel"))
2122
- } else if m.activePrompt == PromptForceReinstallConfirm {
2123
- content.WriteString(boldStyle.Render("Force re-download ") + errStyle.Render(pkg.ShortName) + boldStyle.Render("?") + "\n")
2124
- content.WriteString(warnStyle.Render("⚠ WARNING: This will delete the local directory and lose all modifications!") + "\n\n")
2125
- opts := []string{"Yes, wipe & reinstall", "No, cancel"}
2126
- for i, opt := range opts {
2127
- if i == m.promptSel {
2128
- bg := colorErr
2129
- if i == 1 {
2130
- bg = colorAccent
2131
- }
2132
- content.WriteString(lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" "+opt+" "))
2133
- } else {
2134
- content.WriteString(modalOptionInactive.Render(" " + opt + " "))
2135
- }
2136
- content.WriteString(" ")
2137
- }
2138
- content.WriteString("\n\n" + subtleStyle.Render("← → select Enter confirm Esc cancel"))
2139
- } else if m.activePrompt == PromptManagePackage {
2140
- var leftCols []string
2141
- leftCols = append(leftCols, boldStyle.Render("Package:")+" "+accentStyle.Render(pkg.ShortName))
2142
- leftCols = append(leftCols, mutedStyle.Render("Type: ")+" "+boldStyle.Render(strings.ToUpper(pkg.Kind)))
2143
-
2144
- status := mutedStyle.Render("Not Installed")
2145
- if pkg.Installed {
2146
- status = accentStyle.Render("Installed")
2147
- if pkg.LocalSource {
2148
- status += " " + lipgloss.NewStyle().Foreground(colorLocal).Render("(workspace)")
2149
- }
2150
- }
2151
- leftCols = append(leftCols, mutedStyle.Render("Status: ")+" "+status)
2152
-
2153
- src := "NPM"
2154
- if pkg.LocalSource {
2155
- src = "LOC (workspace)"
2156
- } else if pkg.Installed && !pkg.LocalSource {
2157
- src = "GIT"
2158
- }
2159
- leftCols = append(leftCols, mutedStyle.Render("Source: ")+" "+boldStyle.Render(src))
2160
-
2161
- // Kits
2162
- var kits []string
2163
- for _, dep := range m.promptPkgDeps {
2164
- if strings.HasPrefix(dep, "@owdproject/kit-") {
2165
- shortKit := dep[strings.LastIndex(dep, "/")+1:]
2166
- kits = append(kits, shortKit)
2167
- }
2168
- }
2169
- kitsStr := "none"
2170
- if len(kits) > 0 {
2171
- var styledKits []string
2172
- for _, k := range kits {
2173
- styledKits = append(styledKits, accentStyle.Render(k))
2174
- }
2175
- kitsStr = strings.Join(styledKits, ", ")
2176
- }
2177
- leftCols = append(leftCols, mutedStyle.Render("Kits: ")+" "+kitsStr)
2178
-
2179
- gitStatus := "—"
2180
- if pkg.LocalSource {
2181
- kindDir := ""
2182
- switch pkg.Kind {
2183
- case "app":
2184
- kindDir = "apps"
2185
- case "module":
2186
- kindDir = "packages"
2187
- case "theme":
2188
- kindDir = "themes"
2189
- }
2190
- if kindDir != "" {
2191
- pkgPath := filepath.Join(m.workspaceRoot, kindDir, pkg.ShortName)
2192
- gitDir := filepath.Join(pkgPath, ".git")
2193
- if _, err := os.Stat(gitDir); err == nil {
2194
- branch := gitBranch(pkgPath)
2195
- changes := gitChanges(pkgPath)
2196
- gitStatus = fmt.Sprintf("branch: %s (%s)", branch, changes)
2197
- }
2198
- }
2199
- }
2200
- leftCols = append(leftCols, mutedStyle.Render("Git: ")+" "+gitStatus)
2201
-
2202
- // NPM & GitHub Links
2203
- npmLink := fmt.Sprintf("https://www.npmjs.com/package/%s", pkg.Name)
2204
- leftCols = append(leftCols, mutedStyle.Render("NPM: ")+" "+cyanStyle.Render(npmLink))
2205
-
2206
- ghLink := fmt.Sprintf("https://github.com/owdproject/%s", pkg.ShortName)
2207
- if pkg.HtmlUrl != nil && *pkg.HtmlUrl != "" {
2208
- ghLink = *pkg.HtmlUrl
2209
- }
2210
- leftCols = append(leftCols, mutedStyle.Render("GitHub: ")+" "+cyanStyle.Render(ghLink))
2211
-
2212
- // Deps (excluding kits)
2213
- var otherDeps []string
2214
- for _, d := range m.promptPkgDeps {
2215
- if !strings.HasPrefix(d, "@owdproject/kit-") {
2216
- s := d
2217
- if idx := strings.LastIndex(d, "/"); idx >= 0 {
2218
- s = d[idx+1:]
2219
- }
2220
- otherDeps = append(otherDeps, s)
2221
- }
2222
- }
2223
- depsStr := "none"
2224
- if len(otherDeps) > 0 {
2225
- depsStr = strings.Join(otherDeps, ", ")
2226
- }
2227
- leftCols = append(leftCols, mutedStyle.Render("Deps: ")+" "+boldStyle.Render(depsStr))
2228
-
2229
- leftPanel := strings.Join(leftCols, "\n")
2230
-
2231
- var rightCols []string
2232
- rightCols = append(rightCols, boldStyle.Render("Management Actions:")+"\n")
2233
-
2234
- actions := []string{
2235
- "Update package",
2236
- "Switch to NPM registry",
2237
- "Switch to Git (HTTPS)",
2238
- "Switch to Git (SSH)",
2239
- "Force Re-download (wipe changes)",
2240
- "Back to catalog",
2241
- }
2242
-
2243
- for i, act := range actions {
2244
- if i == m.promptSel {
2245
- rightCols = append(rightCols, modalOptionActive.Render(fmt.Sprintf(" %s ", act)))
2246
- } else {
2247
- rightCols = append(rightCols, modalOptionInactive.Render(fmt.Sprintf(" %s ", act)))
2248
- }
2249
- }
2250
-
2251
- rightPanel := strings.Join(rightCols, "\n")
2252
-
2253
- splitLayout := lipgloss.JoinHorizontal(lipgloss.Top,
2254
- lipgloss.NewStyle().Width(65).Render(leftPanel),
2255
- lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).
2256
- BorderForeground(colorDim).
2257
- PaddingLeft(3).
2258
- Render(rightPanel),
2259
- )
2260
-
2261
- content.WriteString(splitLayout)
2262
- content.WriteString("\n\n" + subtleStyle.Render("↑↓ select Enter confirm Esc close"))
2263
-
2264
- return modalStyle.Width(110).Render(content.String())
2265
- } else if m.activePrompt == PromptSettings {
2266
- content.WriteString(boldStyle.Render("Control Panel Settings") + "\n\n")
2267
-
2268
- // 1. Install Mode
2269
- modeLabel := "1. Install Mode"
2270
- var modeVal string
2271
- if m.settingsInstallMode == "npm" {
2272
- modeVal = modalOptionActive.Render(" NPM registry ") + " " + modalOptionInactive.Render(" workspace ")
2273
- } else {
2274
- modeVal = modalOptionInactive.Render(" NPM registry ") + " " + modalOptionActive.Render(" workspace ")
2275
- }
2276
- if m.settingsSel == 0 {
2277
- content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(modeLabel) + "\n " + modeVal + "\n")
2278
- } else {
2279
- content.WriteString(" " + boldStyle.Render(modeLabel) + "\n " + modeVal + "\n")
2280
- }
2281
- content.WriteString("\n")
2282
-
2283
- // 2. Catalog Sort
2284
- sortLabel := "2. Catalog Sort"
2285
- var sortVal strings.Builder
2286
- sortModes := []string{"updated", "name", "stars", "installed"}
2287
- for _, sm := range sortModes {
2288
- if sm == m.settingsCatalogSort {
2289
- sortVal.WriteString(modalOptionActive.Render(" "+sm+" ") + " ")
2290
- } else {
2291
- sortVal.WriteString(modalOptionInactive.Render(" "+sm+" ") + " ")
2292
- }
2293
- }
2294
- if m.settingsSel == 1 {
2295
- content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(sortLabel) + "\n " + sortVal.String() + "\n")
2296
- } else {
2297
- content.WriteString(" " + boldStyle.Render(sortLabel) + "\n " + sortVal.String() + "\n")
2298
- }
2299
- content.WriteString("\n")
2300
-
2301
- // 3. Trusted Orgs (Authors)
2302
- orgsLabel := "3. Trusted Orgs"
2303
- var orgsVal string
2304
- if m.ctx != nil && len(m.ctx.Settings.GithubOrgs) > 0 {
2305
- orgsVal = strings.Join(m.ctx.Settings.GithubOrgs, ", ")
2306
- } else {
2307
- orgsVal = "owdproject, atproto-os" // default/fallback
2308
- }
2309
- orgsValText := boldStyle.Render(orgsVal) + " " + subtleStyle.Render("(Read-only. Edit settings.json)")
2310
- if m.settingsSel == 2 {
2311
- content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(orgsLabel) + "\n " + orgsValText + "\n")
2312
- } else {
2313
- content.WriteString(" " + boldStyle.Render(orgsLabel) + "\n " + orgsValText + "\n")
2314
- }
2315
- content.WriteString("\n")
2316
-
2317
- // 4. GitHub User
2318
- ghLabel := "4. GitHub User"
2319
- ghUser := "(not set)"
2320
- if m.ctx != nil && m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
2321
- ghUser = *m.ctx.Settings.GithubUser
2322
- }
2323
- ghVal := boldStyle.Render(ghUser) + " " + subtleStyle.Render("(Read-only. Edit settings.json)")
2324
- if m.settingsSel == 3 {
2325
- content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(ghLabel) + "\n " + ghVal + "\n")
2326
- } else {
2327
- content.WriteString(" " + boldStyle.Render(ghLabel) + "\n " + ghVal + "\n")
2328
- }
2329
- content.WriteString("\n\n")
2330
-
2331
- // Buttons Row: Save and Cancel
2332
- var saveBtn, cancelBtn string
2333
- if m.settingsSel == 4 {
2334
- saveBtn = lipgloss.NewStyle().Background(colorAccent).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 3).Render(" SAVE ")
2335
- } else {
2336
- saveBtn = lipgloss.NewStyle().Background(colorDim).Foreground(colorWhite).Padding(0, 3).Render(" SAVE ")
2337
- }
2338
-
2339
- if m.settingsSel == 5 {
2340
- cancelBtn = lipgloss.NewStyle().Background(colorErr).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 3).Render(" CANCEL ")
2341
- } else {
2342
- cancelBtn = lipgloss.NewStyle().Background(colorDim).Foreground(colorWhite).Padding(0, 3).Render(" CANCEL ")
2343
- }
2344
-
2345
- content.WriteString(" " + saveBtn + " " + cancelBtn + "\n\n")
2346
- content.WriteString(subtleStyle.Render("↑↓ select item ←→ toggle/buttons Enter confirm Esc exit"))
2347
- return modalStyle.Width(72).Render(content.String())
2348
- }
2349
-
2350
- return modalStyle.Render(content.String())
2351
- }
2352
-
2353
- // ─────────────────────────────────────────────
2354
- // Background tasks
2355
- // ─────────────────────────────────────────────
2356
-
2357
- func runProcessAndStreamLogs(root, command string, args []string, p *tea.Program) {
2358
- cmd := exec.Command(command, args...)
2359
- cmd.Dir = root
2360
-
2361
- stdout, err := cmd.StdoutPipe()
2362
- if err != nil {
2363
- p.Send(taskFinishedMsg{Success: false, Err: err})
2364
- return
2365
- }
2366
- cmd.Stderr = cmd.Stdout
2367
-
2368
- if err := cmd.Start(); err != nil {
2369
- p.Send(taskFinishedMsg{Success: false, Err: err})
2370
- return
2371
- }
2372
-
2373
- reader := bufio.NewReader(stdout)
2374
- for {
2375
- line, err := reader.ReadString('\n')
2376
- if err != nil {
2377
- if err != io.EOF {
2378
- // ignore partial read errors
2379
- }
2380
- break
2381
- }
2382
- p.Send(logLineMsg(strings.TrimRight(line, "\r\n")))
2383
- }
2384
-
2385
- err = cmd.Wait()
2386
- p.Send(taskFinishedMsg{Success: err == nil, Err: err})
2387
- }
2388
-
2389
- func (m *TuiModel) RunInstallTask(pkgName string, method string, p *tea.Program) {
2390
- go func() {
2391
- shortName := pkgName
2392
- if idx := strings.LastIndex(pkgName, "/"); idx >= 0 {
2393
- shortName = pkgName[idx+1:]
2394
- }
2395
-
2396
- desktopJs := filepath.Join(m.workspaceRoot, "packages", "cli", "bin", "desktop.js")
2397
-
2398
- // Build arguments
2399
- args := []string{desktopJs, "add", shortName}
2400
- if method == "npm" {
2401
- args = append(args, "--npm")
2402
- } else if method == "local" {
2403
- args = append(args, "--dev")
2404
- } else {
2405
- // Find org/owner
2406
- owner := "owdproject"
2407
- if m.ctx != nil && m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
2408
- owner = *m.ctx.Settings.GithubUser
2409
- } else if m.catalog != nil {
2410
- for _, e := range m.catalog.Entries {
2411
- if e.Name == pkgName && e.Org != "" {
2412
- owner = e.Org
2413
- break
2414
- }
2415
- }
2416
- }
2417
-
2418
- var fromVal string
2419
- if method == "git-ssh" {
2420
- fromVal = fmt.Sprintf("git@github.com:%s/%s.git", owner, shortName)
2421
- } else {
2422
- fromVal = fmt.Sprintf("https://github.com/%s/%s.git", owner, shortName)
2423
- }
2424
- args = append(args, "--from", fromVal)
2425
- }
2426
-
2427
- p.Send(logLineMsg(fmt.Sprintf(">>> Executing: node desktop.js add %s", shortName)))
2428
- runProcessAndStreamLogs(m.workspaceRoot, "node", args, p)
2429
-
2430
- // Check if it's a theme and resolve dependencies!
2431
- isTheme := strings.HasPrefix(shortName, "theme-")
2432
- if isTheme {
2433
- p.Send(logLineMsg(">>> Resolving theme dependencies…"))
2434
- deps, err := getThemeDependencies(m.workspaceRoot, shortName)
2435
- if err == nil {
2436
- for _, dep := range deps {
2437
- // Check if already installed in context
2438
- installed := false
2439
- if m.ctx != nil {
2440
- for _, d := range m.ctx.Deps {
2441
- if d == dep {
2442
- installed = true
2443
- break
2444
- }
2445
- }
2446
- }
2447
-
2448
- if !installed {
2449
- depShort := dep
2450
- if idx := strings.LastIndex(dep, "/"); idx >= 0 {
2451
- depShort = dep[idx+1:]
2452
- }
2453
-
2454
- p.Send(logLineMsg(fmt.Sprintf(">>> Installing theme dependency: %s…", depShort)))
2455
- depArgs := []string{desktopJs, "add", depShort}
2456
- if method == "npm" {
2457
- depArgs = append(depArgs, "--npm")
2458
- } else {
2459
- depOwner := "owdproject"
2460
- if m.ctx != nil && m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
2461
- depOwner = *m.ctx.Settings.GithubUser
2462
- }
2463
-
2464
- var fromVal string
2465
- if method == "git-ssh" {
2466
- fromVal = fmt.Sprintf("git@github.com:%s/%s.git", depOwner, depShort)
2467
- } else {
2468
- fromVal = fmt.Sprintf("https://github.com/%s/%s.git", depOwner, depShort)
2469
- }
2470
- depArgs = append(depArgs, "--from", fromVal)
692
+ // 2. NPM latest version check
693
+ if entry.Installed && !entry.LocalSource {
694
+ localVer := getLocalVersion(m.workspaceRoot, entry)
695
+ if localVer != "" {
696
+ latestVer, err := fetchLatestNpmVersion(entry.Name)
697
+ if err != nil {
698
+ res.err = err
699
+ } else if semverCompare(localVer, latestVer) < 0 {
700
+ res.update = &UpdateInfo{Npm: true}
2471
701
  }
2472
- runProcessAndStreamLogs(m.workspaceRoot, "node", depArgs, p)
2473
702
  }
2474
703
  }
2475
- }
2476
- }
2477
-
2478
- // Rebuild stubs
2479
- p.Send(logLineMsg(">>> Rebuilding stubs…"))
2480
- runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"run", "prepare:modules"}, p)
2481
-
2482
- p.Send(taskFinishedMsg{Success: true})
2483
- }()
2484
- }
2485
-
2486
- func (m *TuiModel) RunUninstallTask(p *tea.Program) {
2487
- go func() {
2488
- p.Send(logLineMsg(">>> Running pnpm install (cleanup)…"))
2489
- runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"install"}, p)
2490
- p.Send(logLineMsg(">>> Preparing workspace modules…"))
2491
- runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"run", "prepare:modules"}, p)
2492
- p.Send(taskFinishedMsg{Success: true})
2493
- }()
2494
- }
2495
-
2496
- func (m *TuiModel) RunServeTask(p *tea.Program) {
2497
- go func() {
2498
- if m.ctx == nil {
2499
- p.Send(serverStatusMsg{Running: false})
2500
- return
2501
- }
2502
-
2503
- _ = os.MkdirAll(m.ctx.Paths.MetaDir, 0755)
2504
704
 
2505
- logPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.log")
2506
- logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
2507
- if err != nil {
2508
- p.Send(logLineMsg(fmt.Sprintf(">>> Failed to open log file: %v", err)))
2509
- p.Send(serverStatusMsg{Running: false})
2510
- return
2511
- }
2512
- defer logFile.Close()
2513
-
2514
- logFile.WriteString(">>> Starting Nuxt dev server (pnpm run dev)…\n")
2515
-
2516
- cmd := exec.Command("pnpm", "run", "dev")
2517
- if m.ctx.Paths.IsPlayground {
2518
- cmd.Dir = m.ctx.Paths.PackageDir
2519
- } else {
2520
- cmd.Dir = m.workspaceRoot
2521
- }
2522
-
2523
- cmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", m.ctx.Settings.DevPort))
2524
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
2525
- cmd.Stdout = logFile
2526
- cmd.Stderr = logFile
2527
- ServerCmd = cmd
2528
-
2529
- if err := cmd.Start(); err != nil {
2530
- logFile.WriteString(fmt.Sprintf(">>> Server failed to start: %v\n", err))
2531
- p.Send(serverStatusMsg{Running: false})
2532
- return
2533
- }
2534
-
2535
- // Write PID file
2536
- pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
2537
- _ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644)
2538
-
2539
- p.Send(serverStatusMsg{Running: true})
2540
-
2541
- cmd.Wait()
2542
- logFile.WriteString(">>> Dev server exited.\n")
2543
- p.Send(serverStatusMsg{Running: false})
2544
- ServerCmd = nil
2545
- }()
2546
- }
2547
-
2548
- func (m *TuiModel) StopServeTask(p *tea.Program) {
2549
- go func() {
2550
- if m.ctx == nil {
2551
- p.Send(serverStatusMsg{Running: false})
2552
- return
2553
- }
2554
-
2555
- if ServerCmd != nil && ServerCmd.Process != nil {
2556
- _ = syscall.Kill(-ServerCmd.Process.Pid, syscall.SIGKILL)
2557
- ServerCmd = nil
2558
- }
2559
-
2560
- pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
2561
- if data, err := os.ReadFile(pidPath); err == nil {
2562
- var pid int
2563
- if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err == nil {
2564
- _ = syscall.Kill(-pid, syscall.SIGKILL)
2565
- _ = syscall.Kill(pid, syscall.SIGKILL)
2566
- }
2567
- _ = os.Remove(pidPath)
2568
- }
2569
-
2570
- _ = exec.Command("pkill", "-9", "-f", "nuxt.mjs").Run()
2571
- _ = exec.Command("pkill", "-9", "-f", "nuxt dev").Run()
2572
- _ = exec.Command("pkill", "-9", "-f", "nx run desktop:serve").Run()
2573
-
2574
- p.Send(serverStatusMsg{Running: false})
2575
- }()
2576
- }
2577
-
2578
- func StartLogTailer(logPath string, p *tea.Program) {
2579
- go func() {
2580
- var lastSize int64 = 0
2581
-
2582
- // Check if file exists and get its initial size
2583
- if info, err := os.Stat(logPath); err == nil {
2584
- lastSize = info.Size()
2585
- // Seek back up to 10000 bytes (~100 lines) to show initial history
2586
- offset := lastSize - 10000
2587
- if offset < 0 {
2588
- offset = 0
2589
- }
2590
-
2591
- file, err := os.Open(logPath)
2592
- if err == nil {
2593
- _, _ = file.Seek(offset, 0)
2594
- reader := bufio.NewReader(file)
2595
- if offset > 0 {
2596
- _, _ = reader.ReadString('\n') // discard first potentially cut line
2597
- }
2598
- for {
2599
- line, err := reader.ReadString('\n')
2600
- if err != nil {
2601
- break
2602
- }
2603
- p.Send(logLineMsg(strings.TrimRight(line, "\r\n")))
2604
- }
2605
- file.Close()
2606
- }
705
+ resultsChan <- res
706
+ }(e)
2607
707
  }
2608
708
 
2609
- for {
2610
- time.Sleep(250 * time.Millisecond)
2611
-
2612
- info, err := os.Stat(logPath)
2613
- if err != nil {
2614
- continue
2615
- }
2616
-
2617
- if info.Size() < lastSize {
2618
- // File was truncated/cleared
2619
- lastSize = info.Size()
2620
- p.Send(clearLogsMsg{})
2621
- continue
709
+ var checkErr error
710
+ for i := 0; i < len(m.catalog.Entries); i++ {
711
+ res := <-resultsChan
712
+ if res.update != nil {
713
+ updatesMap[res.shortName] = *res.update
2622
714
  }
2623
-
2624
- if info.Size() > lastSize {
2625
- file, err := os.Open(logPath)
2626
- if err != nil {
2627
- continue
2628
- }
2629
- _, err = file.Seek(lastSize, 0)
2630
- if err != nil {
2631
- file.Close()
2632
- continue
2633
- }
2634
-
2635
- reader := bufio.NewReader(file)
2636
- for {
2637
- line, err := reader.ReadString('\n')
2638
- if err != nil {
2639
- break
2640
- }
2641
- p.Send(logLineMsg(strings.TrimRight(line, "\r\n")))
2642
- }
2643
- lastSize = info.Size()
2644
- file.Close()
715
+ if res.err != nil && checkErr == nil {
716
+ checkErr = res.err
2645
717
  }
2646
718
  }
2647
- }()
2648
- }
2649
-
2650
- // ─────────────────────────────────────────────
2651
- // Git & Updates Helpers
2652
- // ─────────────────────────────────────────────
2653
-
2654
- func runGitStatus(dir string) (added, modified, deleted int, err error) {
2655
- cmd := exec.Command("git", "status", "--porcelain")
2656
- cmd.Dir = dir
2657
- out, err := cmd.Output()
2658
- if err != nil {
2659
- return 0, 0, 0, err
2660
- }
2661
- lines := strings.Split(string(out), "\n")
2662
- for _, line := range lines {
2663
- if len(line) < 3 {
2664
- continue
2665
- }
2666
- status := line[:2]
2667
- if strings.HasPrefix(status, "??") {
2668
- added++
2669
- } else if strings.Contains(status, "D") {
2670
- deleted++
2671
- } else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
2672
- modified++
2673
- }
2674
- }
2675
- return added, modified, deleted, nil
2676
- }
2677
-
2678
- func runGitStatusForSubdir(rootDir, subdir string) (added, modified, deleted int, err error) {
2679
- cmd := exec.Command("git", "status", "--porcelain", subdir)
2680
- cmd.Dir = rootDir
2681
- out, err := cmd.Output()
2682
- if err != nil {
2683
- return 0, 0, 0, err
2684
- }
2685
- lines := strings.Split(string(out), "\n")
2686
- for _, line := range lines {
2687
- if len(line) < 3 {
2688
- continue
2689
- }
2690
- status := line[:2]
2691
- if strings.HasPrefix(status, "??") {
2692
- added++
2693
- } else if strings.Contains(status, "D") {
2694
- deleted++
2695
- } else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
2696
- modified++
2697
- }
2698
- }
2699
- return added, modified, deleted, nil
2700
- }
2701
-
2702
- func runGitBehindCheck(dir string) (int, error) {
2703
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
2704
- defer cancel()
2705
-
2706
- cmdFetch := exec.CommandContext(ctx, "git", "fetch")
2707
- cmdFetch.Dir = dir
2708
- _ = cmdFetch.Run() // ignore fetch errors
2709
-
2710
- cmdRev := exec.Command("git", "rev-list", "--count", "HEAD..@{u}")
2711
- cmdRev.Dir = dir
2712
- out, err := cmdRev.Output()
2713
- if err != nil {
2714
- // Try with origin/branch
2715
- cmdBranch := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
2716
- cmdBranch.Dir = dir
2717
- branchOut, errBranch := cmdBranch.Output()
2718
- if errBranch != nil {
2719
- return 0, errBranch
2720
- }
2721
- branch := strings.TrimSpace(string(branchOut))
2722
- if branch == "" || branch == "HEAD" {
2723
- return 0, err
2724
- }
2725
- cmdRev = exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..origin/%s", branch))
2726
- cmdRev.Dir = dir
2727
- out, err = cmdRev.Output()
2728
- if err != nil {
2729
- return 0, err
2730
- }
2731
- }
2732
-
2733
- var behind int
2734
- _, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &behind)
2735
- return behind, err
2736
- }
2737
719
 
2738
- func fetchLatestNpmVersion(pkgName string) (string, error) {
2739
- client := &http.Client{Timeout: 3 * time.Second}
2740
- resp, err := client.Get(fmt.Sprintf("https://registry.npmjs.org/%s/latest", url.PathEscape(pkgName)))
2741
- if err != nil {
2742
- return "", err
2743
- }
2744
- defer resp.Body.Close()
2745
- if resp.StatusCode != http.StatusOK {
2746
- return "", fmt.Errorf("HTTP %d", resp.StatusCode)
2747
- }
2748
- var data struct {
2749
- Version string `json:"version"`
2750
- }
2751
- if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
2752
- return "", err
720
+ return remoteUpdatesMsg{Updates: updatesMap, Err: checkErr}
2753
721
  }
2754
- return data.Version, nil
2755
722
  }
2756
723
 
2757
724
  func getLocalVersion(workspaceRoot string, entry bridge.CatalogEntry) string {
@@ -2800,145 +767,6 @@ func semverCompare(v1, v2 string) int {
2800
767
  return len(parts1) - len(parts2)
2801
768
  }
2802
769
 
2803
- func (m TuiModel) checkForUpdatesCmd() tea.Cmd {
2804
- return func() tea.Msg {
2805
- gitChangesMap := make(map[string]GitChanges)
2806
- updatesMap := make(map[string]UpdateInfo)
2807
-
2808
- if m.catalog == nil {
2809
- return updatesLoadedMsg{GitChanges: gitChangesMap, Updates: updatesMap}
2810
- }
2811
-
2812
- type result struct {
2813
- shortName string
2814
- changes *GitChanges
2815
- update *UpdateInfo
2816
- }
2817
-
2818
- resultsChan := make(chan result, len(m.catalog.Entries))
2819
- sem := make(chan struct{}, 10) // Limit concurrency
2820
-
2821
- for _, e := range m.catalog.Entries {
2822
- sem <- struct{}{}
2823
- go func(entry bridge.CatalogEntry) {
2824
- defer func() { <-sem }()
2825
-
2826
- res := result{shortName: entry.ShortName}
2827
-
2828
- // 1. Check local changes & upstream behind counts
2829
- short := entry.ShortName
2830
- kindDir := ""
2831
- switch entry.Kind {
2832
- case "app":
2833
- kindDir = "apps"
2834
- case "module":
2835
- kindDir = "packages"
2836
- case "theme":
2837
- kindDir = "themes"
2838
- }
2839
-
2840
- if kindDir != "" {
2841
- pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
2842
- gitDir := filepath.Join(pkgPath, ".git")
2843
- if _, err := os.Stat(gitDir); err == nil {
2844
- added, modified, deleted, err := runGitStatus(pkgPath)
2845
- if err == nil && (added > 0 || modified > 0 || deleted > 0) {
2846
- res.changes = &GitChanges{Added: added, Modified: modified, Deleted: deleted}
2847
- }
2848
-
2849
- behind, err := runGitBehindCheck(pkgPath)
2850
- if err == nil && behind > 0 {
2851
- res.update = &UpdateInfo{LocalGit: true, BehindCount: behind}
2852
- }
2853
- } else {
2854
- // Fallback: Check if the monorepo itself tracks changes for this directory
2855
- parentGitDir := filepath.Join(m.workspaceRoot, ".git")
2856
- if _, err := os.Stat(parentGitDir); err == nil {
2857
- relPath := filepath.Join(kindDir, short)
2858
- added, modified, deleted, err := runGitStatusForSubdir(m.workspaceRoot, relPath)
2859
- if err == nil && (added > 0 || modified > 0 || deleted > 0) {
2860
- res.changes = &GitChanges{Added: added, Modified: modified, Deleted: deleted}
2861
- }
2862
- }
2863
- }
2864
- }
2865
-
2866
- // 2. Check NPM updates
2867
- if entry.Installed && !entry.LocalSource {
2868
- localVer := getLocalVersion(m.workspaceRoot, entry)
2869
- if localVer != "" {
2870
- latestVer, err := fetchLatestNpmVersion(entry.Name)
2871
- if err == nil && semverCompare(localVer, latestVer) < 0 {
2872
- res.update = &UpdateInfo{Npm: true}
2873
- }
2874
- }
2875
- }
2876
-
2877
- resultsChan <- res
2878
- }(e)
2879
- }
2880
-
2881
- for i := 0; i < len(m.catalog.Entries); i++ {
2882
- res := <-resultsChan
2883
- if res.changes != nil {
2884
- gitChangesMap[res.shortName] = *res.changes
2885
- }
2886
- if res.update != nil {
2887
- updatesMap[res.shortName] = *res.update
2888
- }
2889
- }
2890
-
2891
- return updatesLoadedMsg{GitChanges: gitChangesMap, Updates: updatesMap}
2892
- }
2893
- }
2894
-
2895
- // ─────────────────────────────────────────────
2896
- // Theme Dependencies & Package Metadata Helpers
2897
- // ─────────────────────────────────────────────
2898
-
2899
- func getThemeDependencies(workspaceRoot, themeShortName string) ([]string, error) {
2900
- var pathsToCheck []string
2901
- pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "themes", themeShortName, "package.json"))
2902
- pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "node_modules", "@owdproject", themeShortName, "package.json"))
2903
-
2904
- var data []byte
2905
- var err error
2906
- for _, p := range pathsToCheck {
2907
- data, err = os.ReadFile(p)
2908
- if err == nil {
2909
- break
2910
- }
2911
- }
2912
-
2913
- if err != nil {
2914
- return nil, err
2915
- }
2916
-
2917
- var pkg struct {
2918
- Dependencies map[string]string `json:"dependencies"`
2919
- DevDependencies map[string]string `json:"devDependencies"`
2920
- }
2921
- if err := json.Unmarshal(data, &pkg); err != nil {
2922
- return nil, err
2923
- }
2924
-
2925
- var deps []string
2926
- seen := make(map[string]bool)
2927
- for name := range pkg.Dependencies {
2928
- if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
2929
- deps = append(deps, name)
2930
- seen[name] = true
2931
- }
2932
- }
2933
- for name := range pkg.DevDependencies {
2934
- if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
2935
- deps = append(deps, name)
2936
- seen[name] = true
2937
- }
2938
- }
2939
- return deps, nil
2940
- }
2941
-
2942
770
  func (m *TuiModel) loadPackageDetails(pkg *bridge.CatalogEntry) {
2943
771
  m.promptPkgDeps = []string{}
2944
772
  m.promptPkgHasPrimevue = false
@@ -3010,5 +838,32 @@ func (m *TuiModel) loadPackageDetails(pkg *bridge.CatalogEntry) {
3010
838
  }
3011
839
  }
3012
840
 
3013
- // keep rand used (for future sparkline noise simulation)
3014
- var _ = rand.Float64
841
+ func (m *TuiModel) isClearLogsClick(msg tea.MouseMsg) bool {
842
+ if msg.Y != 0 {
843
+ return false
844
+ }
845
+
846
+ w := m.termWidth
847
+ if w < 120 {
848
+ return false
849
+ }
850
+
851
+ leftW := w * 40 / 100
852
+ midW := w * 28 / 100
853
+ catW := leftW + midW
854
+
855
+ panelStartX := catW - 1
856
+
857
+ rightPanelTitle := "Logs"
858
+ if m.activePrompt == PromptResolveDependency {
859
+ rightPanelTitle = "Wizard Setup"
860
+ } else if m.activeTask == TaskSetup || m.activeTask == TaskWipe {
861
+ rightPanelTitle = "Setup"
862
+ }
863
+
864
+ prefixLen := len(rightPanelTitle) + 2
865
+ clearStartX := panelStartX + 2 + prefixLen
866
+ clearEndX := clearStartX + 7
867
+
868
+ return msg.X >= clearStartX && msg.X < clearEndX
869
+ }