feat(gui, config): support simple folder grouping (fixes #2070) (#10563)

Adds a very simple way to group up folders to help with organising from
the GUI. 

Signed-off-by: Ben Norcombe <bennorcombe@pm.me>
This commit is contained in:
Ben Norcombe
2026-04-07 17:05:41 +02:00
committed by GitHub
parent 5d877f65f5
commit 6a26d56ad9
6 changed files with 517 additions and 460 deletions
+18 -8
View File
@@ -381,9 +381,13 @@
<div class="col-md-6" aria-labelledby="folder_list" role="region" >
<h3 id="folder_list"><span translate>Folders</span><span ng-if="folderList().length > 1"> ({{folderList().length}})</span></h3>
<div class="panel-group" id="folders">
<div class="panel panel-default" ng-repeat="folder in folderList()">
<button class="btn panel-heading" data-toggle="collapse" data-parent="#folders" data-target="#folder-{{$index}}" aria-expanded="false">
<div ng-repeat="(folderGroupName, groupedFolders) in foldersGrouped">
<h4 ng-if="folderGroupName !== ''">{{ folderGroupName }}
<span ng-if="groupedFolders.length > 1 && folderGroupName.length > 0"> ({{groupedFolders.length}})</span>
</h4>
<div class="panel-group" id="folders-{{ $index }}">
<div class="panel panel-default" ng-repeat="folder in groupedFolders">
<button class="btn panel-heading" data-toggle="collapse" data-parent="#folders-{{$index}}" data-target="#folder-{{$parent.$index}}-{{$index}}" aria-expanded="false">
<div class="panel-progress" ng-show="folderStatus(folder) == 'syncing'" ng-attr-style="width: {{syncPercentage(folder.id) | percent}}"></div>
<div class="panel-progress" ng-show="folderStatus(folder) == 'scanning' && scanProgress[folder.id] != undefined" ng-attr-style="width: {{scanPercentage(folder.id) | percent}}"></div>
<h4 class="panel-title">
@@ -406,7 +410,7 @@
</div>
</h4>
</button>
<div id="folder-{{$index}}" class="panel-collapse collapse">
<div id="folder-{{$parent.$index}}-{{$index}}" class="panel-collapse collapse">
<div class="panel-body less-padding">
<table class="table table-condensed table-striped table-auto">
<tbody>
@@ -643,6 +647,7 @@
</div>
</div>
</div>
</div>
<span class="pull-right">
<button type="button" class="btn btn-sm btn-default" ng-click="setAllFoldersPause(true)" ng-if="isAtleastOneFolderPausedStateSetTo(false)">
<span class="fas fa-pause"></span>&nbsp;<span translate>Pause All</span>
@@ -770,9 +775,13 @@
<!-- Remote devices -->
<h3><span translate>Remote Devices</span> <span ng-if="otherDevices().length > 1"> ({{otherDevices().length}})</span></h3>
<div class="panel-group" id="devices">
<div class="panel panel-default" ng-repeat="deviceCfg in otherDevices()">
<button class="btn panel-heading" data-toggle="collapse" data-parent="#devices" data-target="#device-{{$index}}" aria-expanded="false">
<div ng-repeat="(deviceGroupName, groupedDevices) in devicesGrouped">
<h4>{{ deviceGroupName }}
<span ng-if="groupedDevices.length > 1 && deviceGroupName.length > 0"> ({{groupedDevices.length}})</span>
</h4>
<div class="panel-group" id="devices-{{ $index }}">
<div class="panel panel-default" ng-repeat="deviceCfg in groupedDevices">
<button class="btn panel-heading" data-toggle="collapse" data-parent="#devices-{{ $parent.$index }}" data-target="#device-{{$parent.$index}}-{{$index}}" aria-expanded="false">
<div class="panel-progress" ng-show="deviceStatus(deviceCfg) == 'syncing'" ng-attr-style="width: {{completion[deviceCfg.deviceID]._total | percent}}"></div>
<h4 class="panel-title">
<identicon class="panel-icon" data-value="deviceCfg.deviceID"></identicon>
@@ -789,7 +798,7 @@
<div class="panel-title-text">{{deviceName(deviceCfg)}}</div>
</h4>
</button>
<div id="device-{{$index}}" class="panel-collapse collapse">
<div id="device-{{$parent.$index}}-{{$index}}" class="panel-collapse collapse">
<div class="panel-body less-padding">
<table class="table table-condensed table-striped table-auto">
<tbody>
@@ -979,6 +988,7 @@
</div>
</div>
</div>
</div>
<div class="form-group">
<span class="pull-right">
<button type="button" class="btn btn-sm btn-default" ng-click="setAllDevicesPause(true)" ng-if="isAtleastOneDevicePausedStateSetTo(false)">
@@ -81,12 +81,14 @@ angular.module('syncthing.core')
$scope.model = {};
$scope.myID = '';
$scope.devices = {};
$scope.devicesGrouped = {};
$scope.discoveryCache = {};
$scope.protocolChanged = false;
$scope.reportData = {};
$scope.reportDataPreview = '';
$scope.reportPreview = false;
$scope.folders = {};
$scope.foldersGrouped = {};
$scope.seenError = '';
$scope.upgradeInfo = null;
$scope.deviceStats = {};
@@ -559,21 +561,44 @@ angular.module('syncthing.core')
$scope.config.options._urAcceptedStr = "" + $scope.config.options.urAccepted;
$scope.devices = deviceMap($scope.config.devices);
$scope.devicesGrouped = {};
for (var id in $scope.devices) {
$scope.completion[id] = {
_total: 100,
_needBytes: 0,
_needItems: 0
};
if ($scope.devicesGrouped[$scope.devices[id].group] === undefined) {
$scope.devicesGrouped[$scope.devices[id].group] = [];
}
$scope.devicesGrouped[$scope.devices[id].group].push($scope.devices[id]);
};
$scope.folders = folderMap($scope.config.folders);
$scope.foldersGrouped = {};
Object.keys($scope.folders).forEach(function (folder) {
refreshFolder(folder);
$scope.folders[folder].devices.forEach(function (deviceCfg) {
refreshCompletion(deviceCfg.deviceID, folder);
});
if ($scope.foldersGrouped[$scope.folders[folder].group] === undefined) {
$scope.foldersGrouped[$scope.folders[folder].group] = [];
}
$scope.foldersGrouped[$scope.folders[folder].group].push($scope.folders[folder]);
});
// Sort with blank group first if any then alphabetically
const blankSort = (a, b) => {
if (a[0] === "" && b[0] !== "") return -1;
if (b[0] === "" && a[0] !== "") return 1;
return a[0].localeCompare(b[0]);
};
$scope.foldersGrouped = Object.fromEntries(Object.entries($scope.foldersGrouped).sort(blankSort));
$scope.devicesGrouped = Object.fromEntries(Object.entries($scope.devicesGrouped).sort(blankSort));
refreshNoAuthWarning();
setDefaultTheme();
@@ -57,6 +57,16 @@
<p translate ng-if="currentDevice.deviceID == myID" class="help-block">Shown instead of Device ID in the cluster status. Will be advertised to other devices as an optional default name.</p>
<p translate ng-if="currentDevice.deviceID != myID" class="help-block">Shown instead of Device ID in the cluster status. Will be updated to the name the device advertises if left empty.</p>
</div>
<div class="form-group" ng-class="{'has-error': deviceEditor.deviceGroup.$invalid && deviceEditor.deviceGroup.$dirty && !editingDeviceDefaults()}">
<label for="deviceGroup"><span translate>Device Group</span></label>
<input name="deviceGroup" id="deviceGroup" class="form-control" type="text" ng-model="currentDevice.group" value="{{currentDevice.group}}" list="device-group-list"/>
<datalist id="device-group-list">
<option ng-repeat="(group, device) in devicesGrouped" value="{{group}}" />
</datalist>
<p class="help-block">
<span translate ng-if="deviceEditor.deviceGroup.$valid || deviceEditor.deviceGroup.$pristine">Optional group for the device. Can be different on each device.</span>
</p>
</div>
</div>
<div ng-if="!editingDeviceDefaults()" id="device-sharing" class="tab-pane">
<div class="row">
@@ -18,6 +18,16 @@
<span translate ng-if="folderEditor.folderLabel.$valid || folderEditor.folderLabel.$pristine">Optional descriptive label for the folder. Can be different on each device.</span>
</p>
</div>
<div class="form-group" ng-class="{'has-error': folderEditor.folderGroup.$invalid && folderEditor.folderGroup.$dirty && !editingFolderDefaults()}">
<label for="folderGroup"><span translate>Folder Group</span></label>
<input name="folderGroup" id="folderGroup" class="form-control" type="text" ng-model="currentFolder.group" value="{{currentFolder.group}}" list="folder-group-list"/>
<datalist id="folder-group-list">
<option ng-repeat="(group, folders) in foldersGrouped" value="{{group}}" />
</datalist>
<p class="help-block">
<span translate ng-if="folderEditor.folderGroup.$valid || folderEditor.folderGroup.$pristine">Optional group for the folder. Can be different on each device.</span>
</p>
</div>
<div ng-if="!editingFolderDefaults()" class="form-group" ng-class="{'has-error': folderEditor.folderID.$invalid && folderEditor.folderID.$dirty}">
<label for="folderID"><span translate>Folder ID</span></label>
<input name="folderID" ng-readonly="has(['existing', 'new-pending'], currentFolder._editing)" id="folderID" class="form-control" type="text" ng-model="currentFolder.id" required="" aria-required="true" unique-folder value="{{currentFolder.id}}" />
+1
View File
@@ -36,6 +36,7 @@ type DeviceConfiguration struct {
Untrusted bool `json:"untrusted" xml:"untrusted"`
RemoteGUIPort int `json:"remoteGUIPort" xml:"remoteGUIPort"`
RawNumConnections int `json:"numConnections" xml:"numConnections"`
Group string `json:"group" xml:"group,attr,omitempty"`
}
func (cfg DeviceConfiguration) Copy() DeviceConfiguration {
+1
View File
@@ -54,6 +54,7 @@ type FolderConfiguration struct {
Path string `json:"path" xml:"path,attr"`
Type FolderType `json:"type" xml:"type,attr"`
Devices []FolderDeviceConfiguration `json:"devices" xml:"device"`
Group string `json:"group" xml:"group,attr,omitempty" restart:"false"`
RescanIntervalS int `json:"rescanIntervalS" xml:"rescanIntervalS,attr" default:"3600"`
FSWatcherEnabled bool `json:"fsWatcherEnabled" xml:"fsWatcherEnabled,attr" default:"true"`
FSWatcherDelayS float64 `json:"fsWatcherDelayS" xml:"fsWatcherDelayS,attr" default:"10"`