main
Raw Download raw file
  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}