Bläddra i källkod

feat(share): async zip with progress for folder download-all

Instead of holding the HTTP connection open while zipping (which times out
on large folders), the "Download All" button now:

  1. Calls POST /share/prepare-zip/{shareId} which starts a goroutine,
     stores the job in an in-memory sync.Map, and immediately returns a
     jobId JSON response.
  2. The frontend polls GET /share/zip-status/{jobId} every 600 ms and
     renders a progress modal (percent bar + current filename).
  3. On completion, the browser is redirected to
     GET /share/zip-download/{jobId} which serves the pre-built zip.
  4. Jobs auto-expire (zip deleted, entry removed) after 1 hour.

Backend additions:
- ZipJob struct with mutex-protected status/progress fields
- zipJobs sync.Map on the Manager for concurrent job tracking
- handleZipStatus / handleZipDownload helper methods
- ArozZipFileWithProgressAndCompression in fileOpr.go — combines
  compression-level control (RegisterCompressor) with progress callbacks
  and nil-fsh support for locally-buffered remote filesystems

Frontend changes (downloadPageFolder.html):
- "Download All" changed from <a href> to <button onclick=startDownloadAll()>
- Async zip progress modal with animated progress bar, status text, and
  current-file indicator
- Compression checkbox state is read at click time (no stale href to update)

https://claude.ai/code/session_013a46yC7Vhy97TEVwcMKvwW
Claude 2 veckor sedan
förälder
incheckning
f395340293
3 ändrade filer med 600 tillägg och 7 borttagningar
  1. 221 0
      src/mod/filesystem/fileOpr.go
  2. 188 0
      src/mod/share/share.go
  3. 191 7
      src/system/share/downloadPageFolder.html

+ 221 - 0
src/mod/filesystem/fileOpr.go

@@ -511,6 +511,227 @@ func ArozZipFileWithCompressionLevel(sourceFshs []*FileSystemHandler, filelist [
 	return nil
 }
 
+// ArozZipFileWithProgressAndCompression combines compression level control with real-time progress updates.
+// Accepts nil fsh entries for locally-buffered source paths.
+func ArozZipFileWithProgressAndCompression(sourceFshs []*FileSystemHandler, filelist []string, outputFsh *FileSystemHandler, outputfile string, includeTopLevelFolder bool, compressionLevel int, progressHandler func(string, int, int, float64) int) error {
+	// Count total files for progress calculation
+	totalFileCount := 0
+	for i, srcpath := range filelist {
+		thisFsh := sourceFshs[i]
+		if thisFsh == nil {
+			if IsDir(srcpath) {
+				filepath.Walk(srcpath, func(_ string, info os.FileInfo, _ error) error {
+					if !info.IsDir() {
+						totalFileCount++
+					}
+					return nil
+				})
+			} else {
+				totalFileCount++
+			}
+		} else {
+			fshAbs := thisFsh.FileSystemAbstraction
+			if fshAbs.IsDir(srcpath) {
+				fshAbs.Walk(srcpath, func(_ string, info os.FileInfo, _ error) error {
+					if !info.IsDir() {
+						totalFileCount++
+					}
+					return nil
+				})
+			} else {
+				totalFileCount++
+			}
+		}
+	}
+
+	var file arozfs.File
+	var err error
+	if outputFsh != nil {
+		file, err = outputFsh.FileSystemAbstraction.Create(outputfile)
+	} else {
+		file, err = os.Create(outputfile)
+	}
+	if err != nil {
+		return err
+	}
+	defer file.Close()
+
+	writer := zip.NewWriter(file)
+	defer writer.Close()
+	writer.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) {
+		return flate.NewWriter(out, compressionLevel)
+	})
+
+	currentFileCount := 0
+
+	for i, srcpath := range filelist {
+		thisFsh := sourceFshs[i]
+
+		if thisFsh == nil {
+			// Local filesystem path (e.g. after buffering a remote FS)
+			if IsDir(srcpath) {
+				topLevelFolderName := filepath.ToSlash(arozfs.Base(filepath.Dir(srcpath)) + "/" + arozfs.Base(srcpath))
+				err = filepath.Walk(srcpath, func(path string, info os.FileInfo, err error) error {
+					if err != nil {
+						return err
+					}
+					if info.IsDir() {
+						return nil
+					}
+					if insideHiddenFolder(path) {
+						return nil
+					}
+
+					thisFile, err := os.Open(path)
+					if err != nil {
+						return err
+					}
+					defer thisFile.Close()
+
+					relativePath := strings.ReplaceAll(filepath.ToSlash(path), filepath.ToSlash(filepath.Clean(srcpath))+"/", "")
+					if includeTopLevelFolder {
+						relativePath = topLevelFolderName + "/" + relativePath
+					} else {
+						relativePath = arozfs.Base(srcpath) + "/" + relativePath
+					}
+
+					f, err := writer.Create(relativePath)
+					if err != nil {
+						return err
+					}
+					if _, err = io.Copy(f, thisFile); err != nil {
+						return err
+					}
+
+					currentFileCount++
+					statusCode := progressHandler(arozfs.Base(path), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+					for statusCode == 1 {
+						time.Sleep(time.Second)
+						statusCode = progressHandler(arozfs.Base(path), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+					}
+					if statusCode == 2 {
+						return errors.New("Operation cancelled by user")
+					}
+					return nil
+				})
+				if err != nil {
+					return err
+				}
+			} else {
+				thisFile, err := os.Open(srcpath)
+				if err != nil {
+					return err
+				}
+				defer thisFile.Close()
+
+				relativePath := arozfs.Base(srcpath)
+				if includeTopLevelFolder {
+					relativePath = arozfs.Base(filepath.Dir(srcpath)) + "/" + relativePath
+				}
+				f, err := writer.Create(relativePath)
+				if err != nil {
+					return err
+				}
+				if _, err = io.Copy(f, thisFile); err != nil {
+					return err
+				}
+
+				currentFileCount++
+				statusCode := progressHandler(arozfs.Base(srcpath), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+				for statusCode == 1 {
+					time.Sleep(time.Second)
+					statusCode = progressHandler(arozfs.Base(srcpath), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+				}
+				if statusCode == 2 {
+					return errors.New("Operation cancelled by user")
+				}
+			}
+		} else {
+			// FileSystemHandler abstraction (remote FS)
+			fshAbs := thisFsh.FileSystemAbstraction
+			if fshAbs.IsDir(srcpath) {
+				topLevelFolderName := filepath.ToSlash(arozfs.Base(filepath.Dir(srcpath)) + "/" + arozfs.Base(srcpath))
+				err = fshAbs.Walk(srcpath, func(path string, info os.FileInfo, err error) error {
+					if err != nil {
+						return err
+					}
+					if info.IsDir() {
+						return nil
+					}
+					if insideHiddenFolder(path) {
+						return nil
+					}
+
+					thisFile, err := fshAbs.ReadStream(path)
+					if err != nil {
+						return err
+					}
+					defer thisFile.Close()
+
+					relativePath := strings.ReplaceAll(filepath.ToSlash(path), filepath.ToSlash(filepath.Clean(srcpath))+"/", "")
+					if includeTopLevelFolder {
+						relativePath = topLevelFolderName + "/" + relativePath
+					} else {
+						relativePath = arozfs.Base(srcpath) + "/" + relativePath
+					}
+
+					f, err := writer.Create(relativePath)
+					if err != nil {
+						return err
+					}
+					if _, err = io.Copy(f, thisFile); err != nil {
+						return err
+					}
+
+					currentFileCount++
+					statusCode := progressHandler(arozfs.Base(path), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+					for statusCode == 1 {
+						time.Sleep(time.Second)
+						statusCode = progressHandler(arozfs.Base(path), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+					}
+					if statusCode == 2 {
+						return errors.New("Operation cancelled by user")
+					}
+					return nil
+				})
+				if err != nil {
+					return err
+				}
+			} else {
+				thisFile, err := fshAbs.ReadStream(srcpath)
+				if err != nil {
+					return err
+				}
+				defer thisFile.Close()
+
+				relativePath := arozfs.Base(srcpath)
+				if includeTopLevelFolder {
+					relativePath = arozfs.Base(filepath.Dir(srcpath)) + "/" + relativePath
+				}
+				f, err := writer.Create(relativePath)
+				if err != nil {
+					return err
+				}
+				if _, err = io.Copy(f, thisFile); err != nil {
+					return err
+				}
+
+				currentFileCount++
+				statusCode := progressHandler(arozfs.Base(srcpath), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+				for statusCode == 1 {
+					time.Sleep(time.Second)
+					statusCode = progressHandler(arozfs.Base(srcpath), currentFileCount, totalFileCount, float64(currentFileCount)/float64(totalFileCount)*100)
+				}
+				if statusCode == 2 {
+					return errors.New("Operation cancelled by user")
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
 func insideHiddenFolder(path string) bool {
 	FileIsHidden, err := hidden.IsHidden(path, true)
 	if err != nil {

+ 188 - 0
src/mod/share/share.go

@@ -27,6 +27,7 @@ import (
 	"sort"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/golang/freetype"
@@ -51,8 +52,22 @@ type Options struct {
 	TmpFolder       string
 }
 
+// ZipJob tracks the state of an async zip operation
+type ZipJob struct {
+	mu          sync.Mutex
+	Status      string  // "buffering" | "zipping" | "done" | "error"
+	Progress    float64 // 0–100
+	CurrentFile string
+	Error       string
+	OutputPath  string
+	Filename    string
+	CreatedAt   time.Time
+	localBuff   string // temp dir to clean up after zipping
+}
+
 type Manager struct {
 	options Options
+	zipJobs sync.Map // map[string]*ZipJob
 }
 
 // Create a new Share Manager
@@ -262,10 +277,27 @@ func (s *Manager) HandleOPGServing(w http.ResponseWriter, r *http.Request, share
 
 // Main function for handle share. Must be called with http.HandleFunc (No auth)
 func (s *Manager) HandleShareAccess(w http.ResponseWriter, r *http.Request) {
+	// Handle async zip status/download endpoints early — these use the job ID as an auth token
+	// and do not require a share entry lookup.
+	{
+		cleanParts := strings.Split(strings.TrimPrefix(filepath.ToSlash(filepath.Clean(r.URL.Path)), "/"), "/")
+		if len(cleanParts) >= 3 {
+			switch cleanParts[1] {
+			case "zip-status":
+				s.handleZipStatus(w, r, cleanParts[2])
+				return
+			case "zip-download":
+				s.handleZipDownload(w, r, cleanParts[2])
+				return
+			}
+		}
+	}
+
 	//New download method variables
 	subpathElements := []string{}
 	directDownload := false
 	directServe := false
+	prepareZip := false
 	relpath := ""
 
 	compressionLevel := flate.DefaultCompression
@@ -306,6 +338,8 @@ func (s *Manager) HandleShareAccess(w http.ResponseWriter, r *http.Request) {
 				}
 			} else if subpathElements[1] == "preview" {
 				directServe = true
+			} else if subpathElements[1] == "prepare-zip" {
+				prepareZip = true
 			} else if len(subpathElements) == 3 {
 				//Check if the last element is the filename
 				if strings.Contains(subpathElements[2], ".") {
@@ -648,6 +682,109 @@ func (s *Manager) HandleShareAccess(w http.ResponseWriter, r *http.Request) {
 					}
 				}
 
+			} else if prepareZip {
+				// Async zip: start a background job and return the job ID immediately
+				jobID := uuid.NewV4().String()
+				tmpFolder := filepath.Join(s.options.TmpFolder, "share-cache")
+				os.MkdirAll(tmpFolder, 0755)
+				targetZipFilename := filepath.Join(tmpFolder, jobID+".zip")
+
+				localBuffDir := ""
+				if targetFsh.RequireBuffer {
+					localBuffDir = filepath.Join(tmpFolder, jobID+"_buff")
+				}
+
+				job := &ZipJob{
+					Status:     "zipping",
+					OutputPath: targetZipFilename,
+					Filename:   arozfs.Base(shareOption.FileRealPath) + ".zip",
+					CreatedAt:  time.Now(),
+					localBuff:  localBuffDir,
+				}
+				if targetFsh.RequireBuffer {
+					job.Status = "buffering"
+				}
+				s.zipJobs.Store(jobID, job)
+
+				// Capture variables for the goroutine
+				capturedFshAbs := targetFshAbs
+				capturedSrcPath := fileRuntimeAbsPath
+				capturedSrcFsh := targetFsh
+				capturedRequireBuffer := targetFsh.RequireBuffer
+				capturedLocalBuff := filepath.Join(localBuffDir, arozfs.Base(fileRuntimeAbsPath))
+				capturedCompressionLevel := compressionLevel
+
+				go func() {
+					actualSource := capturedSrcPath
+					var actualFsh *filesystem.FileSystemHandler = capturedSrcFsh
+
+					if capturedRequireBuffer {
+						os.MkdirAll(capturedLocalBuff, 0755)
+						capturedFshAbs.Walk(capturedSrcPath, func(path string, info fs.FileInfo, err error) error {
+							if err != nil {
+								return nil
+							}
+							relPath := strings.TrimPrefix(filepath.ToSlash(path), filepath.ToSlash(capturedSrcPath))
+							localPath := filepath.Join(capturedLocalBuff, relPath)
+							if info.IsDir() {
+								os.MkdirAll(localPath, 0755)
+							} else {
+								f, err := capturedFshAbs.ReadStream(path)
+								if err != nil {
+									return nil
+								}
+								defer f.Close()
+								dest, err := os.OpenFile(localPath, os.O_CREATE|os.O_WRONLY, 0775)
+								if err != nil {
+									return nil
+								}
+								defer dest.Close()
+								io.Copy(dest, f)
+							}
+							return nil
+						})
+						actualSource = capturedLocalBuff
+						actualFsh = nil
+						job.mu.Lock()
+						job.Status = "zipping"
+						job.mu.Unlock()
+					}
+
+					fshs := []*filesystem.FileSystemHandler{actualFsh}
+					zipErr := filesystem.ArozZipFileWithProgressAndCompression(fshs, []string{actualSource}, nil, targetZipFilename, false, capturedCompressionLevel, func(filename string, current, total int, progress float64) int {
+						job.mu.Lock()
+						job.CurrentFile = filename
+						job.Progress = progress
+						job.mu.Unlock()
+						return 0
+					})
+
+					job.mu.Lock()
+					if zipErr != nil {
+						job.Status = "error"
+						job.Error = zipErr.Error()
+					} else {
+						job.Status = "done"
+						job.Progress = 100
+					}
+					job.mu.Unlock()
+
+					if capturedRequireBuffer {
+						os.RemoveAll(localBuffDir)
+					}
+
+					// Auto-expire the job and zip file after 1 hour
+					go func() {
+						time.Sleep(time.Hour)
+						s.zipJobs.Delete(jobID)
+						os.Remove(targetZipFilename)
+					}()
+				}()
+
+				w.Header().Set("Content-Type", "application/json")
+				json.NewEncoder(w).Encode(map[string]string{"jobId": jobID})
+				return
+
 			} else if directServe {
 				//Folder provide no direct serve method.
 				w.WriteHeader(http.StatusBadRequest)
@@ -1430,3 +1567,54 @@ func getPathHashFromUsernameAndVpath(userinfo *user.User, vpath string) (string,
 	}
 	return shareEntry.GetPathHash(fsh, vpath, userinfo.Username)
 }
+
+// handleZipStatus returns the current status of an async zip job as JSON.
+func (s *Manager) handleZipStatus(w http.ResponseWriter, r *http.Request, jobID string) {
+	v, ok := s.zipJobs.Load(jobID)
+	if !ok {
+		w.Header().Set("Content-Type", "application/json")
+		w.WriteHeader(http.StatusNotFound)
+		json.NewEncoder(w).Encode(map[string]string{"error": "job not found"})
+		return
+	}
+
+	job := v.(*ZipJob)
+	job.mu.Lock()
+	resp := map[string]interface{}{
+		"status":      job.Status,
+		"progress":    job.Progress,
+		"currentFile": job.CurrentFile,
+		"error":       job.Error,
+		"filename":    job.Filename,
+	}
+	job.mu.Unlock()
+
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(resp)
+}
+
+// handleZipDownload serves a completed async zip file.
+func (s *Manager) handleZipDownload(w http.ResponseWriter, r *http.Request, jobID string) {
+	v, ok := s.zipJobs.Load(jobID)
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+
+	job := v.(*ZipJob)
+	job.mu.Lock()
+	status := job.Status
+	outputPath := job.OutputPath
+	filename := job.Filename
+	job.mu.Unlock()
+
+	if status != "done" {
+		w.WriteHeader(http.StatusAccepted)
+		w.Write([]byte("202 - Zip operation still in progress"))
+		return
+	}
+
+	w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+strings.ReplaceAll(url.QueryEscape(filename), "+", "%20"))
+	w.Header().Set("Content-Type", "application/zip")
+	http.ServeFile(w, r, outputPath)
+}

+ 191 - 7
src/system/share/downloadPageFolder.html

@@ -637,6 +637,96 @@
       color: var(--muted);
       font-size: 0.9rem;
     }
+
+    /* --- Async zip progress modal --- */
+    #zip-modal {
+      position: fixed;
+      inset: 0;
+      z-index: 300;
+      display: none;
+      align-items: center;
+      justify-content: center;
+    }
+
+    #zip-backdrop {
+      position: absolute;
+      inset: 0;
+      background: rgba(0,0,0,0.55);
+      backdrop-filter: blur(2px);
+    }
+
+    #zip-dialog {
+      position: relative;
+      background: var(--surface);
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+      padding: 28px 30px 24px;
+      min-width: 300px;
+      max-width: 460px;
+      width: calc(100% - 40px);
+      box-shadow: var(--shadow);
+      display: flex;
+      flex-direction: column;
+      gap: 14px;
+    }
+
+    #zip-title {
+      font-size: 1.05rem;
+      font-weight: 700;
+      color: var(--text);
+    }
+
+    #zip-status-text {
+      font-size: 0.88rem;
+      color: var(--muted);
+    }
+
+    #zip-bar-wrap {
+      height: 7px;
+      background: var(--surface2);
+      border-radius: 999px;
+      overflow: hidden;
+      border: 1px solid var(--border);
+    }
+
+    #zip-bar {
+      height: 100%;
+      width: 0%;
+      background: var(--accent);
+      border-radius: 999px;
+      transition: width 0.35s ease;
+    }
+
+    #zip-bar.error { background: #ef4444; }
+
+    #zip-current-file {
+      font-size: 0.72rem;
+      color: var(--muted);
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      min-height: 1.2em;
+    }
+
+    #zip-actions {
+      display: flex;
+      justify-content: flex-end;
+      margin-top: 4px;
+    }
+
+    #zip-cancel-btn {
+      background: var(--surface2);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      padding: 8px 20px;
+      font-size: 0.85rem;
+      font-weight: 600;
+      cursor: pointer;
+      color: var(--text);
+      transition: border-color var(--trans), color var(--trans);
+    }
+
+    #zip-cancel-btn:hover { border-color: var(--accent); color: var(--accent); }
   </style>
 </head>
 <body>
@@ -672,10 +762,10 @@
         <div class="divider"></div>
 
         <div class="action-stack">
-          <a class="btn btn-primary" href="{{downloadurl}}" id="downloadLink">
+          <button class="btn btn-primary" id="downloadAllBtn" onclick="startDownloadAll()">
             <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
             <span>Download All</span>
-          </a>
+          </button>
           <button class="btn btn-secondary" id="sharebtn" onclick="doShare()">
             <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line></svg>
             <span>Share Link</span>
@@ -721,6 +811,20 @@
 
   <div id="thumb-popup"><img id="thumb-img" src="" alt=""></div>
 
+  <!-- Async zip progress modal -->
+  <div id="zip-modal">
+    <div id="zip-backdrop" onclick="cancelZip()"></div>
+    <div id="zip-dialog">
+      <div id="zip-title">Preparing Download</div>
+      <div id="zip-status-text">Starting archive...</div>
+      <div id="zip-bar-wrap"><div id="zip-bar"></div></div>
+      <div id="zip-current-file"></div>
+      <div id="zip-actions">
+        <button id="zip-cancel-btn" onclick="cancelZip()">Cancel</button>
+      </div>
+    </div>
+  </div>
+
   <div id="preview-overlay">
     <div id="preview-backdrop" onclick="closePreview()"></div>
     <div id="preview-modal">
@@ -799,10 +903,91 @@
       }).catch(function(){});
     }
 
-    document.getElementById('uncompressedCheck').addEventListener('change', function() {
-      var link = document.getElementById('downloadLink');
-      link.href = this.checked ? '{{downloadurl}}?compression_level=0' : '{{downloadurl}}';
-    });
+    // Compression level is read at download time from the checkbox state, no need to update a link href.
+
+    // --- Async zip download logic ---
+    var _zipPollInterval = null;
+    var _zipJobId = null;
+
+    function startDownloadAll() {
+      var compressionParam = document.getElementById('uncompressedCheck').checked ? '?compression_level=0' : '';
+      var prepareUrl = '../../share/prepare-zip/' + downloadUUID + compressionParam;
+
+      document.getElementById('zip-bar').className = '';
+      document.getElementById('zip-bar').style.width = '0%';
+      document.getElementById('zip-status-text').textContent = 'Starting archive...';
+      document.getElementById('zip-current-file').textContent = '';
+      document.getElementById('zip-cancel-btn').textContent = 'Cancel';
+      document.getElementById('zip-cancel-btn').onclick = cancelZip;
+      document.getElementById('zip-modal').style.display = 'flex';
+
+      fetch(prepareUrl)
+        .then(function(resp) { return resp.json(); })
+        .then(function(data) {
+          if (!data.jobId) { showZipError('Server did not return a job ID'); return; }
+          _zipJobId = data.jobId;
+          _zipPollInterval = setInterval(_pollZipStatus, 600);
+        })
+        .catch(function() { showZipError('Network error — please try again.'); });
+    }
+
+    function _pollZipStatus() {
+      if (!_zipJobId) return;
+      fetch('../../share/zip-status/' + _zipJobId)
+        .then(function(resp) { return resp.json(); })
+        .then(function(data) {
+          if (data.status === 'buffering') {
+            document.getElementById('zip-status-text').textContent = 'Reading remote files…';
+            document.getElementById('zip-bar').style.width = '4%';
+          } else if (data.status === 'zipping') {
+            var pct = Math.max(2, Math.round(data.progress || 0));
+            document.getElementById('zip-status-text').textContent = 'Compressing… ' + pct + '%';
+            document.getElementById('zip-bar').style.width = pct + '%';
+            document.getElementById('zip-current-file').textContent = data.currentFile ? '↳ ' + data.currentFile : '';
+          } else if (data.status === 'done') {
+            clearInterval(_zipPollInterval);
+            _zipPollInterval = null;
+            document.getElementById('zip-bar').style.width = '100%';
+            document.getElementById('zip-status-text').textContent = 'Archive ready — downloading…';
+            document.getElementById('zip-current-file').textContent = '';
+            document.getElementById('zip-cancel-btn').textContent = 'Close';
+            document.getElementById('zip-cancel-btn').onclick = closeZipModal;
+            var jobId = _zipJobId;
+            setTimeout(function() {
+              window.location.href = '../../share/zip-download/' + jobId;
+              setTimeout(closeZipModal, 2500);
+            }, 300);
+          } else if (data.status === 'error') {
+            clearInterval(_zipPollInterval);
+            _zipPollInterval = null;
+            showZipError(data.error || 'Unknown error');
+          } else if (data.error) {
+            clearInterval(_zipPollInterval);
+            _zipPollInterval = null;
+            showZipError(data.error);
+          }
+        })
+        .catch(function() { /* transient network hiccup — keep polling */ });
+    }
+
+    function showZipError(msg) {
+      document.getElementById('zip-status-text').textContent = 'Error: ' + msg;
+      document.getElementById('zip-bar').style.width = '100%';
+      document.getElementById('zip-bar').className = 'error';
+      document.getElementById('zip-current-file').textContent = '';
+      document.getElementById('zip-cancel-btn').textContent = 'Close';
+      document.getElementById('zip-cancel-btn').onclick = closeZipModal;
+    }
+
+    function cancelZip() {
+      if (_zipPollInterval) { clearInterval(_zipPollInterval); _zipPollInterval = null; }
+      _zipJobId = null;
+      closeZipModal();
+    }
+
+    function closeZipModal() {
+      document.getElementById('zip-modal').style.display = 'none';
+    }
 
     var treeFileList = {{treelist}};
     var downloadUUID = '{{downloaduuid}}';
@@ -1076,7 +1261,6 @@
 
       if (totalSize > 0 && compressedSize / totalSize >= 0.5) {
         document.getElementById('uncompressedCheck').checked = true;
-        document.getElementById('downloadLink').href = '{{downloadurl}}?compression_level=0';
       }
     })();