feat: enable changelog generation in release workflow #2

Merged
jamie merged 5 commits from v1.1.0 into main 2026-06-09 14:53:28 +01:00
13 changed files with 171 additions and 14 deletions
Showing only changes of commit 93ad8c9321 - Show all commits
+11 -11
View File
@@ -167,7 +167,7 @@ func (a *App) Delete(connId, path string) error {
return exp.Delete(path) return exp.Delete(path)
} }
func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, filename string, size int64) error { func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, filename string, size int64, verify bool) error {
srcExp, err := a.getExplorerForConnection(srcConnId) srcExp, err := a.getExplorerForConnection(srcConnId)
if err != nil { if err != nil {
return err return err
@@ -185,7 +185,7 @@ func (a *App) QueueTransfer(id string, srcConnId, dstConnId, srcPath, dstPath, f
return err return err
} }
return a.transferManager.QueueTransfer(id, srcPath, dstPath, filename, size, srcExp, dstExp) return a.transferManager.QueueTransfer(id, srcPath, dstPath, filename, size, srcExp, dstExp, verify)
} }
func (a *App) GetTransfers() []*transfer.Transfer { func (a *App) GetTransfers() []*transfer.Transfer {
@@ -232,7 +232,7 @@ func (a *App) PromptUploadFiles(connId, destPath string) error {
remotePath = destPath + fileName remotePath = destPath + fileName
} }
a.QueueTransfer(id, "local", connId, localPath, remotePath, fileName, stat.Size()) a.QueueTransfer(id, "local", connId, localPath, remotePath, fileName, stat.Size(), false)
} }
return nil return nil
} }
@@ -272,7 +272,7 @@ func (a *App) PromptUploadDirectory(connId, destPath string) error {
} }
id := uuid.New().String() id := uuid.New().String()
a.QueueTransfer(id, "local", connId, path, remotePath, info.Name(), info.Size()) a.QueueTransfer(id, "local", connId, path, remotePath, info.Name(), info.Size(), false)
return nil return nil
}) })
} }
@@ -304,7 +304,7 @@ func (a *App) PromptDownload(connId, remotePath string) error {
size = stat.Size size = stat.Size
} }
return a.QueueTransfer(id, connId, "local", remotePath, localPath, fileName, size) return a.QueueTransfer(id, connId, "local", remotePath, localPath, fileName, size, false)
} }
type TransferItem struct { type TransferItem struct {
@@ -314,7 +314,7 @@ type TransferItem struct {
Size int64 `json:"size"` Size int64 `json:"size"`
} }
func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []TransferItem) error { func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []TransferItem, verify bool) error {
for _, item := range items { for _, item := range items {
if !item.IsDir { if !item.IsDir {
id := uuid.New().String() id := uuid.New().String()
@@ -328,18 +328,18 @@ func (a *App) TransferItems(srcConnId, dstConnId, dstPath string, items []Transf
remotePath = dstPath + item.Name remotePath = dstPath + item.Name
} }
if err := a.QueueTransfer(id, srcConnId, dstConnId, item.Path, remotePath, item.Name, item.Size); err != nil { if err := a.QueueTransfer(id, srcConnId, dstConnId, item.Path, remotePath, item.Name, item.Size, verify); err != nil {
return err return err
} }
} else { } else {
// Run remote walk in goroutine to not block UI // Run remote walk in goroutine to not block UI
go a.transferRemoteDirectory(srcConnId, dstConnId, item.Path, dstPath) go a.transferRemoteDirectory(srcConnId, dstConnId, item.Path, dstPath, verify)
} }
} }
return nil return nil
} }
func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBasePath string) { func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBasePath string, verify bool) {
baseName := filepath.Base(srcDirPath) baseName := filepath.Base(srcDirPath)
newDstPath := dstBasePath newDstPath := dstBasePath
@@ -360,11 +360,11 @@ func (a *App) transferRemoteDirectory(srcConnId, dstConnId, srcDirPath, dstBaseP
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir { if entry.IsDir {
a.transferRemoteDirectory(srcConnId, dstConnId, entry.Path, newDstPath) a.transferRemoteDirectory(srcConnId, dstConnId, entry.Path, newDstPath, verify)
} else { } else {
id := uuid.New().String() id := uuid.New().String()
itemDstPath := newDstPath + "/" + entry.Name itemDstPath := newDstPath + "/" + entry.Name
a.QueueTransfer(id, srcConnId, dstConnId, entry.Path, itemDstPath, entry.Name, entry.Size) a.QueueTransfer(id, srcConnId, dstConnId, entry.Path, itemDstPath, entry.Name, entry.Size, verify)
} }
} }
} }
+9
View File
@@ -176,6 +176,15 @@
<tbody id="transfer-dest-list"></tbody> <tbody id="transfer-dest-list"></tbody>
</table> </table>
</div> </div>
<div class="form-group" style="display: flex; align-items: center; gap: 0.5rem; flex-direction: row; margin-bottom: 1rem;">
<input type="checkbox" id="transfer-verify" style="width: auto;">
<label style="margin: 0; display: flex; flex-direction: column;">
Verify Checksum (MD5)
<span style="font-size: 0.75rem; color: var(--text-secondary); font-weight: normal;">Slower for non-S3 destinations as it requires re-reading the file over the network.</span>
</label>
</div>
<div style="display: flex; justify-content: flex-end; gap: 1rem;"> <div style="display: flex; justify-content: flex-end; gap: 1rem;">
<button class="btn" style="background: transparent; color: var(--text-primary); border-color: var(--border);" onclick="closeTransferModal()">Cancel</button> <button class="btn" style="background: transparent; color: var(--text-primary); border-color: var(--border);" onclick="closeTransferModal()">Cancel</button>
<button class="btn btn-primary" onclick="executeTransfer()">Transfer Here</button> <button class="btn btn-primary" onclick="executeTransfer()">Transfer Here</button>
+1
View File
@@ -528,6 +528,7 @@ window.openTransferModal = () => {
window.closeTransferModal = () => { window.closeTransferModal = () => {
document.getElementById('transfer-modal').style.display = 'none'; document.getElementById('transfer-modal').style.display = 'none';
document.getElementById('transfer-verify').checked = false;
}; };
window.loadTransferDestRoot = () => { window.loadTransferDestRoot = () => {
+1
View File
@@ -21,4 +21,5 @@ type Explorer interface {
Rename(src, dst string) error Rename(src, dst string) error
ReadFile(path string) (io.ReadCloser, error) ReadFile(path string) (io.ReadCloser, error)
WriteFile(path string, r io.Reader, size int64) error WriteFile(path string, r io.Reader, size int64) error
Checksum(path string) (string, error)
} }
+15
View File
@@ -1,7 +1,9 @@
package ftp package ftp
import ( import (
"crypto/md5"
"crypto/tls" "crypto/tls"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"path/filepath" "path/filepath"
@@ -177,3 +179,16 @@ func (e *FTPExplorer) ReadFile(path string) (io.ReadCloser, error) {
func (e *FTPExplorer) WriteFile(path string, r io.Reader, size int64) error { func (e *FTPExplorer) WriteFile(path string, r io.Reader, size int64) error {
return e.client.Stor(path, r) return e.client.Stor(path, r)
} }
func (e *FTPExplorer) Checksum(path string) (string, error) {
r, err := e.ReadFile(path)
if err != nil {
return "", err
}
defer r.Close()
h := md5.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+15
View File
@@ -1,6 +1,8 @@
package local package local
import ( import (
"crypto/md5"
"encoding/hex"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@@ -82,6 +84,19 @@ func (e *LocalExplorer) Rename(src, dst string) error {
return os.Rename(src, dst) return os.Rename(src, dst)
} }
func (e *LocalExplorer) Checksum(path string) (string, error) {
r, err := e.ReadFile(path)
if err != nil {
return "", err
}
defer r.Close()
h := md5.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func (e *LocalExplorer) ReadFile(path string) (io.ReadCloser, error) { func (e *LocalExplorer) ReadFile(path string) (io.ReadCloser, error) {
return os.Open(path) return os.Open(path)
} }
+15
View File
@@ -3,6 +3,8 @@
package nfs package nfs
import ( import (
"crypto/md5"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"path/filepath" "path/filepath"
@@ -125,3 +127,16 @@ func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error {
_, err = io.Copy(f, r) _, err = io.Copy(f, r)
return err return err
} }
func (e *NFSExplorer) Checksum(path string) (string, error) {
r, err := e.ReadFile(path)
if err != nil {
return "", err
}
defer r.Close()
h := md5.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+1
View File
@@ -25,3 +25,4 @@ func (e *NFSExplorer) Delete(path string) error { return nil }
func (e *NFSExplorer) Rename(src, dst string) error { return nil } func (e *NFSExplorer) Rename(src, dst string) error { return nil }
func (e *NFSExplorer) ReadFile(path string) (io.ReadCloser, error) { return nil, nil } func (e *NFSExplorer) ReadFile(path string) (io.ReadCloser, error) { return nil, nil }
func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error { return nil } func (e *NFSExplorer) WriteFile(path string, r io.Reader, size int64) error { return nil }
func (e *NFSExplorer) Checksum(path string) (string, error) { return "", nil }
+25
View File
@@ -307,3 +307,28 @@ func (e *S3Explorer) WriteFile(path string, r io.Reader, size int64) error {
}) })
return err return err
} }
func (e *S3Explorer) Checksum(path string) (string, error) {
bucket, subpath, _, err := e.getBucketAndPath(path)
if err != nil {
return "", err
}
if subpath == "" {
return "", fmt.Errorf("cannot calculate checksum of bucket")
}
input := &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(subpath),
}
head, err := e.client.HeadObject(context.TODO(), input)
if err != nil {
return "", err
}
if head.ETag != nil {
return strings.Trim(*head.ETag, "\""), nil
}
return "", fmt.Errorf("no etag found")
}
+15
View File
@@ -1,6 +1,8 @@
package sftp package sftp
import ( import (
"crypto/md5"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"path/filepath" "path/filepath"
@@ -162,3 +164,16 @@ func (e *SFTPExplorer) WriteFile(path string, r io.Reader, size int64) error {
_, err = io.Copy(f, r) _, err = io.Copy(f, r)
return err return err
} }
func (e *SFTPExplorer) Checksum(path string) (string, error) {
r, err := e.ReadFile(path)
if err != nil {
return "", err
}
defer r.Close()
h := md5.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+15
View File
@@ -1,6 +1,8 @@
package smb package smb
import ( import (
"crypto/md5"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -243,3 +245,16 @@ func (e *SMBExplorer) WriteFile(path string, r io.Reader, size int64) error {
_, err = io.Copy(f, r) _, err = io.Copy(f, r)
return err return err
} }
func (e *SMBExplorer) Checksum(path string) (string, error) {
r, err := e.ReadFile(path)
if err != nil {
return "", err
}
defer r.Close()
h := md5.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+15
View File
@@ -1,6 +1,8 @@
package webdav package webdav
import ( import (
"crypto/md5"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@@ -102,3 +104,16 @@ func (e *WebDAVExplorer) ReadFile(path string) (io.ReadCloser, error) {
func (e *WebDAVExplorer) WriteFile(path string, r io.Reader, size int64) error { func (e *WebDAVExplorer) WriteFile(path string, r io.Reader, size int64) error {
return e.client.WriteStream(path, r, 0644) return e.client.WriteStream(path, r, 0644)
} }
func (e *WebDAVExplorer) Checksum(path string) (string, error) {
r, err := e.ReadFile(path)
if err != nil {
return "", err
}
defer r.Close()
h := md5.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+33 -3
View File
@@ -1,8 +1,12 @@
package transfer package transfer
import ( import (
"crypto/md5"
"encoding/hex"
"errors" "errors"
"fmt"
"io" "io"
"strings"
"sync" "sync"
"time" "time"
@@ -29,6 +33,7 @@ type Transfer struct {
ETA int `json:"eta_seconds"` ETA int `json:"eta_seconds"`
Status Status `json:"status"` Status Status `json:"status"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Verify bool `json:"verify"`
srcExp explorer.Explorer srcExp explorer.Explorer
dstExp explorer.Explorer dstExp explorer.Explorer
@@ -81,12 +86,36 @@ func (m *Manager) doTransfer(t *Transfer) error {
} }
defer r.Close() defer r.Close()
hash := md5.New()
tr := io.TeeReader(r, hash)
pr := &progressReader{ pr := &progressReader{
r: r, r: tr,
t: t, t: t,
} }
return t.dstExp.WriteFile(t.Destination, pr, t.BytesTotal) if err := t.dstExp.WriteFile(t.Destination, pr, t.BytesTotal); err != nil {
return err
}
if t.Verify {
expectedHash := hex.EncodeToString(hash.Sum(nil))
t.Status = StatusActive // Still active during verification
actualHash, err := t.dstExp.Checksum(t.Destination)
if err != nil {
return fmt.Errorf("verification failed: could not calculate destination checksum: %w", err)
}
if expectedHash != actualHash {
// S3 Multi-part ETags end with -N, we can't easily verify them with local md5
if !strings.Contains(actualHash, "-") {
return fmt.Errorf("verification failed: checksum mismatch (expected %s, got %s)", expectedHash, actualHash)
}
}
}
return nil
} }
type progressReader struct { type progressReader struct {
@@ -109,7 +138,7 @@ func (pr *progressReader) Read(p []byte) (int, error) {
return n, err return n, err
} }
func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int64, srcExp, dstExp explorer.Explorer) error { func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int64, srcExp, dstExp explorer.Explorer, verify bool) error {
if srcExp == nil || dstExp == nil { if srcExp == nil || dstExp == nil {
return errors.New("invalid explorers") return errors.New("invalid explorers")
} }
@@ -121,6 +150,7 @@ func (m *Manager) QueueTransfer(id, srcPath, dstPath, filename string, size int6
Filename: filename, Filename: filename,
BytesTotal: size, BytesTotal: size,
Status: StatusQueued, Status: StatusQueued,
Verify: verify,
srcExp: srcExp, srcExp: srcExp,
dstExp: dstExp, dstExp: dstExp,
} }