@owdproject/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/bin/bridge.js +4 -2
- package/bin/cli.js +63 -5
- package/bin/cp.js +42 -23
- package/bin/lib/catalog.js +3 -0
- package/bin/lib/config.js +51 -8
- package/bin/lib/cpFormat.js +2 -2
- package/bin/lib/create.js +246 -0
- package/bin/lib/desktopConfig.js +48 -8
- package/bin/lib/generateTemplate.js +2 -1
- package/bin/lib/install.js +17 -21
- package/bin/lib/installWizardFlow.js +90 -24
- package/bin/lib/packageSources.js +10 -0
- package/bin/lib/status.js +2 -0
- package/bin/lib/validateModule.js +112 -2
- package/bin/lib/workspace.js +68 -1
- package/go/bridge/bridge.go +20 -0
- package/go/desktop-go +0 -0
- package/go/go.mod +17 -10
- package/go/go.sum +34 -19
- package/go/main.go +7 -3
- package/go/owd-cli +0 -0
- package/go/tui/engine.go +472 -0
- package/go/tui/engine_test.go +40 -0
- package/go/tui/handlers.go +604 -0
- package/go/tui/helpers.go +916 -0
- package/go/tui/helpers_test.go +97 -0
- package/go/tui/modals.go +423 -0
- package/go/tui/resolver.go +377 -0
- package/go/tui/resolver_test.go +86 -0
- package/go/tui/styles.go +153 -0
- package/go/tui/tasks.go +850 -0
- package/go/tui/tui.go +451 -2596
- package/go/tui/tui_test.go +69 -0
- package/go/tui/types.go +230 -0
- package/go/tui/view.go +812 -0
- package/go/tui/workqueue.go +179 -0
- package/go/tui/workqueue_test.go +90 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return serverStatusMsg{Running: false}
|
|
139
|
+
port := m.ctx.Settings.DevPort
|
|
140
|
+
if port == 0 {
|
|
141
|
+
port = 3000
|
|
300
142
|
}
|
|
301
143
|
|
|
302
|
-
|
|
303
|
-
if
|
|
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
|
-
|
|
308
|
-
if
|
|
309
|
-
|
|
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
|
-
|
|
313
|
-
return serverStatusMsg{Running: err == nil}
|
|
157
|
+
return serverStatusMsg{Running: true}
|
|
314
158
|
}
|
|
315
159
|
}
|
|
316
160
|
|
|
317
|
-
|
|
318
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
264
|
+
|
|
265
|
+
return m, tea.Batch(cmds...)
|
|
359
266
|
|
|
360
267
|
case tea.KeyMsg:
|
|
361
|
-
|
|
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
|
|
407
|
-
m.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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.
|
|
417
|
-
|
|
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.
|
|
435
|
-
|
|
436
|
-
m.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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.
|
|
453
|
-
m.
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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.
|
|
459
|
+
return m, tea.Batch(m.checkLocalChangesCmd(), m.checkRemoteUpdatesCmd())
|
|
492
460
|
}
|
|
493
461
|
|
|
494
|
-
case
|
|
462
|
+
case localChangesMsg:
|
|
495
463
|
m.gitChangesMap = msg.GitChanges
|
|
496
|
-
m.
|
|
464
|
+
m.localGitDirs = msg.LocalGitDirs
|
|
497
465
|
m.checkingUpdates = false
|
|
498
466
|
m.loading = false
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
return m,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
521
|
+
return m, m.listenToChannel()
|
|
525
522
|
|
|
526
523
|
case taskFinishedMsg:
|
|
527
|
-
m.
|
|
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("
|
|
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("
|
|
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.
|
|
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
|
-
|
|
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[
|
|
582
|
+
m.logLines = m.logLines[len(m.logLines)-200:]
|
|
554
583
|
}
|
|
555
584
|
}
|
|
556
585
|
|
|
557
|
-
// ─────────────────────────────────────────────
|
|
558
|
-
// Prompt keys
|
|
559
|
-
// ─────────────────────────────────────────────
|
|
560
586
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
653
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
func (m TuiModel)
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
745
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
770
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
662
|
+
for _, e := range m.catalog.Entries {
|
|
663
|
+
sem <- struct{}{}
|
|
664
|
+
go func(entry bridge.CatalogEntry) {
|
|
665
|
+
defer func() { <-sem }()
|
|
784
666
|
|
|
785
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
2506
|
-
|
|
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
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3014
|
-
|
|
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
|
+
}
|