Browse Source

gui, lib/model: Display list of files needed by remote (fixes #4369)

GitHub-Pull-Request: https://github.com/syncthing/syncthing/pull/4559
LGTM: AudriusButkevicius, calmh
Simon Frei 8 years ago
parent
commit
8fa2b7765a

+ 47 - 13
cmd/syncthing/gui.go

@@ -13,6 +13,7 @@ import (
 	"io/ioutil"
 	"net"
 	"net/http"
+	"net/url"
 	"os"
 	"path/filepath"
 	"reflect"
@@ -83,7 +84,8 @@ type modelIntf interface {
 	GlobalDirectoryTree(folder, prefix string, levels int, dirsonly bool) map[string]interface{}
 	Completion(device protocol.DeviceID, folder string) model.FolderCompletion
 	Override(folder string)
-	NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfoTruncated, []db.FileInfoTruncated, []db.FileInfoTruncated, int)
+	NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfoTruncated, []db.FileInfoTruncated, []db.FileInfoTruncated)
+	RemoteNeedFolderFiles(device protocol.DeviceID, folder string, page, perpage int) ([]db.FileInfoTruncated, error)
 	NeedSize(folder string) db.Counts
 	ConnectionStats() map[string]interface{}
 	DeviceStatistics() map[string]stats.DeviceStatistics
@@ -254,6 +256,7 @@ func (s *apiService) Serve() {
 	getRestMux.HandleFunc("/rest/db/file", s.getDBFile)                          // folder file
 	getRestMux.HandleFunc("/rest/db/ignores", s.getDBIgnores)                    // folder
 	getRestMux.HandleFunc("/rest/db/need", s.getDBNeed)                          // folder [perpage] [page]
+	getRestMux.HandleFunc("/rest/db/remoteneed", s.getDBRemoteNeed)              // device folder [perpage] [page]
 	getRestMux.HandleFunc("/rest/db/status", s.getDBStatus)                      // folder
 	getRestMux.HandleFunc("/rest/db/browse", s.getDBBrowse)                      // folder [prefix] [dirsonly] [levels]
 	getRestMux.HandleFunc("/rest/events", s.getIndexEvents)                      // [since] [limit] [timeout] [events]
@@ -661,6 +664,7 @@ func (s *apiService) getDBCompletion(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, map[string]interface{}{
 		"completion":  comp.CompletionPct,
 		"needBytes":   comp.NeedBytes,
+		"needItems":   comp.NeedItems,
 		"globalBytes": comp.GlobalBytes,
 		"needDeletes": comp.NeedDeletes,
 	})
@@ -718,11 +722,7 @@ func (s *apiService) postDBOverride(w http.ResponseWriter, r *http.Request) {
 	go s.model.Override(folder)
 }
 
-func (s *apiService) getDBNeed(w http.ResponseWriter, r *http.Request) {
-	qs := r.URL.Query()
-
-	folder := qs.Get("folder")
-
+func getPagingParams(qs url.Values) (int, int) {
 	page, err := strconv.Atoi(qs.Get("page"))
 	if err != nil || page < 1 {
 		page = 1
@@ -731,20 +731,52 @@ func (s *apiService) getDBNeed(w http.ResponseWriter, r *http.Request) {
 	if err != nil || perpage < 1 {
 		perpage = 1 << 16
 	}
+	return page, perpage
+}
+
+func (s *apiService) getDBNeed(w http.ResponseWriter, r *http.Request) {
+	qs := r.URL.Query()
+
+	folder := qs.Get("folder")
 
-	progress, queued, rest, total := s.model.NeedFolderFiles(folder, page, perpage)
+	page, perpage := getPagingParams(qs)
+
+	progress, queued, rest := s.model.NeedFolderFiles(folder, page, perpage)
 
 	// Convert the struct to a more loose structure, and inject the size.
 	sendJSON(w, map[string]interface{}{
-		"progress": s.toNeedSlice(progress),
-		"queued":   s.toNeedSlice(queued),
-		"rest":     s.toNeedSlice(rest),
-		"total":    total,
+		"progress": toNeedSlice(progress),
+		"queued":   toNeedSlice(queued),
+		"rest":     toNeedSlice(rest),
 		"page":     page,
 		"perpage":  perpage,
 	})
 }
 
+func (s *apiService) getDBRemoteNeed(w http.ResponseWriter, r *http.Request) {
+	qs := r.URL.Query()
+
+	folder := qs.Get("folder")
+	device := qs.Get("device")
+	deviceID, err := protocol.DeviceIDFromString(device)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+
+	page, perpage := getPagingParams(qs)
+
+	if files, err := s.model.RemoteNeedFolderFiles(deviceID, folder, page, perpage); err != nil {
+		http.Error(w, err.Error(), http.StatusNotFound)
+	} else {
+		sendJSON(w, map[string]interface{}{
+			"files":   toNeedSlice(files),
+			"page":    page,
+			"perpage": perpage,
+		})
+	}
+}
+
 func (s *apiService) getSystemConnections(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, s.model.ConnectionStats())
 }
@@ -1351,7 +1383,7 @@ func (s *apiService) getHeapProf(w http.ResponseWriter, r *http.Request) {
 	pprof.WriteHeapProfile(w)
 }
 
-func (s *apiService) toNeedSlice(fs []db.FileInfoTruncated) []jsonDBFileInfo {
+func toNeedSlice(fs []db.FileInfoTruncated) []jsonDBFileInfo {
 	res := make([]jsonDBFileInfo, len(fs))
 	for i, f := range fs {
 		res[i] = jsonDBFileInfo(f)
@@ -1373,6 +1405,7 @@ func (f jsonFileInfo) MarshalJSON() ([]byte, error) {
 		"invalid":       f.Invalid,
 		"noPermissions": f.NoPermissions,
 		"modified":      protocol.FileInfo(f).ModTime(),
+		"modifiedBy":    f.ModifiedBy.String(),
 		"sequence":      f.Sequence,
 		"numBlocks":     len(f.Blocks),
 		"version":       jsonVersionVector(f.Version),
@@ -1384,13 +1417,14 @@ type jsonDBFileInfo db.FileInfoTruncated
 func (f jsonDBFileInfo) MarshalJSON() ([]byte, error) {
 	return json.Marshal(map[string]interface{}{
 		"name":          f.Name,
-		"type":          f.Type,
+		"type":          f.Type.String(),
 		"size":          f.Size,
 		"permissions":   fmt.Sprintf("%#o", f.Permissions),
 		"deleted":       f.Deleted,
 		"invalid":       f.Invalid,
 		"noPermissions": f.NoPermissions,
 		"modified":      db.FileInfoTruncated(f).ModTime(),
+		"modifiedBy":    f.ModifiedBy.String(),
 		"sequence":      f.Sequence,
 	})
 }

+ 6 - 2
cmd/syncthing/mocked_model_test.go

@@ -28,8 +28,12 @@ func (m *mockedModel) Completion(device protocol.DeviceID, folder string) model.
 
 func (m *mockedModel) Override(folder string) {}
 
-func (m *mockedModel) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfoTruncated, []db.FileInfoTruncated, []db.FileInfoTruncated, int) {
-	return nil, nil, nil, 0
+func (m *mockedModel) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfoTruncated, []db.FileInfoTruncated, []db.FileInfoTruncated) {
+	return nil, nil, nil
+}
+
+func (m *mockedModel) RemoteNeedFolderFiles(device protocol.DeviceID, folder string, page, perpage int) ([]db.FileInfoTruncated, error) {
+	return nil, nil
 }
 
 func (m *mockedModel) NeedSize(folder string) db.Counts {

+ 1 - 0
cmd/syncthing/summaryservice.go

@@ -211,6 +211,7 @@ func (c *folderSummaryService) sendSummary(folder string) {
 			"device":      devCfg.DeviceID.String(),
 			"completion":  comp.CompletionPct,
 			"needBytes":   comp.NeedBytes,
+			"needItems":   comp.NeedItems,
 			"globalBytes": comp.GlobalBytes,
 		})
 	}

+ 6 - 0
gui/default/assets/lang/lang-en.json

@@ -59,6 +59,7 @@
    "Device ID": "Device ID",
    "Device Identification": "Device Identification",
    "Device Name": "Device Name",
+   "Device that last modified the item": "Device that last modified the item",
    "Devices": "Devices",
    "Disabled": "Disabled",
    "Disconnected": "Disconnected",
@@ -130,6 +131,7 @@
    "Latest Change": "Latest Change",
    "Learn more": "Learn more",
    "Listeners": "Listeners",
+   "Loading data...": "Loading data...",
    "Local Discovery": "Local Discovery",
    "Local State": "Local State",
    "Local State (Total)": "Local State (Total)",
@@ -138,6 +140,8 @@
    "Maximum Age": "Maximum Age",
    "Metadata Only": "Metadata Only",
    "Minimum Free Disk Space": "Minimum Free Disk Space",
+   "Mod. Device": "Mod. Device",
+   "Mod. Time": "Mod. Time",
    "Move to top of queue": "Move to top of queue",
    "Multi level wildcard (matches multiple directory levels)": "Multi level wildcard (matches multiple directory levels)",
    "Never": "Never",
@@ -221,6 +225,7 @@
    "Shutdown Complete": "Shutdown Complete",
    "Simple File Versioning": "Simple File Versioning",
    "Single level wildcard (matches within a directory only)": "Single level wildcard (matches within a directory only)",
+   "Size": "Size",
    "Smallest First": "Smallest First",
    "Source Code": "Source Code",
    "Stable releases and release candidates": "Stable releases and release candidates",
@@ -268,6 +273,7 @@
    "This is a major version upgrade.": "This is a major version upgrade.",
    "This setting controls the free space required on the home (i.e., index database) disk.": "This setting controls the free space required on the home (i.e., index database) disk.",
    "Time": "Time",
+   "Time the item was last modified": "Time the item was last modified",
    "Trash Can File Versioning": "Trash Can File Versioning",
    "Type": "Type",
    "Unavailable": "Unavailable",

+ 7 - 0
gui/default/index.html

@@ -603,6 +603,12 @@
                         </a>
                       </td>
                     </tr>
+                    <tr ng-if="deviceStatus(deviceCfg) == 'syncing'">
+                      <th><span class="fa fa-fw fa-exchange"></span>&nbsp;<span translate>Out of Sync Items</span></th>
+                      <td class="text-right">
+                        <a href="" ng-click="showRemoteNeed(deviceCfg)">{{completion[deviceCfg.deviceID]._needItems | alwaysNumber}} <span translate>items</span>, ~{{completion[deviceCfg.deviceID]._needBytes | binary}}B</a>
+                      </td>
+                    </tr>
                     <tr>
                       <th><span class="fa fa-fw fa-link"></span>&nbsp<span translate>Address</span></th>
                       <td ng-if="connections[deviceCfg.deviceID].connected" class="text-right">
@@ -722,6 +728,7 @@
   <ng-include src="'syncthing/usagereport/usageReportPreviewModalView.html'"></ng-include>
   <ng-include src="'syncthing/transfer/neededFilesModalView.html'"></ng-include>
   <ng-include src="'syncthing/transfer/failedFilesModalView.html'"></ng-include>
+  <ng-include src="'syncthing/transfer/remoteNeededFilesModalView.html'"></ng-include>
   <ng-include src="'syncthing/core/majorUpgradeModalView.html'"></ng-include>
   <ng-include src="'syncthing/core/aboutModalView.html'"></ng-include>
   <ng-include src="'syncthing/core/discoveryFailuresModalView.html'"></ng-include>

+ 58 - 20
gui/default/syncthing/core/syncthingController.js

@@ -45,7 +45,6 @@ angular.module('syncthing.core')
         $scope.progress = {};
         $scope.version = {};
         $scope.needed = [];
-        $scope.neededTotal = 0;
         $scope.neededCurrentPage = 1;
         $scope.neededPageSize = 10;
         $scope.failed = {};
@@ -56,6 +55,7 @@ angular.module('syncthing.core')
         $scope.globalChangeEvents = {};
         $scope.metricRates = false;
         $scope.folderPathErrors = {};
+        resetRemoteNeed();
 
         try {
             $scope.metricRates = (window.localStorage["metricRates"] == "true");
@@ -241,7 +241,8 @@ angular.module('syncthing.core')
                 };
                 $scope.completion[arg.data.id] = {
                     _total: 100,
-                    _needBytes: 0
+                    _needBytes: 0,
+                    _needItems: 0
                 };
             }
         });
@@ -389,7 +390,8 @@ angular.module('syncthing.core')
             $scope.devices.forEach(function (deviceCfg) {
                 $scope.completion[deviceCfg.deviceID] = {
                     _total: 100,
-                    _needBytes: 0
+                    _needBytes: 0,
+                    _needItems: 0
                 };
             });
             $scope.devices.sort(deviceCompare);
@@ -431,7 +433,7 @@ angular.module('syncthing.core')
                     }
                 }
                 $scope.listenersFailed = listenersFailed;
-                $scope.listenersTotal = Object.keys(data.connectionServiceStatus).length;
+                $scope.listenersTotal = $scope.sizeOf(data.connectionServiceStatus);
 
                 $scope.discoveryTotal = data.discoveryMethods;
                 var discoveryFailed = [];
@@ -476,21 +478,24 @@ angular.module('syncthing.core')
         }
 
         function recalcCompletion(device) {
-            var total = 0, needed = 0, deletes = 0;
+            var total = 0, needed = 0, deletes = 0, items = 0;
             for (var folder in $scope.completion[device]) {
-                if (folder === "_total" || folder === '_needBytes') {
+                if (folder === "_total" || folder === '_needBytes' || folder === '_needItems') {
                     continue;
                 }
                 total += $scope.completion[device][folder].globalBytes;
                 needed += $scope.completion[device][folder].needBytes;
+                items += $scope.completion[device][folder].needItems;
                 deletes += $scope.completion[device][folder].needDeletes;
             }
             if (total == 0) {
                 $scope.completion[device]._total = 100;
                 $scope.completion[device]._needBytes = 0;
+                $scope.completion[device]._needItems = 0;
             } else {
                 $scope.completion[device]._total = Math.floor(100 * (1 - needed / total));
                 $scope.completion[device]._needBytes = needed
+                $scope.completion[device]._needItems = items;
             }
 
             if (needed == 0 && deletes > 0) {
@@ -498,7 +503,6 @@ angular.module('syncthing.core')
                 // to do. Drop down the completion percentage to indicate
                 // that we have stuff to do.
                 $scope.completion[device]._total = 95;
-                $scope.completion[device]._needBytes = 0;
             }
 
             console.log("recalcCompletion", device, $scope.completion[device]);
@@ -616,7 +620,6 @@ angular.module('syncthing.core')
                 merged.push(item);
             });
             $scope.needed = merged;
-            $scope.neededTotal = data.total;
         }
 
         function pathJoin(base, name) {
@@ -638,6 +641,12 @@ angular.module('syncthing.core')
             return $scope.config.options && $scope.config.options.defaultFolderPath && !$scope.editingExisting && $scope.folderEditor.folderPath.$pristine
         }
 
+        function resetRemoteNeed() {
+            $scope.remoteNeed = {};
+            $scope.remoteNeedFolders = [];
+            $scope.remoteNeedDevice = undefined;
+        }
+
         $scope.neededPageChanged = function (page) {
             $scope.neededCurrentPage = page;
             refreshNeed($scope.neededFolder);
@@ -656,6 +665,20 @@ angular.module('syncthing.core')
             $scope.failedPageSize = perpage;
         };
 
+        $scope.refreshRemoteNeed = function (folder, page, perpage) {
+            var url = urlbase + '/db/remoteneed?device=' + $scope.remoteNeedDevice.deviceID;
+            url += '&folder=' + encodeURIComponent(folder);
+            url += "&page=" + page + "&perpage=" + perpage;
+            $http.get(url).success(function (data) {
+                if ($scope.remoteNeedDevice !== '') {
+                    $scope.remoteNeed[folder] = data;
+                }
+            }).error(function (err) {
+                $scope.remoteNeed[folder] = undefined;
+                $scope.emitHTTPError(err);
+            });
+        };
+
         var refreshDeviceStats = debounce(function () {
             $http.get(urlbase + "/stats/device").success(function (data) {
                 $scope.deviceStats = data;
@@ -965,7 +988,7 @@ angular.module('syncthing.core')
             }
 
             // enumerate notifications
-            if ($scope.openNoAuth || !$scope.configInSync || Object.keys($scope.deviceRejections).length > 0 || Object.keys($scope.folderRejections).length > 0 || $scope.errorList().length > 0 || !online) {
+            if ($scope.openNoAuth || !$scope.configInSync || $scope.sizeOf($scope.deviceRejections) > 0 || $scope.sizeOf($scope.folderRejections) > 0 || $scope.errorList().length > 0 || !online) {
                 notifyCount++;
             }
 
@@ -1623,17 +1646,14 @@ angular.module('syncthing.core')
 
         $scope.deviceFolders = function (deviceCfg) {
             var folders = [];
-            for (var folderID in $scope.folders) {
-                var devices = $scope.folders[folderID].devices;
-                for (var i = 0; i < devices.length; i++) {
-                    if (devices[i].deviceID === deviceCfg.deviceID) {
-                        folders.push(folderID);
+            $scope.folderList().forEach(function (folder) {
+                for (var i = 0; i < folder.devices.length; i++) {
+                    if (folder.devices[i].deviceID === deviceCfg.deviceID) {
+                        folders.push(folder.id);
                         break;
                     }
                 }
-            }
-
-            folders.sort(folderCompare);
+            });
             return folders;
         };
 
@@ -1729,11 +1749,25 @@ angular.module('syncthing.core')
             $('#needed').modal().on('hidden.bs.modal', function () {
                 $scope.neededFolder = undefined;
                 $scope.needed = undefined;
-                $scope.neededTotal = 0;
                 $scope.neededCurrentPage = 1;
             });
         };
 
+        $scope.showRemoteNeed = function (device) {
+            resetRemoteNeed();
+            $scope.remoteNeedDevice = device;
+            $scope.deviceFolders(device).forEach(function(folder) {
+                if ($scope.completion[device.deviceID][folder].needItems === 0) {
+                    return;
+                }
+                $scope.remoteNeedFolders.push(folder);
+                $scope.refreshRemoteNeed(folder, 1, 10);
+            });
+            $('#remoteNeed').modal().on('hidden.bs.modal', function () {
+                resetRemoteNeed();
+            });
+        };
+
         $scope.showFailed = function (folder) {
             $scope.failedCurrent = $scope.failed[folder];
             $scope.failedFolderPath = $scope.folders[folder].path;
@@ -1900,12 +1934,16 @@ angular.module('syncthing.core')
                 // pseudo main. called on all definitions assigned
                 initController();
             }
-        }
+        };
 
         $scope.toggleUnits = function () {
             $scope.metricRates = !$scope.metricRates;
             try {
                 window.localStorage["metricRates"] = $scope.metricRates;
             } catch (exception) { }
-        }
+        };
+
+        $scope.sizeOf = function (dict) {
+            return Object.keys(dict).length;
+        };
     });

+ 1 - 1
gui/default/syncthing/transfer/neededFilesModalView.html

@@ -14,7 +14,7 @@
 
     <table class="table table-striped table-condensed">
 
-      <tr dir-paginate="f in needed | itemsPerPage: neededPageSize" current-page="neededCurrentPage" total-items="neededTotal" pagination-id="needed">
+      <tr dir-paginate="f in needed | itemsPerPage: neededPageSize" current-page="neededCurrentPage" total-items="neededItems(neededFolder)" pagination-id="needed">
 
         <!-- Icon -->
         <td class="small-data col-xs-2">

+ 45 - 0
gui/default/syncthing/transfer/remoteNeededFilesModalView.html

@@ -0,0 +1,45 @@
+é<modal id="remoteNeed" status="info" icon="exchange" heading="{{'Out of Sync Items' | translate}} - {{deviceName(remoteNeedDevice)}}" large="yes" closeable="yes">
+  <div class="modal-body">
+    <div ng-if="sizeOf(remoteNeed) == 0">
+      <span translate>Loading data...</span>
+    </div>
+    <div ng-if="sizeOf(remoteNeed) > 0">
+      <div class="panel panel-default" ng-repeat="folder in remoteNeedFolders" ng-if="remoteNeed[folder] && remoteNeed[folder].files.length > 0">
+        <button class="btn panel-heading" data-toggle="collapse" data-target="#remoteNeed-{{folder}}" aria-expanded="false">
+          <h4 class="panel-title">
+            <span>{{folderLabel(folder)}}</span>
+          </h4>
+        </button>
+        <div id="remoteNeed-{{folder}}" class="panel-collapse collapse">
+          <div class="panel-body">
+            <table class="table table-striped table-dynamic">
+              <thead>
+                <tr>
+                  <th translate>Path</th>
+                  <th translate>Size</th>
+                  <th><span tooltip data-original-title="{{'Time the item was last modified' | translate}}" translate>Mod. Time</span></th>
+                  <th><span tooltip data-original-title="{{'Device that last modified the item' | translate}}" translate>Mod. Device</span></th>
+                </tr>
+              </thead>
+              <tr dir-paginate="file in remoteNeed[folder].files | itemsPerPage: remoteNeed[folder].perpage" current-page="remoteNeed[folder].page" total-items="completion[remoteNeedDevice.deviceID][folder].needItems" pagination-id="'remoteNeed-' + folder">
+                <td>{{file.name}}</td>
+                <td><span ng-hide="file.type == 'DIRECTORY'">{{file.size | binary}}B</span></td>
+                <td>{{file.modified | date:"yyyy-MM-dd HH:mm:ss"}}</td>
+                <td ng-if="file.modifiedBy">{{friendlyNameFromShort(file.modifiedBy)}}</td>
+                <td ng-if="!file.modifiedBy"><span translate>Unknown</span></td>
+              </tr>
+            </table>
+
+            <dir-pagination-controls on-page-change="refreshRemoteNeed(folder, newPageNumber, remoteNeed[folder].perpage)" pagination-id="'remoteNeed-' + folder"></dir-pagination-controls>
+            <ul class="pagination pull-right">
+              <li ng-repeat="option in [10, 25, 50]" ng-class="{ active: remoteNeed[folder].perpage == option }">
+                <a href="#" ng-click="refreshRemoteNeed(folder, remoteNeed[folder].page, option)">{{option}}</a>
+              </li>
+            </ul>
+            <div class="clearfix"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</modal>

+ 109 - 40
lib/model/model.go

@@ -110,6 +110,7 @@ var (
 	errDevicePaused      = errors.New("device is paused")
 	errDeviceIgnored     = errors.New("device is ignored")
 	errFolderPaused      = errors.New("folder is paused")
+	errFolderNotRunning  = errors.New("folder is not running")
 	errFolderMissing     = errors.New("no such folder")
 	errNetworkNotAllowed = errors.New("network not allowed")
 )
@@ -182,15 +183,13 @@ func (m *Model) StartFolder(folder string) {
 }
 
 func (m *Model) startFolderLocked(folder string) config.FolderType {
-	cfg, ok := m.folderCfgs[folder]
-	if !ok {
-		panic("cannot start nonexistent folder " + cfg.Description())
+	if err := m.checkFolderRunningLocked(folder); err == errFolderMissing {
+		panic("cannot start nonexistent folder " + folder)
+	} else if err == nil {
+		panic("cannot start already running folder " + folder)
 	}
 
-	_, ok = m.folderRunners[folder]
-	if ok {
-		panic("cannot start already running folder " + cfg.Description())
-	}
+	cfg := m.folderCfgs[folder]
 
 	folderFactory, ok := folderFactories[cfg.Type]
 	if !ok {
@@ -585,6 +584,7 @@ func (m *Model) FolderStatistics() map[string]stats.FolderStatistics {
 type FolderCompletion struct {
 	CompletionPct float64
 	NeedBytes     int64
+	NeedItems     int64
 	GlobalBytes   int64
 	NeedDeletes   int64
 }
@@ -611,7 +611,7 @@ func (m *Model) Completion(device protocol.DeviceID, folder string) FolderComple
 	counts := m.deviceDownloads[device].GetBlockCounts(folder)
 	m.pmut.RUnlock()
 
-	var need, fileNeed, downloaded, deletes int64
+	var need, items, fileNeed, downloaded, deletes int64
 	rf.WithNeedTruncated(device, func(f db.FileIntf) bool {
 		ft := f.(db.FileInfoTruncated)
 
@@ -630,6 +630,8 @@ func (m *Model) Completion(device protocol.DeviceID, folder string) FolderComple
 		}
 
 		need += fileNeed
+		items++
+
 		return true
 	})
 
@@ -649,6 +651,7 @@ func (m *Model) Completion(device protocol.DeviceID, folder string) FolderComple
 	return FolderCompletion{
 		CompletionPct: completionPct,
 		NeedBytes:     need,
+		NeedItems:     items,
 		GlobalBytes:   tot,
 		NeedDeletes:   deletes,
 	}
@@ -715,15 +718,13 @@ func (m *Model) NeedSize(folder string) db.Counts {
 // NeedFolderFiles returns paginated list of currently needed files in
 // progress, queued, and to be queued on next puller iteration, as well as the
 // total number of files currently needed.
-func (m *Model) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfoTruncated, []db.FileInfoTruncated, []db.FileInfoTruncated, int) {
+func (m *Model) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfoTruncated, []db.FileInfoTruncated, []db.FileInfoTruncated) {
 	m.fmut.RLock()
 	defer m.fmut.RUnlock()
 
-	total := 0
-
 	rf, ok := m.folderFiles[folder]
 	if !ok {
-		return nil, nil, nil, 0
+		return nil, nil, nil
 	}
 
 	var progress, queued, rest []db.FileInfoTruncated
@@ -766,7 +767,6 @@ func (m *Model) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfo
 			return true
 		}
 
-		total++
 		if skip > 0 {
 			skip--
 			return true
@@ -778,10 +778,43 @@ func (m *Model) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfo
 				get--
 			}
 		}
-		return true
+		return get > 0
 	})
 
-	return progress, queued, rest, total
+	return progress, queued, rest
+}
+
+// RemoteNeedFolderFiles returns paginated list of currently needed files in
+// progress, queued, and to be queued on next puller iteration, as well as the
+// total number of files currently needed.
+func (m *Model) RemoteNeedFolderFiles(device protocol.DeviceID, folder string, page, perpage int) ([]db.FileInfoTruncated, error) {
+	m.fmut.RLock()
+	m.pmut.RLock()
+	if err := m.checkDeviceFolderConnectedLocked(device, folder); err != nil {
+		m.pmut.RUnlock()
+		m.fmut.RUnlock()
+		return nil, err
+	}
+	rf := m.folderFiles[folder]
+	m.pmut.RUnlock()
+	m.fmut.RUnlock()
+
+	files := make([]db.FileInfoTruncated, 0, perpage)
+	skip := (page - 1) * perpage
+	get := perpage
+	rf.WithNeedTruncated(device, func(f db.FileIntf) bool {
+		if skip > 0 {
+			skip--
+			return true
+		}
+		if get > 0 {
+			files = append(files, f.(db.FileInfoTruncated))
+			get--
+		}
+		return get > 0
+	})
+
+	return files, nil
 }
 
 // Index is called when a new device is connected and we receive their full index.
@@ -1865,22 +1898,30 @@ func (m *Model) ScanFolder(folder string) error {
 }
 
 func (m *Model) ScanFolderSubdirs(folder string, subs []string) error {
-	m.fmut.Lock()
-	runner, okRunner := m.folderRunners[folder]
-	cfg, okCfg := m.folderCfgs[folder]
-	m.fmut.Unlock()
-
-	if !okRunner {
-		if okCfg && cfg.Paused {
-			return errFolderPaused
-		}
-		return errFolderMissing
+	m.fmut.RLock()
+	if err := m.checkFolderRunningLocked(folder); err != nil {
+		m.fmut.RUnlock()
+		return err
 	}
+	runner := m.folderRunners[folder]
+	m.fmut.RUnlock()
 
 	return runner.Scan(subs)
 }
 
 func (m *Model) internalScanFolderSubdirs(ctx context.Context, folder string, subDirs []string) error {
+	m.fmut.RLock()
+	if err := m.checkFolderRunningLocked(folder); err != nil {
+		m.fmut.RUnlock()
+		return err
+	}
+	fset := m.folderFiles[folder]
+	folderCfg := m.folderCfgs[folder]
+	ignores := m.folderIgnores[folder]
+	runner := m.folderRunners[folder]
+	m.fmut.RUnlock()
+	mtimefs := fset.MtimeFS()
+
 	for i := 0; i < len(subDirs); i++ {
 		sub := osutil.NativeFilename(subDirs[i])
 
@@ -1899,14 +1940,6 @@ func (m *Model) internalScanFolderSubdirs(ctx context.Context, folder string, su
 		subDirs[i] = sub
 	}
 
-	m.fmut.Lock()
-	fset := m.folderFiles[folder]
-	folderCfg := m.folderCfgs[folder]
-	ignores := m.folderIgnores[folder]
-	runner, ok := m.folderRunners[folder]
-	m.fmut.Unlock()
-	mtimefs := fset.MtimeFS()
-
 	// Check if the ignore patterns changed as part of scanning this folder.
 	// If they did we should schedule a pull of the folder so that we
 	// request things we might have suddenly become unignored and so on.
@@ -1918,13 +1951,6 @@ func (m *Model) internalScanFolderSubdirs(ctx context.Context, folder string, su
 		}
 	}()
 
-	if !ok {
-		if folderCfg.Paused {
-			return errFolderPaused
-		}
-		return errFolderMissing
-	}
-
 	if err := runner.CheckHealth(); err != nil {
 		return err
 	}
@@ -2495,6 +2521,49 @@ func (m *Model) CommitConfiguration(from, to config.Configuration) bool {
 	return true
 }
 
+// checkFolderRunningLocked returns nil if the folder is up and running and a
+// descriptive error if not.
+// Need to hold (read) lock on m.fmut when calling this.
+func (m *Model) checkFolderRunningLocked(folder string) error {
+	_, ok := m.folderRunners[folder]
+	if ok {
+		return nil
+	}
+
+	if cfg, ok := m.cfg.Folder(folder); !ok {
+		return errFolderMissing
+	} else if cfg.Paused {
+		return errFolderPaused
+	}
+
+	return errFolderNotRunning
+}
+
+// checkFolderDeviceStatusLocked first checks the folder and then whether the
+// given device is connected and shares this folder.
+// Need to hold (read) lock on both m.fmut and m.pmut when calling this.
+func (m *Model) checkDeviceFolderConnectedLocked(device protocol.DeviceID, folder string) error {
+	if err := m.checkFolderRunningLocked(folder); err != nil {
+		return err
+	}
+
+	if cfg, ok := m.cfg.Device(device); !ok {
+		return errDeviceUnknown
+	} else if cfg.Paused {
+		return errDevicePaused
+	}
+
+	if _, ok := m.conn[device]; !ok {
+		return errors.New("device is not connected")
+	}
+
+	if !m.folderDevices.has(device, folder) {
+		return errors.New("folder is not shared with device")
+	}
+
+	return nil
+}
+
 // mapFolders returns a map of folder ID to folder configuration for the given
 // slice of folder configurations.
 func mapFolders(folders []config.FolderConfiguration) map[string]config.FolderConfiguration {

+ 33 - 0
lib/model/model_test.go

@@ -2870,6 +2870,39 @@ func TestIssue4475(t *testing.T) {
 	}
 }
 
+func TestPausedFolders(t *testing.T) {
+	// Create a separate wrapper not to pollute other tests.
+	cfg := defaultConfig.RawCopy()
+	wrapper := config.Wrap("/tmp/test", cfg)
+
+	db := db.OpenMemory()
+	m := NewModel(wrapper, protocol.LocalDeviceID, "syncthing", "dev", db, nil)
+	m.AddFolder(defaultFolderConfig)
+	m.StartFolder("default")
+	m.ServeBackground()
+	defer m.Stop()
+
+	if err := m.ScanFolder("default"); err != nil {
+		t.Error(err)
+	}
+
+	pausedConfig := wrapper.RawCopy()
+	pausedConfig.Folders[0].Paused = true
+	w, err := m.cfg.Replace(pausedConfig)
+	if err != nil {
+		t.Fatal(err)
+	}
+	w.Wait()
+
+	if err := m.ScanFolder("default"); err != errFolderPaused {
+		t.Errorf("Expected folder paused error, received: %v", err)
+	}
+
+	if err := m.ScanFolder("nonexistent"); err != errFolderMissing {
+		t.Errorf("Expected missing folder error, received: %v", err)
+	}
+}
+
 func addFakeConn(m *Model, dev protocol.DeviceID) *fakeConnection {
 	fc := &fakeConnection{id: dev, model: m}
 	m.AddConnection(fc, protocol.HelloResult{})