main
1package extractor
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io/fs"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "slices"
12 "time"
13
14 "github.com/aymanbagabas/go-udiff"
15 "github.com/charmbracelet/bubbles/progress"
16 tea "github.com/charmbracelet/bubbletea"
17 "github.com/charmbracelet/lipgloss"
18)
19
20var (
21 ErrFileModified error = errors.New("dst file modified")
22)
23
24const (
25 _statusWalkError = "walk_error"
26 _statusDir = "directory"
27 _statusFailed = "failed"
28 _statusCreated = "created"
29 _statusUnchanged = "unchanged"
30 _statusModified = "modified"
31)
32
33type ExtractResult struct {
34 Src string
35 Dst string
36 Status string
37 Details string
38 Err error
39}
40
41type Extractor struct {
42 dst string // target dir
43 fs fs.FS // source fs (e.g. embed.FS)
44 mode os.FileMode // apply to all files
45 Results []ExtractResult // per-file results
46}
47
48type progressModel struct {
49 progress progress.Model
50 current int
51 total int
52 done bool
53 spinner int
54}
55
56var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
57
58func (m progressModel) Init() tea.Cmd {
59 return tea.Tick(time.Millisecond*100, func(time.Time) tea.Msg {
60 return tickMsg{}
61 })
62}
63
64func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65 switch msg := msg.(type) {
66 case tea.KeyMsg:
67 return m, tea.Quit
68 case tickMsg:
69 if !m.done {
70 m.spinner = (m.spinner + 1) % len(spinnerFrames)
71 return m, tea.Tick(time.Millisecond*150, func(time.Time) tea.Msg {
72 return tickMsg{}
73 })
74 }
75 return m, nil
76 case progressMsg:
77 m.current = msg.current
78 m.total = msg.total
79 if m.current >= m.total {
80 m.done = true
81 return m, tea.Quit
82 }
83 return m, nil
84 }
85 return m, nil
86}
87
88func (m progressModel) View() string {
89 if m.done {
90 // Completion style with home theme (purple/gray)
91 checkmark := lipgloss.NewStyle().
92 Foreground(lipgloss.Color("#9945ff")).
93 Bold(true).
94 Render("✓")
95
96 title := lipgloss.NewStyle().
97 Foreground(lipgloss.Color("#ffffff")).
98 Bold(true).
99 Render("Home Directory Setup Complete")
100
101 stats := lipgloss.NewStyle().
102 Foreground(lipgloss.Color("#666666")).
103 Render(fmt.Sprintf("(%d files processed)", m.total))
104
105 return fmt.Sprintf("\n %s %s %s\n\n", checkmark, title, stats)
106 }
107
108 // Progress bar styling with home-inspired colors (purple theme)
109 percentage := float64(m.current) / float64(m.total)
110
111 // Styled spinner
112 spinner := lipgloss.NewStyle().
113 Foreground(lipgloss.Color("#9945ff")).
114 Bold(true).
115 Render(spinnerFrames[m.spinner])
116
117 // Styled title
118 title := lipgloss.NewStyle().
119 Foreground(lipgloss.Color("#ffffff")).
120 Bold(true).
121 Render("Extracting Home Directory")
122
123 // Progress bar with custom styling
124 progressBar := m.progress.ViewAs(percentage)
125
126 // Styled stats
127 stats := lipgloss.NewStyle().
128 Foreground(lipgloss.Color("#888888")).
129 Render(fmt.Sprintf("%d/%d files (%.1f%%)", m.current, m.total, percentage*100))
130
131 // Status indicator
132 statusIndicator := lipgloss.NewStyle().
133 Foreground(lipgloss.Color("#bb77ff")).
134 Render(" • processing...")
135
136 return fmt.Sprintf("\n %s %s\n %s\n %s%s\n",
137 spinner, title, progressBar, stats, statusIndicator)
138}
139
140type progressMsg struct {
141 current int
142 total int
143}
144
145type tickMsg struct{}
146
147// TODO: Options
148func New(
149 fs fs.FS,
150 dst string,
151) (*Extractor, error) {
152
153 mode := os.FileMode(0o700)
154 return &Extractor{
155 dst: dst,
156 fs: fs,
157 mode: mode,
158 }, nil
159}
160
161func (e *Extractor) Deploy() error {
162 return e.DeployWithProgress()
163}
164
165func (e *Extractor) DeployWithProgress() error {
166 // Count total files first
167 totalFiles, err := e.countFiles()
168 if err != nil {
169 return fmt.Errorf("counting files: %w", err)
170 }
171
172 if totalFiles == 0 {
173 return nil // Nothing to extract
174 }
175
176 // Setup enhanced progress bar with home-inspired colors (purple theme)
177 p := progress.New(
178 progress.WithScaledGradient("#9945ff", "#bb77ff"),
179 progress.WithWidth(50),
180 progress.WithoutPercentage(),
181 )
182
183 model := progressModel{
184 progress: p,
185 total: totalFiles,
186 }
187
188 program := tea.NewProgram(model)
189 var deployErr error
190
191 go func() {
192 fileCount := 0
193 walkErr := fs.WalkDir(e.fs, ".", func(path string, d fs.DirEntry, err error) error {
194 fileCount++
195
196 // Send progress update
197 program.Send(progressMsg{current: fileCount, total: totalFiles})
198
199 // Call the original deploy logic
200 return e.deployFile(path, d, err)
201 })
202
203 if walkErr != nil {
204 deployErr = walkErr
205 program.Quit()
206 return
207 }
208
209 // Signal completion
210 program.Send(progressMsg{current: totalFiles, total: totalFiles})
211 }()
212
213 _, err = program.Run()
214 if err != nil {
215 return fmt.Errorf("running progress: %w", err)
216 }
217
218 if deployErr != nil {
219 return deployErr
220 }
221
222 return nil
223}
224
225func (e *Extractor) countFiles() (int, error) {
226 count := 0
227 err := fs.WalkDir(e.fs, ".", func(path string, d fs.DirEntry, err error) error {
228 if err != nil {
229 return err
230 }
231 count++
232 return nil
233 })
234 return count, err
235}
236
237// Deploy extracts an the embedded extractor.fs filesystem into the exractor.dst
238// directory. Embeded paths will be preserved.
239// - implements the fs.WalkDirFunc interface
240func (e *Extractor) deployFile(path string, d fs.DirEntry, err error) error {
241
242 dir, _ := filepath.Split(path)
243 dstPath := filepath.Join(e.dst, path)
244 dstDir := filepath.Join(e.dst, dir)
245 result := ExtractResult{
246 Src: path,
247 Dst: dstPath,
248 }
249
250 defer func() {
251 e.Results = append(e.Results, result)
252 }()
253
254 if err != nil {
255 result.Err = err
256 result.Status = _statusWalkError
257 return nil
258 }
259
260 if d.IsDir() {
261 result.Status = _statusDir
262 return nil
263 }
264
265 dstExists := true
266 stat, err := os.Stat(dstPath)
267 if err != nil {
268 if !errors.Is(err, os.ErrNotExist) {
269 result.Err = fmt.Errorf("failed to stat file: %w", err)
270 return nil
271 }
272 dstExists = false
273 }
274 if stat != nil && stat.IsDir() {
275 result.Status = _statusDir
276 return nil
277 }
278
279 err = os.MkdirAll(dstDir, e.mode)
280 if err != nil {
281 result.Status = _statusFailed
282 result.Err = fmt.Errorf("failed to create dir: %w", err)
283 return nil
284 }
285
286 // TODO: Reader / Writer for big files
287 srcData, err := fs.ReadFile(e.fs, path)
288 if err != nil {
289 result.Status = _statusFailed
290 result.Err = fmt.Errorf("failed to read src file: %w", err)
291 return nil
292 }
293
294 if dstExists {
295 dstData, err := os.ReadFile(dstPath)
296 if err != nil {
297 result.Status = _statusFailed
298 result.Err = fmt.Errorf("failed to read dst file: %w", err)
299 return nil
300 }
301
302 if slices.Equal(srcData, dstData) {
303 result.Status = _statusUnchanged
304 return nil
305 }
306
307 // Generate diff
308 diff := udiff.Unified(path, dstPath, string(srcData), string(dstData))
309
310 result.Status = _statusModified
311 result.Details = diff
312 result.Err = ErrFileModified
313 return nil
314 }
315
316 err = os.WriteFile(dstPath, srcData, e.mode)
317 if err != nil {
318 result.Status = _statusFailed
319 result.Err = fmt.Errorf("failed to write dst: %w", err)
320 return nil
321 }
322 return nil
323}
324
325
326func (e *Extractor) LogResults(logger *slog.Logger, ctx context.Context) {
327 for _, r := range e.Results {
328 level := slog.LevelInfo
329 if r.Status == _statusDir || r.Status == _statusUnchanged {
330 level = slog.LevelDebug
331 }
332 attrs := []slog.Attr{
333 slog.String("status", r.Status),
334 slog.String("dst", r.Dst),
335 }
336 if r.Err != nil {
337 level = slog.LevelError
338 if errors.Is(r.Err, ErrFileModified) {
339 level = slog.LevelWarn
340 }
341 attrs = append(attrs, slog.Any("err", r.Err))
342 }
343 logger.LogAttrs(ctx, level, r.Status, attrs...)
344 }
345}