diff --git a/Makefile b/Makefile index 452d39d..e2d6729 100644 --- a/Makefile +++ b/Makefile @@ -32,4 +32,4 @@ deploy: clean build clean: go clean - rm ${OUTPUT_BIN} \ No newline at end of file + rm -f ${OUTPUT_BIN} \ No newline at end of file diff --git a/go.mod b/go.mod index 616337d..657ca6f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/oklog/run v1.1.0 github.com/pterm/pterm v0.12.79 github.com/rivo/tview v0.0.0-20240728114935-65571ae51e71 + github.com/sergi/go-diff v1.2.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 diff --git a/internal/ui/dialog/delete_file_dialog.go b/internal/ui/dialog/delete_file_dialog.go index cfc4ed2..af99e0b 100644 --- a/internal/ui/dialog/delete_file_dialog.go +++ b/internal/ui/dialog/delete_file_dialog.go @@ -37,8 +37,8 @@ func NewDeleteFileDialog(application *tview.Application, file *data.FileBrowserE func (d *DeleteFileDialog) createLayout() { dialogTitle := " Delete File " - textDesctiption := fmt.Sprintf("Delete '%s'?", d.file.Name) - textDesctiptionView := tview.NewTextView().SetText(textDesctiption) + textDescription := fmt.Sprintf("Delete '%s'?", d.file.Name) + textDescriptionView := tview.NewTextView().SetText(textDescription) optionTable := tview.NewTable() optionTable.SetSelectable(true, false) @@ -94,7 +94,7 @@ func (d *DeleteFileDialog) createLayout() { } dialogContent := tview.NewFlex().SetDirection(tview.FlexRow) - dialogContent.AddItem(textDesctiptionView, 0, 1, false) + dialogContent.AddItem(textDescriptionView, 0, 1, false) dialogContent.AddItem(optionTable, 0, 1, true) dialog := createModal(dialogTitle, dialogContent, 50, 6) diff --git a/internal/ui/dialog/delete_snapshot_dialog.go b/internal/ui/dialog/delete_snapshot_dialog.go index 87b60a9..2e9fa4e 100644 --- a/internal/ui/dialog/delete_snapshot_dialog.go +++ b/internal/ui/dialog/delete_snapshot_dialog.go @@ -37,8 +37,8 @@ func NewDeleteSnapshotDialog(application *tview.Application, snapshot *data.Snap func (d *DeleteSnapshotDialog) createLayout() { dialogTitle := " Destroy Snapshot " - textDesctiption := fmt.Sprintf("Destroy '%s'?", d.snapshot.Snapshot.Name) - textDesctiptionView := tview.NewTextView().SetText(textDesctiption) + textDescription := fmt.Sprintf("Destroy '%s'?", d.snapshot.Snapshot.Name) + textDescriptionView := tview.NewTextView().SetText(textDescription) optionTable := tview.NewTable() optionTable.SetSelectable(true, false) @@ -92,7 +92,7 @@ func (d *DeleteSnapshotDialog) createLayout() { } dialogContent := tview.NewFlex().SetDirection(tview.FlexRow) - dialogContent.AddItem(textDesctiptionView, 0, 1, false) + dialogContent.AddItem(textDescriptionView, 0, 1, false) dialogContent.AddItem(optionTable, 0, 1, true) dialog := createModal(dialogTitle, dialogContent, 50, 6) diff --git a/internal/ui/dialog/file_action_dialog.go b/internal/ui/dialog/file_action_dialog.go index ac913a1..e4bf41f 100644 --- a/internal/ui/dialog/file_action_dialog.go +++ b/internal/ui/dialog/file_action_dialog.go @@ -5,7 +5,9 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "golang.org/x/exp/slices" + "os/exec" "zfs-file-history/internal/data" + "zfs-file-history/internal/data/diff_state" "zfs-file-history/internal/ui/util" ) @@ -13,7 +15,8 @@ const ( ActionDialog util.Page = "ActionDialog" // recursively restores all files and folders top to bottom starting with the given entry - FileDialogRestoreFileActionId DialogActionId = iota + FileDialogShowDiffActionId DialogActionId = iota + FileDialogRestoreFileActionId FileDialogRestoreRecursiveDialogActionId FileDialogDeleteDialogActionId FileDialogCreateSnapshotDialogActionId @@ -41,8 +44,8 @@ func NewFileActionDialog(application *tview.Application, file *data.FileBrowserE func (d *FileActionDialog) createLayout() { dialogTitle := " Select Action " - textDesctiption := fmt.Sprintf("What do you want to do with '%s'?", d.file.Name) - textDesctiptionView := tview.NewTextView().SetText(textDesctiption) + textDescription := fmt.Sprintf("What do you want to do with '%s'?", d.file.Name) + textDescriptionView := tview.NewTextView().SetText(textDescription) optionTable := tview.NewTable() optionTable.SetSelectable(true, false) @@ -75,7 +78,13 @@ func (d *FileActionDialog) createLayout() { } if d.file.Type == data.File { - dialogOptions = slices.Insert(dialogOptions, 0, &DialogOption{ + if DiffBinExists() && d.file.DiffState == diff_state.Modified { + dialogOptions = slices.Insert(dialogOptions, 0, &DialogOption{ + Id: FileDialogShowDiffActionId, + Name: fmt.Sprintf("Show diff"), + }) + } + dialogOptions = slices.Insert(dialogOptions, 1, &DialogOption{ Id: FileDialogRestoreFileActionId, Name: fmt.Sprintf("Restore file"), }) @@ -123,7 +132,7 @@ func (d *FileActionDialog) createLayout() { } dialogContent := tview.NewFlex().SetDirection(tview.FlexRow) - dialogContent.AddItem(textDesctiptionView, 0, 1, false) + dialogContent.AddItem(textDescriptionView, 0, 1, false) dialogContent.AddItem(optionTable, 0, 1, true) dialog := createModal(dialogTitle, dialogContent, 50, 15) @@ -142,6 +151,14 @@ func (d *FileActionDialog) createLayout() { d.layout = dialog } +func DiffBinExists() bool { + _, err := exec.LookPath(DiffBinPath) + if err != nil { + return false + } + return true +} + func (d *FileActionDialog) GetName() string { return string(ActionDialog) } @@ -169,6 +186,8 @@ func (d *FileActionDialog) RestoreFile() { func (d *FileActionDialog) selectAction(option *DialogOption) { switch option.Id { + case FileDialogShowDiffActionId: + d.ShowDiff() case FileDialogRestoreFileActionId: d.RestoreFile() case FileDialogRestoreRecursiveDialogActionId: @@ -202,3 +221,10 @@ func (d *FileActionDialog) CreateSnapshot() { d.actionChannel <- FileDialogCreateSnapshotDialogActionId }() } + +func (d *FileActionDialog) ShowDiff() { + go func() { + d.actionChannel <- DialogCloseActionId + d.actionChannel <- FileDialogShowDiffActionId + }() +} diff --git a/internal/ui/dialog/file_diff_dialog.go b/internal/ui/dialog/file_diff_dialog.go new file mode 100644 index 0000000..7e1c6ff --- /dev/null +++ b/internal/ui/dialog/file_diff_dialog.go @@ -0,0 +1,113 @@ +package dialog + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "os/exec" + "strings" + "zfs-file-history/internal/data" + "zfs-file-history/internal/ui/util" +) + +const ( + DiffBinPath = "/usr/bin/diff" + + FileDiffDialogPage util.Page = "FileDiffDialog" +) + +type FileDiffDialog struct { + application *tview.Application + file *data.FileBrowserEntry + snapshot *data.SnapshotBrowserEntry + layout *tview.Flex + actionChannel chan DialogActionId +} + +func NewFileDiffDialog(application *tview.Application, file *data.FileBrowserEntry, snapshot *data.SnapshotBrowserEntry) *FileDiffDialog { + dialog := &FileDiffDialog{ + application: application, + file: file, + snapshot: snapshot, + actionChannel: make(chan DialogActionId), + } + + dialog.createLayout() + + return dialog +} + +func (d *FileDiffDialog) createLayout() { + dialogTitle := " File Diff " + + realFilePath := d.file.RealFile.Path + snapshotFilePath := d.snapshot.Snapshot.GetSnapshotPath(d.file.RealFile.Path) + + output, err := exec.Command( + DiffBinPath, + "-U", "3", + snapshotFilePath, + realFilePath, + ).Output() + diffText := string(output) + if err != nil && err.Error() != "exit status 1" { + diffText = "error calculating diff: " + err.Error() + } + + diffTextLines := strings.Split(diffText, "\n") + for i := 0; i < len(diffTextLines); i++ { + line := diffTextLines[i] + if strings.HasPrefix(line, "+") { + diffTextLines[i] = `[green]` + line + `[white]` + } + if strings.HasPrefix(line, "-") { + diffTextLines[i] = `[red]` + line + `[white]` + } + } + diffText = strings.Join(diffTextLines, "\n") + + textDescriptionView := tview.NewTextView(). + SetDynamicColors(true). + SetRegions(true). + SetChangedFunc(func() { + d.application.Draw() + }) + + textDescriptionView.SetText(diffText) + + closeTextView := util.CreateAttentionTextView("Press 'esc' to close") + + dialogContent := tview.NewFlex().SetDirection(tview.FlexRow) + dialogContent.AddItem(textDescriptionView, 0, 1, true) + dialogContent.AddItem(closeTextView, 1, 0, false) + dialogContent.SetBorderPadding(0, 0, 1, 1) + + width := 80 + height := 20 + dialog := createModal(dialogTitle, dialogContent, width, height) + dialog.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + d.Close() + return nil + } + return event + }) + d.layout = dialog +} + +func (d *FileDiffDialog) GetName() string { + return string(FileDiffDialogPage) +} + +func (d *FileDiffDialog) GetLayout() *tview.Flex { + return d.layout +} + +func (d *FileDiffDialog) GetActionChannel() <-chan DialogActionId { + return d.actionChannel +} + +func (d *FileDiffDialog) Close() { + go func() { + d.actionChannel <- DialogCloseActionId + }() +} diff --git a/internal/ui/dialog/snapshot_action_dialog.go b/internal/ui/dialog/snapshot_action_dialog.go index 7d122a6..c107a75 100644 --- a/internal/ui/dialog/snapshot_action_dialog.go +++ b/internal/ui/dialog/snapshot_action_dialog.go @@ -38,8 +38,8 @@ func NewSnapshotActionDialog(application *tview.Application, snapshot *data.Snap func (d *SnapshotActionDialog) createLayout() { dialogTitle := " Select Action " - textDesctiption := fmt.Sprintf("What do you want to do with '%s'?", d.snapshot.Snapshot.Name) - textDesctiptionView := tview.NewTextView().SetText(textDesctiption) + textDescription := fmt.Sprintf("What do you want to do with '%s'?", d.snapshot.Snapshot.Name) + textDescriptionView := tview.NewTextView().SetText(textDescription) optionTable := tview.NewTable() optionTable.SetSelectable(true, false) @@ -100,7 +100,7 @@ func (d *SnapshotActionDialog) createLayout() { } dialogContent := tview.NewFlex().SetDirection(tview.FlexRow) - dialogContent.AddItem(textDesctiptionView, 0, 1, false) + dialogContent.AddItem(textDescriptionView, 0, 1, false) dialogContent.AddItem(optionTable, 0, 1, true) dialog := createModal(dialogTitle, dialogContent, 50, 10) diff --git a/internal/ui/file_browser/file_browser.go b/internal/ui/file_browser/file_browser.go index 2eadc16..8f06faf 100644 --- a/internal/ui/file_browser/file_browser.go +++ b/internal/ui/file_browser/file_browser.go @@ -519,6 +519,9 @@ func (fileBrowser *FileBrowserComponent) openActionDialog(selection *data.FileBr actionDialogLayout := dialog.NewFileActionDialog(fileBrowser.application, selection) actionHandler := func(action dialog.DialogActionId) bool { switch action { + case dialog.FileDialogShowDiffActionId: + fileBrowser.showDiff(selection, fileBrowser.currentSnapshot) + return true case dialog.FileDialogCreateSnapshotDialogActionId: fileBrowser.createSnapshot(selection) return true @@ -688,6 +691,7 @@ func (fileBrowser *FileBrowserComponent) showDialog(d dialog.Dialog, actionHandl } if action == dialog.DialogCloseActionId { fileBrowser.layout.RemovePage(d.GetName()) + fileBrowser.application.Draw() } } }() @@ -716,6 +720,20 @@ func (fileBrowser *FileBrowserComponent) runRestoreFileAction(entry *data.FileBr }) } +func (fileBrowser *FileBrowserComponent) showDiff(selection *data.FileBrowserEntry, snapshot *data.SnapshotBrowserEntry) { + if selection == nil || snapshot == nil { + return + } + d := dialog.NewFileDiffDialog(fileBrowser.application, selection, snapshot) + fileBrowser.showDialog(d, func(action dialog.DialogActionId) bool { + switch action { + case dialog.DialogCloseActionId: + fileBrowser.Refresh() + } + return false + }) +} + func (fileBrowser *FileBrowserComponent) delete(entry *data.FileBrowserEntry) { go func() { path := entry.RealFile.Path diff --git a/internal/ui/snapshot_browser/snapshot_browser.go b/internal/ui/snapshot_browser/snapshot_browser.go index 474bcef..05a049e 100644 --- a/internal/ui/snapshot_browser/snapshot_browser.go +++ b/internal/ui/snapshot_browser/snapshot_browser.go @@ -197,7 +197,8 @@ func (snapshotBrowser *SnapshotBrowserComponent) Refresh(force bool) { func (snapshotBrowser *SnapshotBrowserComponent) SetFileEntry(fileEntry *data.FileBrowserEntry) { if fileEntry != nil && snapshotBrowser.currentFileEntry != nil && - snapshotBrowser.currentFileEntry.GetRealPath() == fileEntry.GetRealPath() { + snapshotBrowser.currentFileEntry.GetRealPath() == fileEntry.GetRealPath() && + snapshotBrowser.currentFileEntry.DiffState == fileEntry.DiffState { return } snapshotBrowser.currentFileEntry = fileEntry