package cmd import ( "compress/gzip" "fmt" "io" "os" "runtime" "syscall" "time" "unsafe" "github.com/charmbracelet/bubbles/progress" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" "golang.org/x/sys/unix" ) var ( source string target string ) var createCmd = &cobra.Command{ Use: "create", Short: "Create a new backup", Long: "Create a new backup of a file or disk", Run: func(cmd *cobra.Command, args []string) { model, err := initialModel(source, target+".gz") if err != nil { fmt.Println("Error:", err) os.Exit(1) } p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Println("Error:", err) os.Exit(1) } }, } type model struct { progress progress.Model source string target string totalBytes int64 processed int64 done bool err error startTime time.Time progressCh chan int64 } type progressMsg int64 type doneMsg struct{} type errorMsg struct{ err error } // getFileSize возвращает размер файла или диска func getFileSize(path string) (int64, error) { fileInfo, err := os.Stat(path) if err != nil { return 0, err } // Для Linux: проверка, является ли источник блочным устройством if runtime.GOOS == "linux" { stat, ok := fileInfo.Sys().(*syscall.Stat_t) if ok && (stat.Mode&syscall.S_IFMT) == syscall.S_IFBLK { // Это блочное устройство - получаем размер через ioctl f, err := os.Open(path) if err != nil { return 0, err } defer f.Close() var size uint64 _, _, errno := unix.Syscall( unix.SYS_IOCTL, f.Fd(), unix.BLKGETSIZE64, uintptr(unsafe.Pointer(&size)), ) if errno != 0 { return 0, fmt.Errorf("ioctl BLKGETSIZE64 failed: %v", errno) } return int64(size), nil } } // Для обычных файлов и других систем return fileInfo.Size(), nil } func initialModel(src, dst string) (*model, error) { totalBytes, err := getFileSize(src) if err != nil { return nil, err } p := progress.New( progress.WithDefaultGradient(), progress.WithWidth(40), ) return &model{ progress: p, source: src, target: dst, totalBytes: totalBytes, startTime: time.Now(), progressCh: make(chan int64, 100), }, nil } func (m *model) Init() tea.Cmd { return tea.Batch( m.startCompression, m.progressListener, ) } func (m *model) startCompression() tea.Msg { // Переносим всю работу с файлами внутрь горутины go func() { srcFile, err := os.Open(m.source) if err != nil { m.progressCh <- -1 return } defer srcFile.Close() dstFile, err := os.Create(m.target) if err != nil { m.progressCh <- -1 return } defer dstFile.Close() gzipWriter := gzip.NewWriter(dstFile) defer gzipWriter.Close() // Для блочных устройств используем прямой доступ buf := make([]byte, 32*1024) // 32KB buffer for { n, err := srcFile.Read(buf) if n > 0 { _, writeErr := gzipWriter.Write(buf[:n]) if writeErr != nil { m.progressCh <- -1 return } m.progressCh <- int64(n) } if err == io.EOF { break } if err != nil { m.progressCh <- -1 return } } m.progressCh <- -2 // Сигнал завершения }() return nil } func (m *model) progressListener() tea.Msg { select { case n := <-m.progressCh: switch { case n == -1: // Ошибка return errorMsg{fmt.Errorf("compression error")} case n == -2: // Завершение return doneMsg{} default: // Прогресс m.processed += n return progressMsg(m.processed) } } } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.Type == tea.KeyCtrlC { return m, tea.Quit } return m, nil case tea.WindowSizeMsg: m.progress.Width = msg.Width - 4 return m, nil case progressMsg: // Обновляем прогрессбар progressVal := float64(msg) / float64(m.totalBytes) if progressVal > 1.0 { progressVal = 1.0 } // Создаем команду для обновления прогрессбара cmd := m.progress.SetPercent(progressVal) // Продолжаем слушать обновления return m, tea.Batch(cmd, m.progressListener) case doneMsg: m.done = true // Устанавливаем 100% и завершаем cmd := m.progress.SetPercent(1.0) return m, tea.Batch(cmd, tea.Quit) case errorMsg: m.err = msg.err return m, tea.Quit } return m, nil } func (m *model) View() string { if m.err != nil { return fmt.Sprintf("\n❌ Error: %v\n\n", m.err) } if m.done { duration := time.Since(m.startTime) ratio := float64(m.processed) / float64(m.totalBytes) * 100 return fmt.Sprintf("\n✅ Backup complete!\n\n"+ "Source: %s\n"+ "Backup: %s\n"+ "Size: %s → %s (%.1f%%)\n"+ "Time: %v\n\n", m.source, m.target, formatBytes(m.totalBytes), formatBytes(m.processed), ratio, duration.Round(time.Millisecond)) } title := fmt.Sprintf("Backing up %s → %s", m.source, m.target) progressVal := float64(m.processed) / float64(m.totalBytes) progressView := m.progress.ViewAs(progressVal) stats := fmt.Sprintf("%s / %s (%.1f%%)", formatBytes(m.processed), formatBytes(m.totalBytes), progressVal*100) return lipgloss.JoinVertical(lipgloss.Left, title, "", progressView, "", stats, "\nPress Ctrl+C to cancel", ) } func formatBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) } func init() { rootCmd.AddCommand(createCmd) createCmd.Flags().StringVarP(&source, "source", "s", "", "Source file or disk (e.g., /dev/sda) (required)") createCmd.Flags().StringVarP(&target, "target", "t", "", "Target backup file (required)") createCmd.MarkFlagRequired("source") createCmd.MarkFlagRequired("target") }