From 8fa2b7765ab59cba24996cb0a084dcf1315c25f8 Mon Sep 17 00:00:00 2001 From: Simon Frei Date: Fri, 15 Dec 2017 20:01:56 +0000 Subject: [PATCH] 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 --- cmd/syncthing/gui.go | 60 +++++-- cmd/syncthing/mocked_model_test.go | 8 +- cmd/syncthing/summaryservice.go | 1 + gui/default/assets/lang/lang-en.json | 6 + gui/default/index.html | 7 + .../syncthing/core/syncthingController.js | 78 ++++++--- .../transfer/neededFilesModalView.html | 2 +- .../transfer/remoteNeededFilesModalView.html | 45 ++++++ lib/model/model.go | 149 +++++++++++++----- lib/model/model_test.go | 33 ++++ 10 files changed, 313 insertions(+), 76 deletions(-) create mode 100644 gui/default/syncthing/transfer/remoteNeededFilesModalView.html diff --git a/cmd/syncthing/gui.go b/cmd/syncthing/gui.go index 5bf7f73af..ad39a8c2a 100644 --- a/cmd/syncthing/gui.go +++ b/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 +} - progress, queued, rest, total := s.model.NeedFolderFiles(folder, page, perpage) +func (s *apiService) getDBNeed(w http.ResponseWriter, r *http.Request) { + qs := r.URL.Query() + + folder := qs.Get("folder") + + 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, }) } diff --git a/cmd/syncthing/mocked_model_test.go b/cmd/syncthing/mocked_model_test.go index 096081c03..f6e36d030 100644 --- a/cmd/syncthing/mocked_model_test.go +++ b/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 { diff --git a/cmd/syncthing/summaryservice.go b/cmd/syncthing/summaryservice.go index 58ef3db4c..c437d8efd 100644 --- a/cmd/syncthing/summaryservice.go +++ b/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, }) } diff --git a/gui/default/assets/lang/lang-en.json b/gui/default/assets/lang/lang-en.json index b46737661..3efffb1e4 100644 --- a/gui/default/assets/lang/lang-en.json +++ b/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", diff --git a/gui/default/index.html b/gui/default/index.html index c8705fd5c..38969c13a 100644 --- a/gui/default/index.html +++ b/gui/default/index.html @@ -603,6 +603,12 @@ + +  Out of Sync Items + + {{completion[deviceCfg.deviceID]._needItems | alwaysNumber}} items, ~{{completion[deviceCfg.deviceID]._needBytes | binary}}B + +  Address @@ -722,6 +728,7 @@ + diff --git a/gui/default/syncthing/core/syncthingController.js b/gui/default/syncthing/core/syncthingController.js index 5df09bd1c..ad333b1fb 100755 --- a/gui/default/syncthing/core/syncthingController.js +++ b/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; + }; }); diff --git a/gui/default/syncthing/transfer/neededFilesModalView.html b/gui/default/syncthing/transfer/neededFilesModalView.html index e08a2d951..02401353d 100644 --- a/gui/default/syncthing/transfer/neededFilesModalView.html +++ b/gui/default/syncthing/transfer/neededFilesModalView.html @@ -14,7 +14,7 @@ - +
diff --git a/gui/default/syncthing/transfer/remoteNeededFilesModalView.html b/gui/default/syncthing/transfer/remoteNeededFilesModalView.html new file mode 100644 index 000000000..c7a093085 --- /dev/null +++ b/gui/default/syncthing/transfer/remoteNeededFilesModalView.html @@ -0,0 +1,45 @@ +é + + diff --git a/lib/model/model.go b/lib/model/model.go index aa70da86b..f86676b4b 100644 --- a/lib/model/model.go +++ b/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 { diff --git a/lib/model/model_test.go b/lib/model/model_test.go index 50d13180c..d67d310e2 100644 --- a/lib/model/model_test.go +++ b/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{})