Skip to content

Commit e02c73c

Browse files
engalarako
authored andcommitted
feat(tui): project picker dialog with history when -p is omitted
1 parent ed51a4b commit e02c73c

3 files changed

Lines changed: 216 additions & 3 deletions

File tree

cmd/mxcli/cmd_tui.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,25 @@ Example:
3838
`,
3939
Run: func(cmd *cobra.Command, args []string) {
4040
projectPath, _ := cmd.Flags().GetString("project")
41+
mxcliPath, _ := os.Executable()
42+
4143
if projectPath == "" {
42-
fmt.Fprintln(os.Stderr, "Error: --project (-p) is required")
43-
os.Exit(1)
44+
picker := tui.NewPickerModel()
45+
p := tea.NewProgram(picker, tea.WithAltScreen())
46+
result, err := p.Run()
47+
if err != nil {
48+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
49+
os.Exit(1)
50+
}
51+
m := result.(tui.PickerModel)
52+
if m.Chosen() == "" {
53+
return
54+
}
55+
projectPath = m.Chosen()
4456
}
4557

46-
mxcliPath, _ := os.Executable()
58+
tui.SaveHistory(projectPath)
59+
4760
m := tui.New(mxcliPath, projectPath)
4861
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
4962
if _, err := p.Run(); err != nil {

tui/history.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package tui
4+
5+
import (
6+
"encoding/json"
7+
"os"
8+
"path/filepath"
9+
"slices"
10+
)
11+
12+
func historyPath() string {
13+
home, _ := os.UserHomeDir()
14+
return filepath.Join(home, ".mxcli", "tui_history.json")
15+
}
16+
17+
// LoadHistory returns recent project paths, most recent first.
18+
func LoadHistory() []string {
19+
data, err := os.ReadFile(historyPath())
20+
if err != nil {
21+
return nil
22+
}
23+
var paths []string
24+
json.Unmarshal(data, &paths)
25+
return paths
26+
}
27+
28+
// SaveHistory adds mprPath to the front of the history list (max 10 entries).
29+
func SaveHistory(mprPath string) {
30+
paths := LoadHistory()
31+
paths = slices.DeleteFunc(paths, func(p string) bool { return p == mprPath })
32+
paths = append([]string{mprPath}, paths...)
33+
if len(paths) > 10 {
34+
paths = paths[:10]
35+
}
36+
os.MkdirAll(filepath.Dir(historyPath()), 0755)
37+
data, _ := json.Marshal(paths)
38+
os.WriteFile(historyPath(), data, 0644)
39+
}

tui/picker.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package tui
4+
5+
import (
6+
"strings"
7+
8+
"github.com/charmbracelet/bubbles/textinput"
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/lipgloss"
11+
)
12+
13+
// PickerModel lets the user select from recent projects or type a new path.
14+
type PickerModel struct {
15+
history []string
16+
cursor int
17+
input textinput.Model
18+
inputMode bool
19+
chosen string
20+
done bool
21+
width int
22+
height int
23+
}
24+
25+
// NewPickerModel creates the picker model with loaded history.
26+
func NewPickerModel() PickerModel {
27+
ti := textinput.New()
28+
ti.Placeholder = "/path/to/App.mpr"
29+
ti.Prompt = " Path: "
30+
ti.CharLimit = 500
31+
32+
return PickerModel{
33+
history: LoadHistory(),
34+
input: ti,
35+
}
36+
}
37+
38+
// Chosen returns the selected project path (empty if cancelled).
39+
func (m PickerModel) Chosen() string {
40+
return m.chosen
41+
}
42+
43+
func (m PickerModel) Init() tea.Cmd {
44+
return nil
45+
}
46+
47+
func (m PickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
48+
switch msg := msg.(type) {
49+
case tea.WindowSizeMsg:
50+
m.width = msg.Width
51+
m.height = msg.Height
52+
53+
case tea.KeyMsg:
54+
if m.inputMode {
55+
switch msg.String() {
56+
case "esc":
57+
m.inputMode = false
58+
m.input.Blur()
59+
return m, nil
60+
case "enter":
61+
val := strings.TrimSpace(m.input.Value())
62+
if val != "" {
63+
m.chosen = val
64+
m.done = true
65+
return m, tea.Quit
66+
}
67+
default:
68+
var cmd tea.Cmd
69+
m.input, cmd = m.input.Update(msg)
70+
return m, cmd
71+
}
72+
} else {
73+
switch msg.String() {
74+
case "ctrl+c", "q":
75+
m.done = true
76+
return m, tea.Quit
77+
case "j", "down":
78+
if m.cursor < len(m.history)-1 {
79+
m.cursor++
80+
}
81+
case "k", "up":
82+
if m.cursor > 0 {
83+
m.cursor--
84+
}
85+
case "enter":
86+
if len(m.history) > 0 {
87+
m.chosen = m.history[m.cursor]
88+
m.done = true
89+
return m, tea.Quit
90+
}
91+
case "n":
92+
m.inputMode = true
93+
m.input.SetValue("")
94+
m.input.Focus()
95+
return m, nil
96+
}
97+
}
98+
}
99+
return m, nil
100+
}
101+
102+
func (m PickerModel) View() string {
103+
boxStyle := lipgloss.NewStyle().
104+
Border(lipgloss.RoundedBorder()).
105+
BorderForeground(lipgloss.Color("63")).
106+
Padding(1, 2).
107+
Width(60)
108+
109+
titleStyle := lipgloss.NewStyle().
110+
Bold(true).
111+
Foreground(lipgloss.Color("63"))
112+
113+
selectedStyle := lipgloss.NewStyle().
114+
Foreground(lipgloss.Color("255")).
115+
Bold(true)
116+
117+
normalStyle := lipgloss.NewStyle().
118+
Foreground(lipgloss.Color("245"))
119+
120+
dimStyle := lipgloss.NewStyle().
121+
Foreground(lipgloss.Color("240"))
122+
123+
var sb strings.Builder
124+
sb.WriteString(titleStyle.Render("Select Mendix Project") + "\n\n")
125+
126+
if len(m.history) == 0 && !m.inputMode {
127+
sb.WriteString(dimStyle.Render("No recent projects.") + "\n\n")
128+
} else if !m.inputMode {
129+
sb.WriteString(dimStyle.Render("Recent projects:") + "\n")
130+
for i, path := range m.history {
131+
prefix := " "
132+
var line string
133+
if i == m.cursor {
134+
prefix = "> "
135+
line = selectedStyle.Render(prefix + path)
136+
} else {
137+
line = normalStyle.Render(prefix + path)
138+
}
139+
sb.WriteString(line + "\n")
140+
}
141+
sb.WriteString("\n")
142+
}
143+
144+
if m.inputMode {
145+
sb.WriteString(m.input.View() + "\n\n")
146+
sb.WriteString(dimStyle.Render("[Enter] confirm [Esc] back") + "\n")
147+
} else {
148+
hint := "[n] new path"
149+
if len(m.history) > 0 {
150+
hint = "[j/k] navigate [Enter] open [n] new path [q] quit"
151+
}
152+
sb.WriteString(dimStyle.Render(hint) + "\n")
153+
}
154+
155+
content := boxStyle.Render(sb.String())
156+
157+
if m.width > 0 && m.height > 0 {
158+
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content)
159+
}
160+
return content
161+
}

0 commit comments

Comments
 (0)