diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1d16c9c..9fe3b3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,8 @@ jobs: use_oidc: true - name: Installation script contracts run: bash tests/scripts.test.sh --installation-only + - name: Device sharing form contracts + run: node --test tests/device-sharing.test.mjs - name: Integration fixture contracts run: go -C tests/integration test -race ./... - name: Reproduce bundled core diff --git a/CHANGELOG.md b/CHANGELOG.md index c1b8845..6da5ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,18 @@ Notable changes to Syncshell for Omarchy are documented here. +## 0.2.4 - 2026-10-19 + +- render external adapter text literally and escape desktop notifications and + Waybar tooltips +- distinguish confirmed and unconfirmed shares in the device-sharing form +- preserve sharing selections during device-state refreshes +- clean up QML variable scope, property access, and lint diagnostics + ## 0.2.3 - 2026-10-19 -- qualify Omarchy palette references for compatibility with Omarchy 4.0.5 and - Qt 6.12 +- qualify Omarchy palette references for compatibility with Omarchy 4.0.5 and Qt + 6.12 - fix nested submenu dismissal and preserve unsaved folder-sharing selections during background refreshes diff --git a/README.md b/README.md index 3de551c..d0a2449 100644 --- a/README.md +++ b/README.md @@ -320,8 +320,11 @@ See [CHANGELOG.md](CHANGELOG.md) for details. | Release | Date | What changed | | ------- | ---------- | ------------------------------------------------------- | | Future | TBD | mature alpha DMS, II, Caelestia, and Waybar adapters | -| 0.2.3 | 2026-10-19 | support Omarchy 4.0.5 and Qt 6.12 palette references | -| | | fix submenu dismissal and preserve sharing selections | +| 0.2.4 | 2026-10-19 | protect adapter messages and tooltips from markup | +| | | distinguish confirmed and unconfirmed device shares | +| | | preserve sharing drafts and clean up QML diagnostics | +| 0.2.3 | 2026-10-19 | support Omarchy 4.0.5 and Qt 6.12 palette references | +| | | fix submenu dismissal and preserve sharing selections | | 0.2.2 | 2026-10-06 | secure pending-device dismissal confirmations | | 0.2.1 | 2026-10-05 | render pending device names as plain text in adapters | | 0.2.0 | 2026-10-05 | improve multi-folder navigation, search, and filtering | diff --git a/bin/aarch64/syncshell-core b/bin/aarch64/syncshell-core index 9dd62f3..c174079 100755 Binary files a/bin/aarch64/syncshell-core and b/bin/aarch64/syncshell-core differ diff --git a/bin/x86_64/syncshell-core b/bin/x86_64/syncshell-core index eada619..9638d62 100755 Binary files a/bin/x86_64/syncshell-core and b/bin/x86_64/syncshell-core differ diff --git a/core/cmd/syncshell-core/main.go b/core/cmd/syncshell-core/main.go index 85b24f0..88d4834 100644 --- a/core/cmd/syncshell-core/main.go +++ b/core/cmd/syncshell-core/main.go @@ -17,7 +17,7 @@ import ( "github.com/omarchy-QOL/syncshell/core/internal/waybarconfig" ) -var buildVersion = "0.2.2" +var buildVersion = "0.2.4" type options struct { session.Config diff --git a/core/internal/session/events.go b/core/internal/session/events.go index a3a849b..576e112 100644 --- a/core/internal/session/events.go +++ b/core/internal/session/events.go @@ -13,7 +13,8 @@ const ( ) var eventTypes = []string{ - "ConfigSaved", "DeviceConnected", "DeviceDisconnected", "DevicePaused", + "ConfigSaved", "ClusterConfigReceived", "FolderCompletion", + "DeviceConnected", "DeviceDisconnected", "DevicePaused", "DeviceResumed", "DownloadProgress", "FolderErrors", "FolderPaused", "FolderResumed", "FolderScanProgress", "FolderSummary", "ItemFinished", "ItemStarted", "LocalIndexUpdated", diff --git a/core/internal/session/normalize.go b/core/internal/session/normalize.go index 0b60829..cb4ec08 100644 --- a/core/internal/session/normalize.go +++ b/core/internal/session/normalize.go @@ -70,7 +70,7 @@ func normalizeFolder( ) (Folder, int) { devices := make([]FolderDevice, 0, min(len(folder.Devices), maxFolderDevices)) for _, device := range folder.Devices[:min(len(folder.Devices), maxFolderDevices)] { - devices = append(devices, FolderDevice{ID: boundedIdentifier(device.DeviceID)}) + devices = append(devices, FolderDevice{ID: boundedIdentifier(device.DeviceID), RemoteState: "unknown"}) } sort.Slice(devices, func(i, j int) bool { return devices[i].ID < devices[j].ID }) currentErrors := append([]syncthing.FolderError(nil), errorsResponse.Errors...) diff --git a/core/internal/session/session.go b/core/internal/session/session.go index 12340f0..78fde2a 100644 --- a/core/internal/session/session.go +++ b/core/internal/session/session.go @@ -256,6 +256,7 @@ func (s *Session) loadAuthenticated( truncation.NearbyDevices = max(0, len(nearbyDeviceIDs(discovery, devices, status.MyID))-maxNearbyDevices) truncation.FolderErrors = omittedFolderErrors + s.loadRemoteSharing(ctx, normalizedFolders, devices, connections, status.MyID) return hydratedState{ identity: Identity{DeviceID: boundedIdentifier(status.MyID), Version: boundedLabel(version.Version)}, diff --git a/core/internal/session/session_test.go b/core/internal/session/session_test.go index f83be6e..710f468 100644 --- a/core/internal/session/session_test.go +++ b/core/internal/session/session_test.go @@ -472,7 +472,7 @@ func TestConfigureSupportsConcurrentUpdates(t *testing.T) { } } -func newTestSession(t *testing.T, api *testAPI, active, expectedID string) *Session { +func newTestSession(t *testing.T, api http.Handler, active, expectedID string) *Session { t.Helper() server := httptest.NewServer(api) t.Cleanup(server.Close) diff --git a/core/internal/session/sharing.go b/core/internal/session/sharing.go new file mode 100644 index 0000000..be84fcf --- /dev/null +++ b/core/internal/session/sharing.go @@ -0,0 +1,62 @@ +package session + +import ( + "context" + "sync" + "time" + + "github.com/omarchy-QOL/syncshell/core/internal/syncthing" +) + +// Optional peer metadata must not hold up the local folder snapshot indefinitely. +func (s *Session) loadRemoteSharing(ctx context.Context, folders []Folder, + devices []syncthing.Device, connections syncthing.Connections, localID string, +) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + connected := make(map[string]bool, len(devices)) + for _, device := range devices { + connected[device.DeviceID] = !device.Paused && connections.Connections[device.DeviceID].Connected + } + type pair struct { + folderID string + member *FolderDevice + } + jobs := make(chan pair) + var workers sync.WaitGroup + for i := 0; i < 4; i++ { + workers.Add(1) + go func() { + defer workers.Done() + for job := range jobs { + completion, err := s.client.FolderCompletion(ctx, job.folderID, job.member.ID) + if err != nil { + continue + } + switch completion.RemoteState { + case "valid", "paused", "notSharing": + job.member.RemoteState = completion.RemoteState + } + } + }() + } +enqueue: + for _, folder := range folders { + if folder.Paused { + continue + } + for i := range folder.Devices { + member := &folder.Devices[i] + if member.ID == localID || !connected[member.ID] { + continue + } + select { + case jobs <- pair{folder.ID, member}: + case <-ctx.Done(): + break enqueue + } + } + } + close(jobs) + workers.Wait() +} diff --git a/core/internal/session/sharing_test.go b/core/internal/session/sharing_test.go new file mode 100644 index 0000000..1a341dd --- /dev/null +++ b/core/internal/session/sharing_test.go @@ -0,0 +1,155 @@ +package session + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "sync/atomic" + "testing" + "time" + + "github.com/omarchy-QOL/syncshell/core/internal/syncthing" +) + +func TestRemoteSharingStatesHydrateAndRecover(t *testing.T) { + api := &testAPI{} + var fail atomic.Bool + var accepted atomic.Bool + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/config/folders" { + folders := []syncthing.Folder{} + for _, id := range []string{"valid", "paused", "notSharing", "unknown", "unexpected"} { + folders = append(folders, syncthing.Folder{ID: id, + Devices: []syncthing.FolderDevice{syncthing.NewFolderDevice("REMOTE")}}) + } + _ = json.NewEncoder(w).Encode(folders) + return + } + if r.URL.Path == "/rest/db/completion" { + if r.Method != http.MethodGet || r.URL.Query().Get("device") != "REMOTE" || + r.Header.Get("X-API-Key") != sessionTestKey { + t.Error("completion must be an authenticated per-device GET") + } + if fail.Load() { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + state := r.URL.Query().Get("folder") + if accepted.Load() { + state = "valid" + } + _ = json.NewEncoder(w).Encode(syncthing.FolderCompletion{RemoteState: state}) + return + } + api.ServeHTTP(w, r) + }) + s := newTestSession(t, handler, "inactive", "LOCAL-ID") + check := func(allUnknown, allValid bool) { + t.Helper() + snapshot, err := s.Refresh(context.Background()) + if err != nil || !snapshot.State.Connection.Fresh { + t.Fatalf("optional completion failed the local snapshot: %v", err) + } + for _, folder := range snapshot.State.Folders { + want := folder.ID + if allUnknown || want == "unexpected" { + want = "unknown" + } + if allValid { + want = "valid" + } + if got := folder.Devices[0].RemoteState; got != want { + t.Errorf("%s remote state = %s, want %s", folder.ID, got, want) + } + } + } + check(false, false) + fail.Store(true) + check(true, false) + fail.Store(false) + accepted.Store(true) + check(false, true) +} + +func TestRemoteSharingSkipsUnavailablePeersAndBoundsConcurrency(t *testing.T) { + var requests, running, maximum atomic.Int32 + api := &testAPI{} + s := newTestSession(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/rest/db/completion" { + api.ServeHTTP(w, r) + return + } + requests.Add(1) + current := running.Add(1) + defer running.Add(-1) + for current > maximum.Load() { + previous := maximum.Load() + if current <= previous || maximum.CompareAndSwap(previous, current) { + break + } + } + if r.URL.Query().Get("device") != "REMOTE" || r.URL.Query().Get("folder") == "local-paused" { + t.Error("queried a local, paused or disconnected target") + } + time.Sleep(time.Millisecond) + writeSessionJSON(w, `{"remoteState":"valid"}`) + }), "inactive", "LOCAL-ID") + devices := []syncthing.Device{{DeviceID: "LOCAL-ID"}, {DeviceID: "REMOTE"}, + {DeviceID: "OFFLINE"}, {DeviceID: "PAUSED", Paused: true}} + connections := syncthing.Connections{Connections: map[string]syncthing.Connection{ + "LOCAL-ID": {Connected: true}, "REMOTE": {Connected: true}, "PAUSED": {Connected: true}, + }} + folders := make([]Folder, 21) + for i := range folders { + folders[i].ID = "folder" + for _, device := range devices { + folders[i].Devices = append(folders[i].Devices, + FolderDevice{ID: device.DeviceID, RemoteState: "unknown"}) + } + } + folders[20].ID, folders[20].Paused = "local-paused", true + s.loadRemoteSharing(context.Background(), folders, devices, connections, "LOCAL-ID") + if requests.Load() != 20 || maximum.Load() > 4 { + t.Fatalf("requests=%d concurrent=%d", requests.Load(), maximum.Load()) + } + for _, folder := range folders { + for _, member := range folder.Devices { + want := "unknown" + if member.ID == "REMOTE" && !folder.Paused { + want = "valid" + } + if member.RemoteState != want { + t.Errorf("%s/%s state=%s want=%s", folder.ID, member.ID, member.RemoteState, want) + } + } + } +} + +func TestRemoteSharingCancellationLeavesUnknown(t *testing.T) { + api := &testAPI{} + s := newTestSession(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/db/completion" { + <-r.Context().Done() + return + } + api.ServeHTTP(w, r) + }), "inactive", "LOCAL-ID") + folders := []Folder{{ID: "folder", Devices: []FolderDevice{{ID: "REMOTE", RemoteState: "unknown"}}}} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + started := time.Now() + s.loadRemoteSharing(ctx, folders, []syncthing.Device{{DeviceID: "REMOTE"}}, + syncthing.Connections{Connections: map[string]syncthing.Connection{"REMOTE": {Connected: true}}}, "LOCAL-ID") + if time.Since(started) > time.Second || folders[0].Devices[0].RemoteState != "unknown" { + t.Fatal("cancelled peer lookup blocked or falsely confirmed sharing") + } +} + +func TestSharingEventsRefreshRemoteState(t *testing.T) { + for _, kind := range []string{"ClusterConfigReceived", "FolderCompletion"} { + if !slices.Contains(eventTypes, kind) || !requiresHydration([]syncthing.Event{{Type: kind}}) { + t.Errorf("%s does not refresh sharing state", kind) + } + } +} diff --git a/core/internal/session/types.go b/core/internal/session/types.go index 98825ef..efcdd20 100644 --- a/core/internal/session/types.go +++ b/core/internal/session/types.go @@ -56,7 +56,8 @@ type Device struct { // FolderDevice is one normalized folder-sharing relationship. type FolderDevice struct { - ID string `json:"id"` + ID string `json:"id"` + RemoteState string `json:"remoteState"` } // FolderStatus is normalized authoritative folder state. diff --git a/core/internal/syncthing/client.go b/core/internal/syncthing/client.go index a87ae6b..7f7d25d 100644 --- a/core/internal/syncthing/client.go +++ b/core/internal/syncthing/client.go @@ -218,6 +218,14 @@ func (c *Client) FolderStatus(ctx context.Context, folderID string) (FolderStatu return response, err } +// FolderCompletion reads remote sharing state for one folder-device pair. +func (c *Client) FolderCompletion(ctx context.Context, folderID, deviceID string) (FolderCompletion, error) { + var response FolderCompletion + query := url.Values{"folder": {folderID}, "device": {deviceID}} + err := c.request(ctx, http.MethodGet, "/rest/db/completion?"+query.Encode(), nil, true, &response) + return response, err +} + // Rescan requests one verified folder scan. func (c *Client) Rescan(ctx context.Context, folderID string) (RescanDisposition, error) { path := "/rest/db/scan" diff --git a/core/internal/syncthing/completion_test.go b/core/internal/syncthing/completion_test.go new file mode 100644 index 0000000..ef1c315 --- /dev/null +++ b/core/internal/syncthing/completion_test.go @@ -0,0 +1,24 @@ +package syncthing + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFolderCompletionUsesExactPair(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/rest/db/completion" || + r.URL.Query().Get("folder") != "folder &+?" || r.URL.Query().Get("device") != "REMOTE" || + r.Header.Get("X-API-Key") != testAPIKey { + t.Error("completion query did not preserve the authenticated folder-device pair") + } + writeJSON(w, `{"remoteState":"notSharing","completion":0}`) + })) + defer server.Close() + result, err := testClient(t, server.URL, "", false).FolderCompletion(context.Background(), "folder &+?", "REMOTE") + if err != nil || result.RemoteState != "notSharing" { + t.Fatalf("completion = %#v, %v", result, err) + } +} diff --git a/core/internal/syncthing/wire.go b/core/internal/syncthing/wire.go index 48382d7..80d45b2 100644 --- a/core/internal/syncthing/wire.go +++ b/core/internal/syncthing/wire.go @@ -90,6 +90,11 @@ type FolderStatus struct { GlobalBytes int64 `json:"globalBytes"` } +// FolderCompletion contains the remote's current folder-sharing state. +type FolderCompletion struct { + RemoteState string `json:"remoteState"` +} + // Connections is the configured connection response. type Connections struct { Connections map[string]Connection `json:"connections"` diff --git a/core/internal/waybarconfig/config.go b/core/internal/waybarconfig/config.go index e02afbe..9a0d777 100644 --- a/core/internal/waybarconfig/config.go +++ b/core/internal/waybarconfig/config.go @@ -113,6 +113,7 @@ func installConfig(text, root string) (string, error) { "on-click-right": quickshell + " ipc call syncshell refresh", "on-click-middle": quickshell + " ipc call syncshell openWebUi", "tooltip": true, + "escape": true, } encoded, err := json.MarshalIndent(values, " ", " ") if err != nil { diff --git a/hosts/omarchy/OmarchyPanel.qml b/hosts/omarchy/OmarchyPanel.qml index eba152e..7d45340 100644 --- a/hosts/omarchy/OmarchyPanel.qml +++ b/hosts/omarchy/OmarchyPanel.qml @@ -265,7 +265,7 @@ Panel { function folderDisplayLabel(folderId) { var options = folderOptions(); - for (var i = 0; i < options.length; i++) { + for (let i = 0; i < options.length; i++) { if (options[i].value === folderId) return options[i].label; } @@ -316,10 +316,10 @@ Panel { function folderViewOptions() { var labels = folderOptions(); var options = []; - for (var i = 0; i < labels.length; i++) { - var folder = folderById(labels[i].value); - var state = folderState(folder); - var group = PanelModel.folderStateGroup(state); + for (let i = 0; i < labels.length; i++) { + let folder = folderById(labels[i].value); + let state = folderState(folder); + let group = PanelModel.folderStateGroup(state); if (!folderGroupVisible(group)) continue; options.push({ @@ -400,7 +400,7 @@ Panel { } function ensurePendingOfferSelection() { - for (var i = 0; i < pendingOfferRows.length; i++) { + for (let i = 0; i < pendingOfferRows.length; i++) { if (pendingOfferRows[i].value === selectedPendingOffer) return; } @@ -516,7 +516,7 @@ Panel { folderConfirmId = String(choice[0] || ""); folderConfirmDeviceId = String(choice[1] || ""); folderConfirmLabel = folderConfirmId; - for (var i = 0; i < pendingOfferRows.length; i++) { + for (let i = 0; i < pendingOfferRows.length; i++) { if (pendingOfferRows[i].value === selected) { folderConfirmLabel = pendingOfferRows[i].label + (pendingOfferRows[i].trailingText ? " " + pendingOfferRows[i].trailingText : ""); break; @@ -531,10 +531,10 @@ Panel { if (!value) return ""; try { - var choice = JSON.parse(value); + let choice = JSON.parse(value); if (!(choice instanceof Array) || choice.length !== 2) return ""; - var deviceId = String(choice[1] || ""); + let deviceId = String(choice[1] || ""); return popup.selectedDeviceIds.indexOf(deviceId) >= 0 ? deviceId : ""; } catch (error) { return ""; @@ -799,7 +799,7 @@ Panel { onExited: function (exitCode, exitStatus) { var selected = String(root.folderPickerOutput || folderPickerStdout.text || "").trim(); if (exitCode === 0 && selected) { - var path = Paths.localFilePath(selected); + let path = Paths.localFilePath(selected); popup.addPathText = path; if (!popup.addLabelText) popup.addLabelText = root.pathLabel(path); diff --git a/hosts/omarchy/OmarchyService.qml b/hosts/omarchy/OmarchyService.qml index 542a3fc..295f939 100644 --- a/hosts/omarchy/OmarchyService.qml +++ b/hosts/omarchy/OmarchyService.qml @@ -112,7 +112,7 @@ QtObject { } function currentLocalDeviceName() { - for (var i = 0; i < devices.length; i++) { + for (let i = 0; i < devices.length; i++) { if (devices[i].deviceID === localDeviceId) return devices[i].name; } @@ -227,7 +227,11 @@ QtObject { var args = ["omarchy-notification-send"]; if (Number(visibleMs) > 0) args.push("-t", String(Math.round(Number(visibleMs)))); - args.push("Syncthing", String(message)); + // Notification bodies support markup; our messages are plain text. + args.push("Syncthing", String(message) + .replace(/&/g, "&") + .replace(//g, ">")); Quickshell.execDetached(args); } @@ -246,8 +250,8 @@ QtObject { function scanningFolderIds() { var result = []; - for (var i = 0; i < folders.length; i++) { - var state = folderStatuses[String(folders[i].id || "")] || ({}); + for (let i = 0; i < folders.length; i++) { + let state = folderStatuses[String(folders[i].id || "")] || ({}); if (String(state.state || "").indexOf("scan") === 0) result.push(String(folders[i].id || "")); } @@ -331,7 +335,7 @@ QtObject { function configuredFolder(folderId) { var wanted = String(folderId || ""); - for (var i = 0; i < folders.length; i++) { + for (let i = 0; i < folders.length; i++) { if (String(folders[i].id || "") === wanted) return folders[i]; } @@ -404,15 +408,15 @@ QtObject { var folder = configuredFolder(folderId); var previous = []; var members = folder && folder.devices ? folder.devices : []; - for (var i = 0; i < members.length; i++) { - var memberId = String((members[i] || {}).deviceID || ""); + for (let i = 0; i < members.length; i++) { + let memberId = String((members[i] || {}).deviceID || ""); if (memberId && memberId !== localDeviceId) previous.push(memberId); } var names = []; - for (var j = 0; j < shared.length; j++) { - var name = ""; - for (var k = 0; k < devices.length; k++) { + for (let j = 0; j < shared.length; j++) { + let name = ""; + for (let k = 0; k < devices.length; k++) { if (String(devices[k].deviceID || "") === String(shared[j])) { name = String(devices[k].name || ""); break; diff --git a/hosts/omarchy/controllers/PackageController.qml b/hosts/omarchy/controllers/PackageController.qml index 18976d2..368d3dc 100644 --- a/hosts/omarchy/controllers/PackageController.qml +++ b/hosts/omarchy/controllers/PackageController.qml @@ -75,7 +75,7 @@ QtObject { packageActionRunning = true; packageError = ""; packageStatus = "Complete installation in the Omarchy terminal"; - operationPollTimer.ticks = 0; + operationPoll.ticks = 0; _operationSeen = false; operationPollTimer.start(); Quickshell.execDetached(["bash", helperPath, "install"]); @@ -90,6 +90,7 @@ QtObject { } property Timer operationPollTimer: Timer { + id: operationPoll property int ticks: 0 interval: 1500 repeat: true diff --git a/hosts/omarchy/controllers/SettingsController.qml b/hosts/omarchy/controllers/SettingsController.qml index e1af49a..7aae7a8 100644 --- a/hosts/omarchy/controllers/SettingsController.qml +++ b/hosts/omarchy/controllers/SettingsController.qml @@ -1,7 +1,6 @@ import QtQuick import Quickshell import Quickshell.Io -import qs.Commons import qs.Commons as Commons import "../models/SettingsModel.js" as SettingsModel @@ -339,7 +338,7 @@ QtObject { property ThemePaletteController themePalette: ThemePaletteController {} property FileView settingsFile: FileView { - id: settingsFile + id: settingsDocument path: root.settingsPath watchChanges: true printErrors: false @@ -364,7 +363,7 @@ QtObject { } property Process settingsProcess: Process { - id: settingsProcess + id: settingsWorker command: [] stdinEnabled: true stdout: StdioCollector { @@ -382,7 +381,7 @@ QtObject { var action = root._settingsAction; root.busy = false; if (exitCode === 0) { - settingsFile.reload(); + root.settingsFile.reload(); if (root._openAfterEnsure) { Quickshell.execDetached(["omarchy", "launch", "config-editor", root.settingsPath]); } @@ -404,7 +403,7 @@ QtObject { } property Process themeProcess: Process { - id: themeProcess + id: themeWorker command: [] onExited: function (exitCode) { if (!root.settingsReady) { @@ -420,7 +419,7 @@ QtObject { } property Process removalProcess: Process { - id: removalProcess + id: removalWorker command: [] onExited: function (exitCode) { if (exitCode === 0) { diff --git a/hosts/omarchy/models/FacadeModel.js b/hosts/omarchy/models/FacadeModel.js index 2e7ea20..4c79b15 100644 --- a/hosts/omarchy/models/FacadeModel.js +++ b/hosts/omarchy/models/FacadeModel.js @@ -3,8 +3,8 @@ function devices(values) { var projected = [] var source = values || [] - for (var i = 0; i < source.length; i++) { - var device = source[i] || ({}) + for (let i = 0; i < source.length; i++) { + let device = source[i] || ({}) projected.push({ deviceID: String(device.id || ""), name: String(device.name || ""), @@ -37,12 +37,15 @@ function folderStatus(value) { function folders(values) { var projected = [] var source = values || [] - for (var i = 0; i < source.length; i++) { - var folder = source[i] || ({}) - var folderDevices = [] - var shared = folder.devices || [] - for (var j = 0; j < shared.length; j++) { - folderDevices.push({ deviceID: String((shared[j] || {}).id || "") }) + for (let i = 0; i < source.length; i++) { + let folder = source[i] || ({}) + let folderDevices = [] + let shared = folder.devices || [] + for (let j = 0; j < shared.length; j++) { + folderDevices.push({ + deviceID: String((shared[j] || {}).id || ""), + remoteState: String((shared[j] || {}).remoteState || "unknown") + }) } projected.push({ id: String(folder.id || ""), @@ -59,8 +62,8 @@ function folders(values) { function folderStatuses(values) { var projected = ({}) var source = values || [] - for (var i = 0; i < source.length; i++) { - var folder = source[i] || ({}) + for (let i = 0; i < source.length; i++) { + let folder = source[i] || ({}) projected[String(folder.id || "")] = folderStatus(folder.status) } return projected @@ -69,7 +72,7 @@ function folderStatuses(values) { function syncingFiles(activity) { var projected = [] var files = activity && activity.files ? activity.files : [] - for (var i = 0; i < files.length; i++) { + for (let i = 0; i < files.length; i++) { projected.push(String((files[i] || {}).detail || "")) } return projected @@ -88,7 +91,7 @@ function truncationWarning(truncation) { var state = truncation || ({}) var keys = ["devices", "folders", "folderDevices", "pendingFolders", "pendingOffers", "pendingDevices", "nearbyDevices"] - for (var i = 0; i < keys.length; i++) { + for (let i = 0; i < keys.length; i++) { if (Number(state[keys[i]] || 0) > 0) { return "Some Syncthing items exceed panel limits; use the Web UI for " + "the hidden entries" diff --git a/hosts/omarchy/models/PanelModel.js b/hosts/omarchy/models/PanelModel.js index afa0b26..1bd322d 100644 --- a/hosts/omarchy/models/PanelModel.js +++ b/hosts/omarchy/models/PanelModel.js @@ -22,18 +22,18 @@ function buildFolderRows(syncthing, homePath) { var rows = []; var source = syncthing && syncthing.folders ? syncthing.folders : []; var statuses = syncthing && syncthing.folderStatuses ? syncthing.folderStatuses : ({}); - for (var i = 0; i < source.length; i++) { - var folder = source[i] || ({}); - var id = String(folder.id || ""); - var status = statuses[id] || ({}); - var state = String(status.state || "unknown"); - var errorCount = Math.max(Number(status.errors || 0), Number(status.pullErrors || 0)); - var needItems = Number(status.needTotalItems || 0); - var configuredLabel = String(folder.label || ""); - var folderDevices = folder.devices || []; - var sharedDeviceCount = 0; - for (var j = 0; j < folderDevices.length; j++) { - var deviceId = String((folderDevices[j] || {}).deviceID || ""); + for (let i = 0; i < source.length; i++) { + let folder = source[i] || ({}); + let id = String(folder.id || ""); + let status = statuses[id] || ({}); + let state = String(status.state || "unknown"); + let errorCount = Math.max(Number(status.errors || 0), Number(status.pullErrors || 0)); + let needItems = Number(status.needTotalItems || 0); + let configuredLabel = String(folder.label || ""); + let folderDevices = folder.devices || []; + let sharedDeviceCount = 0; + for (let j = 0; j < folderDevices.length; j++) { + let deviceId = String((folderDevices[j] || {}).deviceID || ""); if (deviceId && (!syncthing || deviceId !== syncthing.localDeviceId)) { sharedDeviceCount++; } @@ -65,14 +65,14 @@ function buildFolderRows(syncthing, homePath) { function total(rows, key) { var value = 0; - for (var i = 0; i < rows.length; i++) + for (let i = 0; i < rows.length; i++) value += Number(rows[i][key] || 0); return value; } function stateCount(rows, key) { var count = 0; - for (var i = 0; i < rows.length; i++) { + for (let i = 0; i < rows.length; i++) { if (rows[i][key]) count++; } @@ -104,8 +104,8 @@ function folderErrorText(folder) { return ""; var groups = Object.create(null); var errors = folder.errorDetails || []; - for (var i = 0; i < errors.length; i++) { - var error = errors[i]; + for (let i = 0; i < errors.length; i++) { + let error = errors[i]; if (!groups[error.error]) groups[error.error] = []; groups[error.error].push(error.path); @@ -121,7 +121,7 @@ function folderErrorText(folder) { function folderMeta(folder, rescanning) { var suffix = folder.configuredLabel && folder.configuredLabel !== folder.label ? " · " + folder.configuredLabel : ""; if (folder.problem) { - var firstError = (folder.errorDetails || [])[0]; + let firstError = (folder.errorDetails || [])[0]; return (folder.error || (firstError && firstError.error) || "Folder needs attention") + suffix; } if (folder.paused) @@ -129,7 +129,7 @@ function folderMeta(folder, rescanning) { if (rescanning) return "Scanning local changes" + suffix; if (folder.syncing) { - var remaining = formatCount(folder.needItems) + " item" + (folder.needItems === 1 ? "" : "s") + " remaining"; + let remaining = formatCount(folder.needItems) + " item" + (folder.needItems === 1 ? "" : "s") + " remaining"; return folder.needBytes > 0 ? remaining + " · " + formatBytes(folder.needBytes) + suffix : remaining + suffix; } if (folder.sharedDeviceCount === 0) @@ -208,7 +208,7 @@ function sortFolderOptions(options, mode) { } function folderById(rows, folderId) { - for (var i = 0; i < rows.length; i++) { + for (let i = 0; i < rows.length; i++) { if (rows[i].id === folderId) return rows[i]; } @@ -217,13 +217,13 @@ function folderById(rows, folderId) { function folderOptions(rows, homePath) { var options = []; - for (var i = 0; i < rows.length; i++) { - var duplicate = false; - for (var j = 0; j < rows.length; j++) { + for (let i = 0; i < rows.length; i++) { + let duplicate = false; + for (let j = 0; j < rows.length; j++) { if (i !== j && rows[j].label === rows[i].label) duplicate = true; } - var label = rows[i].label; + let label = rows[i].label; if (duplicate) label += " (" + pathParentName(rows[i].path, homePath) + ")"; options.push({ @@ -235,8 +235,8 @@ function folderOptions(rows, homePath) { } function deviceName(devices, deviceId) { - for (var i = 0; i < devices.length; i++) { - var device = devices[i] || ({}); + for (let i = 0; i < devices.length; i++) { + let device = devices[i] || ({}); if (String(device.deviceID || "") === String(deviceId || "")) { return String(device.name || "Device " + String(device.deviceID || "").slice(0, 7)); } @@ -250,8 +250,8 @@ function localDeviceName(syncthing, fallback) { return displayName; var id = String(syncthing && syncthing.localDeviceId || ""); var devices = syncthing && syncthing.devices ? syncthing.devices : []; - for (var i = 0; i < devices.length; i++) { - var device = devices[i] || ({}); + for (let i = 0; i < devices.length; i++) { + let device = devices[i] || ({}); if (String(device.deviceID || "") === id && device.name) { return String(device.name); } @@ -262,9 +262,9 @@ function localDeviceName(syncthing, fallback) { function deviceOptions(syncthing) { var options = []; var devices = syncthing && syncthing.devices ? syncthing.devices : []; - for (var i = 0; i < devices.length; i++) { - var device = devices[i] || ({}); - var id = String(device.deviceID || ""); + for (let i = 0; i < devices.length; i++) { + let device = devices[i] || ({}); + let id = String(device.deviceID || ""); if (!id || id === syncthing.localDeviceId) continue; options.push({ @@ -284,23 +284,27 @@ function remoteDeviceRows(syncthing) { var rows = []; var devices = syncthing && syncthing.devices ? syncthing.devices : []; var folders = syncthing && syncthing.folders ? syncthing.folders : []; - for (var i = 0; i < devices.length; i++) { - var device = devices[i] || ({}); - var id = String(device.deviceID || ""); + for (let i = 0; i < devices.length; i++) { + let device = devices[i] || ({}); + let id = String(device.deviceID || ""); if (!id || id === syncthing.localDeviceId) continue; - var folderIds = []; - for (var j = 0; j < folders.length; j++) { - var members = folders[j].devices || []; - for (var k = 0; k < members.length; k++) { + let folderIds = []; + let remoteFolderStates = ({}); + for (let j = 0; j < folders.length; j++) { + let members = folders[j].devices || []; + for (let k = 0; k < members.length; k++) { if (String((members[k] || {}).deviceID || "") === id) { folderIds.push(String(folders[j].id || "")); + remoteFolderStates[String(folders[j].id || "")] = + syncthing.statusFresh && device.connected && !device.paused + ? String(members[k].remoteState || "unknown") : "unknown"; break; } } } - var name = String(device.name || "Device " + shortDeviceId(id)); - var count = folderIds.length; + let name = String(device.name || "Device " + shortDeviceId(id)); + let count = folderIds.length; rows.push({ id: id, name: name, @@ -310,6 +314,7 @@ function remoteDeviceRows(syncthing) { downloadBps: Number(device.downloadBps || 0), uploadBps: Number(device.uploadBps || 0), folderIds: folderIds, + remoteFolderStates: remoteFolderStates, label: name + " · " + count + " folder" + (count === 1 ? "" : "s") }); } @@ -319,13 +324,13 @@ function remoteDeviceRows(syncthing) { function pendingDeviceRows(syncthing) { var rows = []; var devices = syncthing && syncthing.pendingDevices ? syncthing.pendingDevices : []; - for (var i = 0; i < devices.length; i++) { - var device = devices[i] || ({}); - var id = String(device.id || ""); + for (let i = 0; i < devices.length; i++) { + let device = devices[i] || ({}); + let id = String(device.id || ""); if (!id) continue; - var shortId = shortDeviceId(id); - var name = String(device.name || ""); + let shortId = shortDeviceId(id); + let name = String(device.name || ""); rows.push({ id: id, name: name, @@ -345,12 +350,12 @@ function nearbyDeviceOptions(syncthing) { } ]; var devices = syncthing && syncthing.nearbyDevices ? syncthing.nearbyDevices : []; - for (var i = 0; i < devices.length; i++) { - var device = devices[i] || ({}); - var id = String(device.id || ""); + for (let i = 0; i < devices.length; i++) { + let device = devices[i] || ({}); + let id = String(device.id || ""); if (!id) continue; - var addresses = device.addresses || []; + let addresses = device.addresses || []; options.push({ value: id, label: shortDeviceId(id) + (addresses.length > 0 ? " · " + String(addresses[0]) : "") @@ -371,13 +376,13 @@ function pendingOfferOptions(syncthing) { var pending = syncthing && syncthing.pendingFolders ? syncthing.pendingFolders : ({}); var ids = Object.keys(pending); var devices = syncthing && syncthing.devices ? syncthing.devices : []; - for (var i = 0; i < ids.length; i++) { - var offeredBy = (pending[ids[i]] || {}).offeredBy || ({}); - var deviceIds = Object.keys(offeredBy); + for (let i = 0; i < ids.length; i++) { + let offeredBy = (pending[ids[i]] || {}).offeredBy || ({}); + let deviceIds = Object.keys(offeredBy); if (hasEncryptedOffer(offeredBy)) continue; - for (var offerIndex = 0; offerIndex < deviceIds.length; offerIndex++) { - var offer = offeredBy[deviceIds[offerIndex]] || ({}); + for (let offerIndex = 0; offerIndex < deviceIds.length; offerIndex++) { + let offer = offeredBy[deviceIds[offerIndex]] || ({}); options.push({ value: JSON.stringify([ids[i], deviceIds[offerIndex]]), label: String(offer.label || ids[i]), @@ -392,8 +397,8 @@ function encryptedPendingOfferCount(syncthing) { var count = 0; var pending = syncthing && syncthing.pendingFolders ? syncthing.pendingFolders : ({}); var ids = Object.keys(pending); - for (var i = 0; i < ids.length; i++) { - var offeredBy = (pending[ids[i]] || {}).offeredBy || ({}); + for (let i = 0; i < ids.length; i++) { + let offeredBy = (pending[ids[i]] || {}).offeredBy || ({}); if (hasEncryptedOffer(offeredBy)) count++; } diff --git a/hosts/omarchy/models/PanelNavigation.js b/hosts/omarchy/models/PanelNavigation.js index 642c12a..22c6a00 100644 --- a/hosts/omarchy/models/PanelNavigation.js +++ b/hosts/omarchy/models/PanelNavigation.js @@ -18,9 +18,9 @@ function isAvailable(item, boundary) { function availableRows(rows, boundary) { var result = []; - for (var row = 0; row < rows.length; row++) { - var available = []; - for (var column = 0; column < rows[row].length; column++) { + for (let row = 0; row < rows.length; row++) { + let available = []; + for (let column = 0; column < rows[row].length; column++) { if (isAvailable(rows[row][column], boundary)) available.push(rows[row][column]); } @@ -42,8 +42,8 @@ function move(rows, current, dx, dy, boundary) { var currentRow = -1; var currentColumn = -1; - for (var row = 0; row < available.length; row++) { - var column = available[row].indexOf(current); + for (let row = 0; row < available.length; row++) { + let column = available[row].indexOf(current); if (column >= 0) { currentRow = row; currentColumn = column; diff --git a/hosts/omarchy/models/SettingsModel.js b/hosts/omarchy/models/SettingsModel.js index 55702d1..fffcfda 100644 --- a/hosts/omarchy/models/SettingsModel.js +++ b/hosts/omarchy/models/SettingsModel.js @@ -10,8 +10,8 @@ var SupportedVersion = 2; function stripComment(line) { var quote = ""; - for (var i = 0; i < line.length; i++) { - var character = line.charAt(i); + for (let i = 0; i < line.length; i++) { + let character = line.charAt(i); if ((character === "\"" || character === "'") && !quote) { quote = character; } else if (character === quote) { @@ -49,11 +49,11 @@ function parse(raw, allowOlder) { var serviceSectionSeen = false; var rootStyleSetting = false; - for (var i = 0; i < lines.length; i++) { - var line = stripComment(lines[i]).trim(); + for (let i = 0; i < lines.length; i++) { + let line = stripComment(lines[i]).trim(); if (!line) continue; - var header = line.match(/^\[([A-Za-z_][A-Za-z0-9_-]*)\]$/); + let header = line.match(/^\[([A-Za-z_][A-Za-z0-9_-]*)\]$/); if (header) { section = header[1]; if (section !== "style" && section !== "service") { @@ -68,11 +68,11 @@ function parse(raw, allowOlder) { serviceSectionSeen = true; continue; } - var assignment = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)$/); + let assignment = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.+)$/); if (!assignment) { return invalid("Invalid settings syntax on line " + (i + 1)); } - var key = assignment[1]; + let key = assignment[1]; if (key === "version" && !section) { if (version !== null) { return invalid("Duplicate setting version on line " + (i + 1)); @@ -83,8 +83,8 @@ function parse(raw, allowOlder) { } continue; } - var styleSetting = key === "icon_style" || key === "web_ui_theme"; - var serviceSetting = key === "service_state" || key === "probe_interval_seconds"; + let styleSetting = key === "icon_style" || key === "web_ui_theme"; + let serviceSetting = key === "service_state" || key === "probe_interval_seconds"; if ((section === "service" && !serviceSetting) || (section !== "service" && !styleSetting)) { return invalid("Unknown setting " + (section ? section + "." : "") + key + " on line " + (i + 1)); } @@ -94,13 +94,13 @@ function parse(raw, allowOlder) { return invalid("Duplicate setting " + key + " on line " + (i + 1)); } if (key === "probe_interval_seconds") { - var interval = parseInteger(assignment[2]); + let interval = parseInteger(assignment[2]); if (interval === null || interval < MinimumProbeIntervalSeconds || interval > MaximumProbeIntervalSeconds) { return invalid("probe_interval_seconds must be an integer between " + MinimumProbeIntervalSeconds + " and " + MaximumProbeIntervalSeconds); } values[key] = interval; } else { - var value = parseValue(assignment[2]); + let value = parseValue(assignment[2]); if (value === null) { return invalid(key + " must use a quoted value"); } @@ -175,12 +175,12 @@ function migrate(raw) { var intervalSeen = false; function addServiceDefaults() { if (!stateSeen) { - var state = 'service_state = "' + previous.serviceState + '"'; + let state = 'service_state = "' + previous.serviceState + '"'; output.push(state); additions.push(state); } if (!intervalSeen) { - var interval = "probe_interval_seconds = " + previous.probeIntervalSeconds; + let interval = "probe_interval_seconds = " + previous.probeIntervalSeconds; output.push(interval); additions.push(interval); } diff --git a/hosts/omarchy/models/ThemePaletteModel.js b/hosts/omarchy/models/ThemePaletteModel.js index 6f7921d..cf0ba36 100644 --- a/hosts/omarchy/models/ThemePaletteModel.js +++ b/hosts/omarchy/models/ThemePaletteModel.js @@ -15,8 +15,8 @@ function defaults() { function parse(text) { var palette = {} var lines = String(text || "").split(/\r?\n/) - for (var i = 0; i < lines.length; i++) { - var fields = lines[i].split("\t") + for (let i = 0; i < lines.length; i++) { + let fields = lines[i].split("\t") if (fields.length !== 2 || ["yellow", "green", "cyan"].indexOf(fields[0]) < 0) continue if (palette[fields[0]] !== undefined diff --git a/hosts/omarchy/ui/AddRemoteDeviceForm.qml b/hosts/omarchy/ui/AddRemoteDeviceForm.qml index 1c4d953..f426e06 100644 --- a/hosts/omarchy/ui/AddRemoteDeviceForm.qml +++ b/hosts/omarchy/ui/AddRemoteDeviceForm.qml @@ -40,7 +40,7 @@ Item { if (id === String(syncthing && syncthing.localDeviceId || "")) return false; var rows = controller.remoteDeviceRows(); - for (var i = 0; i < rows.length; i++) { + for (let i = 0; i < rows.length; i++) { if (String(rows[i].id || "") === id) return false; } diff --git a/hosts/omarchy/ui/ChoiceDialog.qml b/hosts/omarchy/ui/ChoiceDialog.qml index 9dec85a..bf7960b 100644 --- a/hosts/omarchy/ui/ChoiceDialog.qml +++ b/hosts/omarchy/ui/ChoiceDialog.qml @@ -34,8 +34,8 @@ FocusScope { function moveChoice(direction) { if (busy) return; - for (var step = 1; step <= choices.length; step++) { - var index = (selectedChoice + (direction > 0 ? step : -step) + choices.length) % choices.length; + for (let step = 1; step <= choices.length; step++) { + let index = (selectedChoice + (direction > 0 ? step : -step) + choices.length) % choices.length; if (isEnabled(index)) { selectedChoice = index; return; diff --git a/hosts/omarchy/ui/DeviceFoldersForm.qml b/hosts/omarchy/ui/DeviceFoldersForm.qml index 554b7d7..3b39a1d 100644 --- a/hosts/omarchy/ui/DeviceFoldersForm.qml +++ b/hosts/omarchy/ui/DeviceFoldersForm.qml @@ -13,34 +13,74 @@ Item { property var draftFolderIds: [] property color foreground: Commons.Color.foreground property color urgent: Commons.Color.urgent + property var originalFolderIds: [] + property string draftDeviceId: "" required property color warning property string fontFamily: Style.font.family - readonly property bool popupOpen: folderPicker.popupOpen + readonly property bool popupOpen: folderPicker.popupOpen || unconfirmedPicker.popupOpen + readonly property var existingOptions: folderOptions(false) + readonly property var unconfirmedOptions: folderOptions(true) signal canceled signal submissionStarted(bool started) - function folderOptions() { + function folderOptions(unconfirmed) { var options = []; var ids = device ? device.folderIds || [] : []; - for (var i = 0; i < ids.length; i++) { - var folder = controller.folderById(ids[i]); + for (let i = 0; i < ids.length; i++) { + let state = (device.remoteFolderStates || {})[ids[i]] || "unknown"; + let confirmed = state === "valid" || state === "paused"; + if (unconfirmed === confirmed) + continue; + let folder = controller.folderById(ids[i]); options.push({ value: ids[i], - label: String(folder && folder.label || ids[i]) + label: String(folder && folder.label || ids[i]), + description: state === "notSharing" ? "Remote is not sharing back" : !confirmed ? "Remote acceptance unconfirmed" : state === "paused" ? "Remote folder paused" : "" }); } return options; } function reset() { - draftFolderIds = device ? (device.folderIds || []).slice() : []; + draftDeviceId = device ? device.id : ""; + originalFolderIds = device ? (device.folderIds || []).slice() : []; + draftFolderIds = originalFolderIds.slice(); + } + + function reconcileDevice() { + if (!device || device.id !== draftDeviceId) { + reset(); + return; + } + var current = device.folderIds || []; + draftFolderIds = current.filter(function (id) { + return originalFolderIds.indexOf(id) < 0 || draftFolderIds.indexOf(id) >= 0; + }); + originalFolderIds = current.slice(); + } + + function selectedValues(options) { + return options.map(function (option) { + return option.value; + }).filter(function (id) { + return draftFolderIds.indexOf(id) >= 0; + }); + } + + function setGroupValues(options, values) { + var ids = options.map(function (option) { + return option.value; + }); + draftFolderIds = originalFolderIds.filter(function (id) { + return ids.indexOf(id) >= 0 ? values.indexOf(id) >= 0 : draftFolderIds.indexOf(id) >= 0; + }); } function removedFolderIds() { var removed = []; - var original = device ? device.folderIds || [] : []; - for (var i = 0; i < original.length; i++) { + var original = originalFolderIds; + for (let i = 0; i < original.length; i++) { if (draftFolderIds.indexOf(original[i]) < 0) removed.push(original[i]); } @@ -49,9 +89,10 @@ Item { function closePopups() { folderPicker.close(); + unconfirmedPicker.close(); } - onDeviceChanged: reset() + onDeviceChanged: reconcileDevice() onVisibleChanged: if (visible) reset() @@ -75,7 +116,7 @@ Item { } Text { - visible: root.folderOptions().length === 0 + visible: root.originalFolderIds.length === 0 width: parent.width text: "No folders are currently shared with this device." textFormat: Text.PlainText @@ -87,22 +128,74 @@ Item { SyncshellMultiSelect { id: folderPicker - visible: options.length > 0 + visible: root.originalFolderIds.length > 0 + enabled: options.length > 0 && !root.submitting width: parent.width label: "Existing shares" - values: root.draftFolderIds - options: root.folderOptions() - noSelectionText: "No folders selected" + values: root.selectedValues(options) + options: root.existingOptions + noSelectionText: options.length ? "No folders selected" : "No confirmed shares" + placeholderText: "Find a folder..." + foreground: root.foreground + fontFamily: root.fontFamily + onChanged: function (values) { + root.setGroupValues(options, values); + } + onPopupOpenChanged: if (popupOpen) + unconfirmedPicker.close() + } + + Row { + visible: root.originalFolderIds.length > 0 + width: parent.width + spacing: Style.spacing.labelGap + + Text { + id: unconfirmedLabel + text: "Unconfirmed shares" + textFormat: Text.PlainText + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + } + + Text { + id: unconfirmedHint + visible: root.unconfirmedOptions.length > 0 + width: parent.width - unconfirmedLabel.implicitWidth - parent.spacing + text: "(acceptance pending/device offline)" + textFormat: Text.PlainText + color: root.warning + font.family: root.fontFamily + font.pixelSize: Style.font.caption + wrapMode: Text.NoWrap + elide: Text.ElideRight + } + } + + SyncshellMultiSelect { + id: unconfirmedPicker + visible: root.originalFolderIds.length > 0 + enabled: options.length > 0 && !root.submitting + width: parent.width + label: "Unconfirmed shares" + showLabel: false + values: root.selectedValues(options) + options: root.unconfirmedOptions + noSelectionText: options.length ? "No folders selected" : "No unconfirmed shares" placeholderText: "Find a folder..." foreground: root.foreground fontFamily: root.fontFamily onChanged: function (values) { - root.draftFolderIds = values; + root.setGroupValues(options, values); } + onPopupOpenChanged: if (popupOpen) + folderPicker.close() } Text { - visible: root.folderOptions().length > 0 + visible: root.originalFolderIds.length > 0 width: parent.width text: " Use -button (share folder) to restore a folder." textFormat: Text.PlainText @@ -114,7 +207,7 @@ Item { } Button { - visible: root.folderOptions().length > 0 + visible: root.originalFolderIds.length > 0 width: parent.width text: root.submitting ? "SAVING..." : "SAVE SHARING" bordered: true diff --git a/hosts/omarchy/ui/FolderOverview.qml b/hosts/omarchy/ui/FolderOverview.qml index e6abe35..b1c897f 100644 --- a/hosts/omarchy/ui/FolderOverview.qml +++ b/hosts/omarchy/ui/FolderOverview.qml @@ -30,8 +30,8 @@ Column { readonly property bool childPopupOpen: folderSharingForm.popupOpen readonly property var keyboardRows: { var rows = [[folderSelector, addFolderButton, shareFolderButton, linkFolderButton]]; - for (var i = 0; i < folderCards.count; i++) { - var card = folderCards.itemAt(i) as FolderCard; + for (let i = 0; i < folderCards.count; i++) { + let card = folderCards.itemAt(i) as FolderCard; if (card) rows = rows.concat(card.keyboardRows); } diff --git a/hosts/omarchy/ui/FolderSharingForm.qml b/hosts/omarchy/ui/FolderSharingForm.qml index 0cb06e2..440d00f 100644 --- a/hosts/omarchy/ui/FolderSharingForm.qml +++ b/hosts/omarchy/ui/FolderSharingForm.qml @@ -22,8 +22,8 @@ Item { draftFolderId = folder ? String(folder.id || "") : ""; var selected = []; var members = folder && folder.devices ? folder.devices : []; - for (var i = 0; i < members.length; i++) { - var id = String((members[i] || {}).deviceID || ""); + for (let i = 0; i < members.length; i++) { + let id = String((members[i] || {}).deviceID || ""); if (id && id !== syncthing.localDeviceId) selected.push(id); } diff --git a/hosts/omarchy/ui/FolderViewOptions.qml b/hosts/omarchy/ui/FolderViewOptions.qml index d42878a..219d491 100644 --- a/hosts/omarchy/ui/FolderViewOptions.qml +++ b/hosts/omarchy/ui/FolderViewOptions.qml @@ -85,7 +85,7 @@ Item { } function moveKeyboardCursor(dx, dy) { if (dx !== 0 && keyboardIndex < filterRows.length) { - var rowStart = Math.floor(keyboardIndex / 2) * 2; + let rowStart = Math.floor(keyboardIndex / 2) * 2; keyboardIndex = rowStart + ((keyboardIndex - rowStart + (dx > 0 ? 1 : -1) + 2) % 2); } else if (dy > 0) { if (keyboardIndex < filterRows.length - 2) diff --git a/hosts/omarchy/ui/MoreDetails.qml b/hosts/omarchy/ui/MoreDetails.qml index b2d21d2..e92942a 100644 --- a/hosts/omarchy/ui/MoreDetails.qml +++ b/hosts/omarchy/ui/MoreDetails.qml @@ -35,7 +35,7 @@ Column { if (!syncthing) return "Unavailable"; if (syncthing.installationState === "existing") { - var status = flatpakInstallation ? "available" : "executable"; + let status = flatpakInstallation ? "available" : "executable"; return "Existing installation found: " + status + ""; } if (syncthing.installationState === "incomplete") { diff --git a/hosts/omarchy/ui/RemoteDevices.qml b/hosts/omarchy/ui/RemoteDevices.qml index 480e344..2a62fd8 100644 --- a/hosts/omarchy/ui/RemoteDevices.qml +++ b/hosts/omarchy/ui/RemoteDevices.qml @@ -42,7 +42,7 @@ Column { signal actionHovered(Item action) function rowById(rows, id) { - for (var i = 0; i < rows.length; i++) { + for (let i = 0; i < rows.length; i++) { if (String(rows[i].id || "") === String(id || "")) return rows[i]; } @@ -79,8 +79,8 @@ Column { function deviceOptions() { var options = []; - for (var i = 0; i < remoteRows.length; i++) { - var device = remoteRows[i]; + for (let i = 0; i < remoteRows.length; i++) { + let device = remoteRows[i]; options.push({ value: device.id, label: device.name, @@ -94,7 +94,7 @@ Column { function pendingOptions() { var options = []; - for (var i = 0; i < pendingRows.length; i++) + for (let i = 0; i < pendingRows.length; i++) options.push({ value: pendingRows[i].id, label: pendingRows[i].label diff --git a/hosts/omarchy/ui/SideCardMenu.qml b/hosts/omarchy/ui/SideCardMenu.qml index a279f71..6c28dbd 100644 --- a/hosts/omarchy/ui/SideCardMenu.qml +++ b/hosts/omarchy/ui/SideCardMenu.qml @@ -14,7 +14,10 @@ Popup { parent: trigger x: { + // track relative movement so the popup stays current + // qmllint disable confusing-expression-statement positionWatcher.transform; + // qmllint enable confusing-expression-statement return panel.mapToItem(trigger, 0, 0).x - width; } y: 0 diff --git a/hosts/omarchy/ui/SyncshellDropdown.qml b/hosts/omarchy/ui/SyncshellDropdown.qml index 78ad9b0..b4b76d0 100644 --- a/hosts/omarchy/ui/SyncshellDropdown.qml +++ b/hosts/omarchy/ui/SyncshellDropdown.qml @@ -114,14 +114,14 @@ Item { return optionStatusVisible(o) ? String(o.statusHelpText || "") : ""; } function currentLabel() { - for (var i = 0; i < options.length; i++) { + for (let i = 0; i < options.length; i++) { if (optionValue(options[i]) === value) return optionLabel(options[i]); } return value; } function currentTrailingLabel() { - for (var i = 0; i < options.length; i++) { + for (let i = 0; i < options.length; i++) { if (optionValue(options[i]) === value) return optionTrailingText(options[i]); } @@ -132,7 +132,7 @@ Item { var text = label.toLowerCase(); var needle = query.trim().toLowerCase(); var position = 0; - for (var i = 0; i < needle.length; i++) { + for (let i = 0; i < needle.length; i++) { position = text.indexOf(needle[i], position); if (position < 0) return false; @@ -399,7 +399,7 @@ Item { currentIndex: -1 function indexOfValue(v) { - for (var i = 0; i < root.matchingOptions.length; i++) + for (let i = 0; i < root.matchingOptions.length; i++) if (root.optionValue(root.matchingOptions[i]) === v) return i; return -1; diff --git a/hosts/omarchy/ui/SyncshellMultiSelect.qml b/hosts/omarchy/ui/SyncshellMultiSelect.qml index f931223..358ee45 100644 --- a/hosts/omarchy/ui/SyncshellMultiSelect.qml +++ b/hosts/omarchy/ui/SyncshellMultiSelect.qml @@ -73,7 +73,7 @@ Item { if (!v || typeof v.length !== "number" || typeof v === "string") return []; var out = []; - for (var i = 0; i < v.length; i++) + for (let i = 0; i < v.length; i++) out.push(v[i]); return out; } @@ -82,8 +82,8 @@ Item { var out = []; var src = arrayFrom(arr); var seen = ({}); - for (var i = 0; i < src.length; i++) { - var n = normalizeOption(src[i]); + for (let i = 0; i < src.length; i++) { + let n = normalizeOption(src[i]); if (!n.value) continue; if (seen[n.value]) @@ -97,7 +97,7 @@ Item { function valueSet() { var set = ({}); var arr = arrayFrom(values); - for (var i = 0; i < arr.length; i++) + for (let i = 0; i < arr.length; i++) set[String(arr[i])] = true; return set; } @@ -124,9 +124,9 @@ Item { return ""; var labels = []; var byValue = ({}); - for (var i = 0; i < resolvedOptions.length; i++) + for (let i = 0; i < resolvedOptions.length; i++) byValue[resolvedOptions[i].value] = resolvedOptions[i].label; - for (var j = 0; j < arr.length; j++) + for (let j = 0; j < arr.length; j++) labels.push(byValue[String(arr[j])] || String(arr[j])); if (labels.length <= 3) return labels.join(", "); @@ -141,8 +141,8 @@ Item { return; } var out = []; - for (var i = 0; i < resolvedOptions.length; i++) { - var o = resolvedOptions[i]; + for (let i = 0; i < resolvedOptions.length; i++) { + let o = resolvedOptions[i]; if (o.label.toLowerCase().indexOf(q) !== -1 || o.description.toLowerCase().indexOf(q) !== -1 || o.value.toLowerCase().indexOf(q) !== -1) out.push(o); } diff --git a/integrations/caelestia/files/SyncshellPopout.qml.in b/integrations/caelestia/files/SyncshellPopout.qml.in index f1ea738..acdeda6 100644 --- a/integrations/caelestia/files/SyncshellPopout.qml.in +++ b/integrations/caelestia/files/SyncshellPopout.qml.in @@ -4,12 +4,22 @@ import QtQuick import QtQuick.Layouts import Caelestia.Config import qs.components +import qs.components as Components import qs.components.controls +import qs.components.controls as Controls import qs.services import "../../../syncshell/shared" ColumnLayout { id: root + + component StyledText: Components.StyledText { + textFormat: Text.PlainText + } + + component TextButton: Controls.TextButton { + label.textFormat: Text.PlainText + } implicitWidth: 420 spacing: Tokens.spacing.medium property bool moreOpen: false diff --git a/integrations/dankmaterialshell/SyncshellWidget.qml b/integrations/dankmaterialshell/SyncshellWidget.qml index f924f75..3be98d4 100644 --- a/integrations/dankmaterialshell/SyncshellWidget.qml +++ b/integrations/dankmaterialshell/SyncshellWidget.qml @@ -2,12 +2,17 @@ import QtQuick import QtQuick.Layouts import qs.Common import qs.Widgets +import qs.Widgets as Widgets import qs.Modules.Plugins import "shared" PluginComponent { id: root + component StyledText: Widgets.StyledText { + textFormat: Text.PlainText + } + layerNamespacePlugin: "syncshell" popoutWidth: 480 popoutHeight: 680 diff --git a/integrations/dankmaterialshell/plugin.json b/integrations/dankmaterialshell/plugin.json index 809e878..c34d5f1 100644 --- a/integrations/dankmaterialshell/plugin.json +++ b/integrations/dankmaterialshell/plugin.json @@ -2,7 +2,7 @@ "id": "syncshell", "name": "Syncshell", "description": "Monitor Syncthing and safely manage synchronized folders", - "version": "0.2.2", + "version": "0.2.4", "author": "ilyaZar", "type": "composite", "capabilities": ["daemon", "dankbar-widget", "monitoring"], diff --git a/integrations/illogical-impulse/files/SyncshellPopup.qml.in b/integrations/illogical-impulse/files/SyncshellPopup.qml.in index a7265d4..c0d7bb5 100644 --- a/integrations/illogical-impulse/files/SyncshellPopup.qml.in +++ b/integrations/illogical-impulse/files/SyncshellPopup.qml.in @@ -5,6 +5,7 @@ import QtQuick.Effects import QtQuick.Layouts import qs.modules.common import qs.modules.common.widgets +import qs.modules.common.widgets as Widgets import qs.services import Quickshell import Quickshell.Hyprland @@ -13,6 +14,20 @@ import "../../../syncshell/shared" LazyLoader { id: root + + // Host styling must not interpret peer-controlled text as markup. + component StyledText: Widgets.StyledText { + textFormat: Text.PlainText + } + + component DialogButton: Widgets.DialogButton { + id: button + Binding { + target: button.contentItem + property: "textFormat" + value: Text.PlainText + } + } property bool pinned: false property bool moreOpen: false property bool addOpen: false diff --git a/integrations/waybar/shell.qml b/integrations/waybar/shell.qml index 5b0d06b..0046cc5 100644 --- a/integrations/waybar/shell.qml +++ b/integrations/waybar/shell.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls +import QtQuick.Controls as Controls import QtQuick.Layouts import Quickshell import Quickshell.Hyprland @@ -12,6 +13,37 @@ import "shared" ShellRoot { id: root + // Peer names, paths and errors are data, never display markup. + component Label: Controls.Label { + textFormat: Text.PlainText + } + + component Button: Controls.Button { + id: button + contentItem: Text { + text: button.text + textFormat: Text.PlainText + font: button.font + color: button.palette.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + component CheckBox: Controls.CheckBox { + id: checkbox + contentItem: Text { + text: checkbox.text + textFormat: Text.PlainText + font: checkbox.font + color: checkbox.palette.windowText + verticalAlignment: Text.AlignVCenter + leftPadding: checkbox.indicator && !checkbox.mirrored ? checkbox.indicator.width + checkbox.spacing : 0 + rightPadding: checkbox.indicator && checkbox.mirrored ? checkbox.indicator.width + checkbox.spacing : 0 + } + } + property bool popupOpen: false property bool moreOpen: false property bool addOpen: false diff --git a/manifest.json b/manifest.json index 2a01087..9e4da3b 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "io.github.ilyazar.syncthing", "name": "Syncthing", - "version": "0.2.3", + "version": "0.2.4", "author": "ilyaZar", "license": "MIT AND MPL-2.0 AND OFL-1.1 AND BSD-3-Clause", "description": "An elegant Syncthing interface with intuitive multi-folder and multi-device management. Includes a redesigned web UI with optional Omarchy theme integration, while retaining support for Syncthing’s original Web UI. Monitor file activity and easily manage folder sharing across machines. Review pending device and folder-sharing invitations. The screenshot shows the modern webUI under the Nord theme (left) and the plugin with open folder addition view (right).", diff --git a/packaging/bundled/SHA256SUMS b/packaging/bundled/SHA256SUMS index bc2a40d..c351f94 100644 --- a/packaging/bundled/SHA256SUMS +++ b/packaging/bundled/SHA256SUMS @@ -1,3 +1,3 @@ ff1a30e27af91678877df6ae65e6e9a3bded047b6ac1efd484bd94f5daf1f7c8 bin/syncshell-core -bd0b193ecddc1fcc826ed198757853ad03c91a3e3594edabaa26d17bd11df7ba bin/x86_64/syncshell-core -a39aa3d251154c68014e937067e53e7fce71c4eb168969384bd49ddb95befb70 bin/aarch64/syncshell-core +b472a55f7edc422c56fd964f27b8668a0c8b7a462f79349a96076aa10fa1e9d5 bin/x86_64/syncshell-core +080201dc245258103ca283ce2562b6205bf743943ffeaca4056a233cad90292d bin/aarch64/syncshell-core diff --git a/shared/AdapterService.qml b/shared/AdapterService.qml index b6816fd..7551e49 100644 --- a/shared/AdapterService.qml +++ b/shared/AdapterService.qml @@ -72,7 +72,7 @@ QtObject { function device(deviceId) { var wanted = String(deviceId || ""); - for (var index = 0; index < devices.length; index++) { + for (let index = 0; index < devices.length; index++) { if (String(devices[index].id || "") === wanted) return devices[index]; } @@ -86,8 +86,8 @@ QtObject { function remoteDevices() { var result = []; - for (var index = 0; index < devices.length; index++) { - var value = devices[index] || ({}); + for (let index = 0; index < devices.length; index++) { + let value = devices[index] || ({}); if (String(value.id || "") !== "" && String(value.id || "") !== localDeviceId) result.push(value); } @@ -97,7 +97,7 @@ QtObject { function shareableDevices() { var result = []; var remote = remoteDevices(); - for (var index = 0; index < remote.length; index++) { + for (let index = 0; index < remote.length; index++) { if (remote[index].untrusted !== true) result.push(remote[index]); } @@ -108,8 +108,8 @@ QtObject { var value = folder(folderId); var members = value && value.devices ? value.devices : []; var result = []; - for (var index = 0; index < members.length; index++) { - var id = String((members[index] || {}).id || ""); + for (let index = 0; index < members.length; index++) { + let id = String((members[index] || {}).id || ""); if (id !== "" && id !== localDeviceId) result.push(id); } @@ -119,9 +119,9 @@ QtObject { function folderIdsForDevice(deviceId) { var wanted = String(deviceId || ""); var result = []; - for (var folderIndex = 0; folderIndex < folders.length; folderIndex++) { - var members = folders[folderIndex].devices || []; - for (var memberIndex = 0; memberIndex < members.length; memberIndex++) { + for (let folderIndex = 0; folderIndex < folders.length; folderIndex++) { + let members = folders[folderIndex].devices || []; + for (let memberIndex = 0; memberIndex < members.length; memberIndex++) { if (String((members[memberIndex] || {}).id || "") === wanted) { result.push(String(folders[folderIndex].id || "")); break; @@ -134,13 +134,13 @@ QtObject { function pendingFolderOffers() { var result = []; var folderIds = Object.keys(pendingFolders).sort(); - for (var folderIndex = 0; folderIndex < folderIds.length; folderIndex++) { - var folderId = folderIds[folderIndex]; - var offeredBy = (pendingFolders[folderId] || {}).offeredBy || ({}); - var deviceIds = Object.keys(offeredBy).sort(); - for (var deviceIndex = 0; deviceIndex < deviceIds.length; deviceIndex++) { - var deviceId = deviceIds[deviceIndex]; - var offer = offeredBy[deviceId] || ({}); + for (let folderIndex = 0; folderIndex < folderIds.length; folderIndex++) { + let folderId = folderIds[folderIndex]; + let offeredBy = (pendingFolders[folderId] || {}).offeredBy || ({}); + let deviceIds = Object.keys(offeredBy).sort(); + for (let deviceIndex = 0; deviceIndex < deviceIds.length; deviceIndex++) { + let deviceId = deviceIds[deviceIndex]; + let offer = offeredBy[deviceId] || ({}); result.push({ folderId: folderId, deviceId: deviceId, @@ -154,7 +154,7 @@ QtObject { function folder(folderId) { var wanted = String(folderId || ""); - for (var index = 0; index < folders.length; index++) { + for (let index = 0; index < folders.length; index++) { if (String(folders[index].id || "") === wanted) return folders[index]; } @@ -236,7 +236,7 @@ QtObject { function scanningFolderIds() { var result = []; - for (var index = 0; index < folders.length; index++) { + for (let index = 0; index < folders.length; index++) { if (scanning(folders[index].id)) result.push(String(folders[index].id)); } diff --git a/shared/CoreProcess.qml b/shared/CoreProcess.qml index f1e6f79..c30492e 100644 --- a/shared/CoreProcess.qml +++ b/shared/CoreProcess.qml @@ -92,7 +92,7 @@ QtObject { id: id }; var keys = Object.keys(values || ({})); - for (var index = 0; index < keys.length; index++) { + for (let index = 0; index < keys.length; index++) { message[keys[index]] = values[keys[index]]; } var encoded = JSON.stringify(message); @@ -202,9 +202,9 @@ QtObject { code: "core_unavailable", message: String(message || "Native core became unavailable") }; - for (var index = 0; index < ids.length; index++) { - var id = ids[index]; - var callback = pending[id]; + for (let index = 0; index < ids.length; index++) { + let id = ids[index]; + let callback = pending[id]; if (callback) callback(false, null, failure); resultReceived(id, false, null, failure); diff --git a/shared/DeviceWorkflow.qml b/shared/DeviceWorkflow.qml index 201db0f..a6623de 100644 --- a/shared/DeviceWorkflow.qml +++ b/shared/DeviceWorkflow.qml @@ -55,7 +55,7 @@ QtObject { function removedFolderIds() { var original = service.folderIdsForDevice(targetDeviceId); var removed = []; - for (var index = 0; index < original.length; index++) { + for (let index = 0; index < original.length; index++) { if (draftIds.indexOf(original[index]) < 0) removed.push(original[index]); } diff --git a/shared/RescanTracker.qml b/shared/RescanTracker.qml index 5873f39..4ae678c 100644 --- a/shared/RescanTracker.qml +++ b/shared/RescanTracker.qml @@ -12,7 +12,7 @@ QtObject { if (!Array.isArray(values)) return false; var previous = ""; - for (var index = 0; index < values.length; index++) { + for (let index = 0; index < values.length; index++) { if (typeof values[index] !== "string" || values[index] === "" || index > 0 && values[index] <= previous) return false; previous = values[index]; @@ -24,9 +24,9 @@ QtObject { if (!data || typeof data !== "object" || data.state !== "completed" && data.state !== "running" || !validIds(data.targetFolderIds) || !validIds(data.runningFolderIds)) return false; var targets = ({}); - for (var index = 0; index < data.targetFolderIds.length; index++) + for (let index = 0; index < data.targetFolderIds.length; index++) targets[data.targetFolderIds[index]] = true; - for (var runningIndex = 0; runningIndex < data.runningFolderIds.length; runningIndex++) { + for (let runningIndex = 0; runningIndex < data.runningFolderIds.length; runningIndex++) { if (!targets[data.runningFolderIds[runningIndex]]) return false; } @@ -49,9 +49,9 @@ QtObject { return; var scanning = ({}); var values = Array.isArray(scanningFolderIds) ? scanningFolderIds : []; - for (var index = 0; index < values.length; index++) + for (let index = 0; index < values.length; index++) scanning[String(values[index])] = true; - for (var runningIndex = 0; runningIndex < _runningFolderIds.length; runningIndex++) { + for (let runningIndex = 0; runningIndex < _runningFolderIds.length; runningIndex++) { if (scanning[_runningFolderIds[runningIndex]]) return; } diff --git a/tests/device-sharing.test.mjs b/tests/device-sharing.test.mjs new file mode 100644 index 0000000..38516f7 --- /dev/null +++ b/tests/device-sharing.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const paths = vm.createContext({}); +vm.runInContext(readFileSync(new URL("../shared/Paths.js", import.meta.url), "utf8") + .replace(/^\.pragma library\n/, ""), paths); + +function model(name) { + const context = vm.createContext({ Paths: paths }); + vm.runInContext(readFileSync(new URL( + `../hosts/omarchy/models/${name}.js`, import.meta.url), "utf8") + .replace(/^\.pragma library\n/, "").replace(/^\.import .*\n/gm, ""), context); + return context; +} + +const facade = model("FacadeModel"); +const panel = model("PanelModel"); +const source = readFileSync(new URL( + "../hosts/omarchy/ui/DeviceFoldersForm.qml", import.meta.url), "utf8"); + +function form(device) { + const context = vm.createContext({ + device, draftDeviceId: "", originalFolderIds: [], draftFolderIds: [], + controller: { folderById: id => ({ label: id }) }, + }); + context.root = context; + for (const method of source.matchAll(/^ function \w+\([^]*?^ }/gm)) + vm.runInContext(method[0], context); + context.reset(); + return context; +} + +function remote(states, { fresh = true, connected = true, paused = false } = {}) { + const folders = Object.entries(states).map(([id, state]) => ({ + id, label: id, devices: [{ id: "OPTIPLEX", remoteState: state }], + })); + return panel.remoteDeviceRows({ + localDeviceId: "XPS", statusFresh: fresh, + folders: facade.folders(folders), + devices: facade.devices([{ id: "OPTIPLEX", name: "optiplex", connected, paused }]), + })[0]; +} + +function ids(options) { + return Array.from(options, option => option.value); +} + +test("43 established shares and one unknown share remain 44 configured shares", () => { + const states = Object.fromEntries(Array.from({ length: 43 }, (_, i) => [`folder-${i}`, "valid"])); + states.useful = "unknown"; + const device = remote(states); + const edit = form(device); + assert.equal(device.folderIds.length, 44); + assert.equal(edit.folderOptions(false).length, 43); + assert.deepEqual(ids(edit.folderOptions(true)), ["useful"]); + assert.equal(edit.selectedValues(edit.folderOptions(false)).length, 43); + assert.deepEqual(Array.from(edit.selectedValues(edit.folderOptions(true))), ["useful"]); + assert.equal(edit.removedFolderIds().length, 0); +}); + +test("remote paused is confirmed; notSharing, missing and unknown are unconfirmed", () => { + const edit = form(remote({ ready: "valid", stopped: "paused", offered: "notSharing", + useful: "unknown", missing: undefined, unexpected: "future-state" })); + assert.deepEqual(ids(edit.folderOptions(false)), ["ready", "stopped"]); + assert.deepEqual(ids(edit.folderOptions(true)), ["offered", "useful", "missing", "unexpected"]); + assert.match(edit.folderOptions(true)[0].description, /not sharing back/); + assert.match(edit.folderOptions(true)[1].description, /unconfirmed/); +}); + +test("stale, disconnected and device-paused snapshots cannot claim confirmation", () => { + for (const status of [{ fresh: false }, { connected: false }, { paused: true }]) { + const edit = form(remote({ established: "valid", stopped: "paused" }, status)); + assert.equal(edit.folderOptions(false).length, 0); + assert.deepEqual(ids(edit.folderOptions(true)), ["established", "stopped"]); + } +}); + +test("each dropdown edits only its group without unsharing the other group", () => { + const edit = form(remote({ established: "valid", useful: "unknown" })); + edit.setGroupValues(edit.folderOptions(true), []); + assert.deepEqual(Array.from(edit.removedFolderIds()), ["useful"]); + edit.setGroupValues(edit.folderOptions(false), []); + assert.deepEqual(Array.from(edit.removedFolderIds()), ["established", "useful"]); + edit.setGroupValues(edit.folderOptions(true), ["useful"]); + assert.deepEqual(Array.from(edit.removedFolderIds()), ["established"]); +}); + +test("acceptance during editing moves a share without resetting its checkbox", () => { + const edit = form(remote({ established: "valid", useful: "unknown" })); + edit.setGroupValues(edit.folderOptions(true), []); + edit.device = remote({ established: "valid", useful: "valid" }); + edit.reconcileDevice(); + assert.equal(edit.folderOptions(true).length, 0); + assert.deepEqual(Array.from(edit.selectedValues(edit.folderOptions(false))), ["established"]); + assert.deepEqual(Array.from(edit.removedFolderIds()), ["useful"]); + edit.device = remote({ established: "valid", useful: "valid" }, { connected: false }); + edit.reconcileDevice(); + assert.deepEqual(Array.from(edit.selectedValues(edit.folderOptions(true))), ["established"]); + assert.deepEqual(Array.from(edit.removedFolderIds()), ["useful"]); +}); + +test("refresh preserves edits and cannot remove newly added shares", () => { + const edit = form(remote({ established: "valid", useful: "unknown" })); + edit.setGroupValues(edit.folderOptions(true), []); + edit.device = remote({ established: "valid", useful: "unknown", new: "valid" }); + edit.reconcileDevice(); + assert.deepEqual(Array.from(edit.draftFolderIds), ["established", "new"]); + assert.deepEqual(Array.from(edit.removedFolderIds()), ["useful"]); + edit.device = remote({ established: "valid", new: "valid" }); + edit.reconcileDevice(); + assert.equal(edit.removedFolderIds().length, 0); +}); + +test("switching devices resets the draft and closing closes both popups", () => { + const edit = form(remote({ useful: "unknown" })); + edit.setGroupValues(edit.folderOptions(true), []); + edit.device = { id: "CHRONOS", folderIds: ["other"], remoteFolderStates: {} }; + edit.reconcileDevice(); + assert.deepEqual(Array.from(edit.draftFolderIds), ["other"]); + let closed = 0; + edit.folderPicker = { close() { closed++; } }; + edit.unconfirmedPicker = { close() { closed++; } }; + edit.closePopups(); + assert.equal(closed, 2); + assert.match(source, /onDeviceChanged: reconcileDevice\(\)/); + assert.match(source, /label: "Existing shares"/); + assert.match(source, /label: "Unconfirmed shares"/); + assert.equal((source.match(/root\.setGroupValues\(options, values\)/g) || []).length, 2); +}); + +test("unconfirmed hint is yellow, inline and elided to the available width", () => { + const hint = source.match(/id: unconfirmedHint([^]*?)\n\s*}/)[1]; + assert.match(hint, /color: root.warning/); + assert.match(hint, /wrapMode: Text.NoWrap/); + assert.match(hint, /elide: Text.ElideRight/); + assert.match(hint, /text: "\(acceptance pending\/device offline\)"/); + assert.doesNotMatch(source, /Acceptance is not confirmed; the remote may be offline/); +}); diff --git a/tests/folder-overview.qml b/tests/folder-overview.qml index 7c74729..19cc41c 100644 --- a/tests/folder-overview.qml +++ b/tests/folder-overview.qml @@ -58,7 +58,7 @@ ShellRoot { function visit(item) { if (item.folder !== undefined && item.stateLabel !== undefined) result.push(item); - for (var i = 0; i < item.children.length; i++) + for (let i = 0; i < item.children.length; i++) visit(item.children[i]); } visit(overview); @@ -69,8 +69,8 @@ ShellRoot { function visit(item) { if (item.options !== undefined) return item; - for (var i = 0; i < item.children.length; i++) { - var found = visit(item.children[i]); + for (let i = 0; i < item.children.length; i++) { + let found = visit(item.children[i]); if (found) return found; } @@ -84,7 +84,7 @@ ShellRoot { function folders(count) { var configured = []; - for (var i = 0; i < count; i++) { + for (let i = 0; i < count; i++) { configured.push({ id: "folder-" + i, path: "/tmp/folder-" + i, @@ -151,7 +151,7 @@ ShellRoot { } function optionByValue(options, value) { - for (var i = 0; i < options.length; i++) { + for (let i = 0; i < options.length; i++) { if (options[i].value === value) return options[i]; } @@ -212,7 +212,7 @@ ShellRoot { root.rows = root.viewRows(false); break; case 8: - var initial = panel.folderViewOptions(); + let initial = panel.folderViewOptions(); root.check(shown.length === 1 && picker.visible, "view rows use selector"); root.check(initial.length === 4, "default view includes every folder"); root.check(!picker.statusVisible, "selector trigger omits the redundant folder status"); diff --git a/tests/plugin-ui.test.sh b/tests/plugin-ui.test.sh index 5281e1f..0ca96e4 100755 --- a/tests/plugin-ui.test.sh +++ b/tests/plugin-ui.test.sh @@ -7,7 +7,7 @@ cd -- "$root" # Models run in Qt, not a JavaScript surrogate with different import semantics. QT_QPA_PLATFORM=offscreen QT_FORCE_STDERR_LOGGING=1 \ /usr/lib/qt6/bin/qml tests/run.qml -node --test tests/folder-creation.test.mjs tests/bar-click.test.mjs +node --test tests/folder-creation.test.mjs tests/bar-click.test.mjs tests/device-sharing.test.mjs # Run serially: popup focus tests share the desktop and are timing sensitive. for name in architecture plugin-acceptance-lint \ diff --git a/tests/self-removal.qml b/tests/self-removal.qml index b8da5c5..cc321e3 100644 --- a/tests/self-removal.qml +++ b/tests/self-removal.qml @@ -33,7 +33,7 @@ ShellRoot { function displaysError(item) { if (item.visible && item.text === service.settingsError) return true; - for (var i = 0; i < item.children.length; i++) { + for (let i = 0; i < item.children.length; i++) { if (displaysError(item.children[i])) return true; }