main
1package nvim
2
3import (
4 _ "embed"
5 "text/template"
6 "time"
7
8 "archive/tar"
9 "bytes"
10 "compress/gzip"
11 "errors"
12 "fmt"
13 "io"
14 "os"
15 "path/filepath"
16 "strings"
17
18 "github.com/charmbracelet/bubbles/progress"
19 tea "github.com/charmbracelet/bubbletea"
20 "github.com/charmbracelet/lipgloss"
21 "github.com/go-git/go-git/v5"
22)
23
24//go:embed nvim-linux-x86_64.tar.gz
25var nvimSrc []byte
26
27type progressModel struct {
28 progress progress.Model
29 current int
30 total int
31 done bool
32 phase string
33 spinner int
34}
35
36var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
37
38func (m progressModel) Init() tea.Cmd {
39 return tea.Tick(time.Millisecond*120, func(time.Time) tea.Msg {
40 return tickMsg{}
41 })
42}
43
44func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45 switch msg := msg.(type) {
46 case tea.KeyMsg:
47 return m, tea.Quit
48 case tickMsg:
49 if !m.done {
50 m.spinner = (m.spinner + 1) % len(spinnerFrames)
51 return m, tea.Tick(time.Millisecond*120, func(time.Time) tea.Msg {
52 return tickMsg{}
53 })
54 }
55 return m, nil
56 case progressMsg:
57 m.current = msg.current
58 m.total = msg.total
59 m.phase = msg.phase
60 if m.current >= m.total && m.phase == "complete" {
61 m.done = true
62 return m, tea.Quit
63 }
64 return m, nil
65 }
66 return m, nil
67}
68
69func (m progressModel) View() string {
70 if m.done {
71 // Completion style with vim colors
72 checkmark := lipgloss.NewStyle().
73 Foreground(lipgloss.Color("#00ff00")).
74 Bold(true).
75 Render("✓")
76
77 title := lipgloss.NewStyle().
78 Foreground(lipgloss.Color("#ffffff")).
79 Bold(true).
80 Render("Nvim + Kickstart Installation Complete")
81
82 stats := lipgloss.NewStyle().
83 Foreground(lipgloss.Color("#666666")).
84 Render(fmt.Sprintf("(%d files + kickstart.nvim config)", m.total))
85
86 return fmt.Sprintf("\n %s %s %s\n\n", checkmark, title, stats)
87 }
88
89 // Progress bar styling with vim-inspired colors
90 percentage := float64(m.current) / float64(m.total)
91
92 // Styled spinner
93 spinnerColor := "#00ff00" // Green like vim
94 if m.phase == "git" {
95 spinnerColor = "#ff6600" // Orange for git phase
96 }
97
98 spinner := lipgloss.NewStyle().
99 Foreground(lipgloss.Color(spinnerColor)).
100 Bold(true).
101 Render(spinnerFrames[m.spinner])
102
103 // Styled title
104 status := "Installing Nvim"
105 titleColor := "#ffffff"
106 if m.phase == "git" {
107 status = "Cloning kickstart.nvim"
108 titleColor = "#ff9900"
109 }
110
111 title := lipgloss.NewStyle().
112 Foreground(lipgloss.Color(titleColor)).
113 Bold(true).
114 Render(status)
115
116 // Progress bar with custom styling
117 progressBar := m.progress.ViewAs(percentage)
118
119 // Styled stats
120 stats := lipgloss.NewStyle().
121 Foreground(lipgloss.Color("#888888")).
122 Render(fmt.Sprintf("%d/%d files (%.1f%%)", m.current, m.total, percentage*100))
123
124 // Phase indicator
125 phaseIndicator := ""
126 if m.phase == "git" {
127 phaseIndicator = lipgloss.NewStyle().
128 Foreground(lipgloss.Color("#ffaa00")).
129 Render(" • configuring editor...")
130 } else if m.current > 50 {
131 phaseIndicator = lipgloss.NewStyle().
132 Foreground(lipgloss.Color("#00aa00")).
133 Render(" • extracting runtime...")
134 }
135
136 return fmt.Sprintf("\n %s %s\n %s\n %s%s\n",
137 spinner, title, progressBar, stats, phaseIndicator)
138}
139
140type progressMsg struct {
141 current int
142 total int
143 phase string
144}
145
146type tickMsg struct{}
147
148func bashrcd() error {
149 homeDir := os.Getenv("HOME")
150 installDir := filepath.Join(homeDir, ".local")
151 rcPath := filepath.Join(homeDir, ".bashrc.d", "nvim.sh")
152
153 const tmpl = `#!/bin/bash
154export PATH=$PATH:{{ .InstallDir }}/bin
155alias vim=nvim
156`
157
158 t, err := template.New("pathRC").Parse(tmpl)
159 if err != nil {
160 return err
161 }
162 f, err := os.Create(rcPath)
163 if err != nil {
164 return err
165 }
166 err = t.Execute(f, struct{ InstallDir string }{InstallDir: installDir})
167 if err != nil {
168 return err
169 }
170 return nil
171}
172
173func Install() error {
174 homeDir := os.Getenv("HOME")
175 installDir := filepath.Join(homeDir, ".local")
176
177 err := bashrcd()
178 if err != nil {
179 return fmt.Errorf("installing bashrc.d env helper: %w", err)
180 }
181
182 nvimShareDir := filepath.Join(installDir, "share/nvim")
183 nvimLibDir := filepath.Join(installDir, "lib/nvim")
184 nvimBin := filepath.Join(installDir, "bin/nvim")
185 for _, path := range []string{nvimShareDir, nvimLibDir, nvimBin} {
186 err = os.RemoveAll(path)
187 if err != nil {
188 return fmt.Errorf("cleaning up old install path=%q: %w", path, err)
189 }
190 }
191
192 // Setup enhanced progress bar with vim-inspired colors
193 p := progress.New(
194 progress.WithScaledGradient("#00ff00", "#88ff88"),
195 progress.WithWidth(45),
196 progress.WithoutPercentage(),
197 )
198
199 model := progressModel{
200 progress: p,
201 total: nvimTotalFiles + 1, // +1 for git clone step
202 }
203
204 program := tea.NewProgram(model)
205 var installErr error
206
207 go func() {
208 gr, err := gzip.NewReader(bytes.NewReader(nvimSrc))
209 if err != nil {
210 installErr = fmt.Errorf("setting up gzip reader: %w", err)
211 program.Quit()
212 return
213 }
214 defer gr.Close()
215 tr := tar.NewReader(gr)
216
217 fileCount := 0
218 for {
219 header, err := tr.Next()
220 if err != nil {
221 if errors.Is(err, io.EOF) {
222 break
223 }
224 installErr = err
225 program.Quit()
226 return
227 }
228 fileCount++
229
230 // Send progress update
231 program.Send(progressMsg{current: fileCount, total: nvimTotalFiles + 1, phase: "extract"})
232
233 mode := os.FileMode(header.Mode)
234 target := filepath.Join(installDir, header.Name)
235
236 // Strip the first component (nvim-linux-x86_64/)
237 parts := strings.Split(header.Name, "/")
238 if len(parts) > 0 {
239 parts = parts[1:]
240 if len(parts) == 0 {
241 continue
242 }
243 target = filepath.Join(append([]string{installDir}, parts...)...)
244 }
245
246 switch header.Typeflag {
247 case tar.TypeDir:
248 err = os.MkdirAll(target, mode)
249 if err != nil {
250 installErr = err
251 program.Quit()
252 return
253 }
254
255 case tar.TypeReg:
256 // Ensure parent directory exists
257 dir := filepath.Dir(target)
258 err = os.MkdirAll(dir, 0755)
259 if err != nil {
260 installErr = err
261 program.Quit()
262 return
263 }
264
265 flag := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
266 f, err := os.OpenFile(target, flag, mode)
267 if err != nil {
268 installErr = err
269 program.Quit()
270 return
271 }
272 _, err = io.Copy(f, tr)
273 if err != nil {
274 f.Close()
275 installErr = err
276 program.Quit()
277 return
278 }
279 err = f.Close()
280 if err != nil {
281 installErr = err
282 program.Quit()
283 return
284 }
285 }
286 }
287
288 // Clone kickstart.nvim configuration
289 program.Send(progressMsg{current: nvimTotalFiles, total: nvimTotalFiles + 1, phase: "git"})
290
291 configDir := os.Getenv("XDG_CONFIG_HOME")
292 if configDir == "" {
293 configDir = filepath.Join(homeDir, ".config")
294 }
295 nvimConfigDir := filepath.Join(configDir, "nvim")
296
297 // Remove existing nvim config if it exists
298 err = os.RemoveAll(nvimConfigDir)
299 if err != nil {
300 installErr = fmt.Errorf("cleaning up existing nvim config: %w", err)
301 program.Quit()
302 return
303 }
304
305 _, err = git.PlainClone(nvimConfigDir, false, &git.CloneOptions{
306 URL: "https://github.com/nvim-lua/kickstart.nvim.git",
307 })
308 if err != nil {
309 installErr = fmt.Errorf("cloning kickstart.nvim: %w", err)
310 program.Quit()
311 return
312 }
313
314 // Signal completion
315 program.Send(progressMsg{current: nvimTotalFiles + 1, total: nvimTotalFiles + 1, phase: "complete"})
316 }()
317
318 _, err = program.Run()
319 if err != nil {
320 return fmt.Errorf("running progress: %w", err)
321 }
322
323 if installErr != nil {
324 return installErr
325 }
326
327 return nil
328}