diff --git a/.github/release-request.json b/.github/release-request.json index bd14a30..7b76eee 100644 --- a/.github/release-request.json +++ b/.github/release-request.json @@ -1 +1 @@ -{"tag":"v2.0.15","verification":"Use non-blocking visible-window caption discovery and five packaged GUI test runs before release."} +{"tag":"v2.0.16","verification":"Name/Size/Date sorting in both directions and both panes; five packaged GUI runs and downloaded release-binary tests."} diff --git a/.github/workflows/request-release.yml b/.github/workflows/request-release.yml index 8cdba21..6f53f1e 100644 --- a/.github/workflows/request-release.yml +++ b/.github/workflows/request-release.yml @@ -94,7 +94,7 @@ jobs: - name: Test the downloaded release through native GUI controls and real SSH shell: pwsh run: | - go test ./e2e -run '^TestWindowsGUIHashes$' -count=1 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log + go test ./e2e -run '^TestWindowsGUI(Hashes|Sorting)$' -count=1 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - uses: actions/upload-artifact@v4 if: always() diff --git a/.github/workflows/windows-gui.yml b/.github/workflows/windows-gui.yml index a908761..9585544 100644 --- a/.github/workflows/windows-gui.yml +++ b/.github/workflows/windows-gui.yml @@ -72,7 +72,7 @@ jobs: - name: Test the packaged executable over real SSH and native Windows controls (five runs) shell: pwsh run: | - go test ./e2e -run '^TestWindowsGUIHashes$' -count=5 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log + go test ./e2e -run '^TestWindowsGUI(Hashes|Sorting)$' -count=5 -v -timeout 5m 2>&1 | Tee-Object validation/gui-test.log if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Upload binaries and validation evidence diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a18b7..b282cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog / 更新日志 +## v2.0.16 (2026-09-21) + +### Features / 新功能 + +- **Name / Size / Date sorting** in both Windows file panes, with an **Asc / Desc** button. Names default to A-Z, sizes to largest first, and modification dates to newest first / 两侧文件列表支持名称、大小、修改日期排序及升降序切换。 +- Preserve selected filenames when sorting, including when sorting the other pane. Sort key and direction survive refresh and navigation; folders stay first and unknown metadata stays last / 排序保留所选文件,刷新及切换目录保留排序方式;文件夹优先,未知信息排在末尾。 +- Serialize listing updates with sort changes on the UI thread so a background refresh cannot restore a stale order / 列表更新与排序在 UI 线程统一处理,避免后台刷新覆盖新的排序方式。 + +### Verification / 验证 + +- Portable tests for all keys/directions, numeric metadata, unknown/zero/large values, deterministic ties and selection remapping. +- Native packaged-executable tests for both panes, selection/hash preservation, refresh and navigation, alongside the existing hash regression tests. +- The existing CI-gated release pipeline verifies downloaded Windows and Linux amd64 binaries. + +--- + ## v2.0.15 (2026-09-17) ### Features / 新功能 diff --git a/docs/file-sorting.md b/docs/file-sorting.md new file mode 100644 index 0000000..fed2065 --- /dev/null +++ b/docs/file-sorting.md @@ -0,0 +1,28 @@ +# Sorting in the Windows transfer GUI + +The Local and Remote panes each have a **Sort** dropdown with **Name**, **Size** +and **Date**, plus an **Asc / Desc** button that reverses the current order. + +| Sort key | Default order | Reverse order | +| --- | --- | --- | +| Name | A to Z (case-insensitive) | Z to A | +| Size | Largest first | Smallest first | +| Date | Newest modification date first | Oldest first | + +Folders stay above files in either direction. Sizes use actual byte counts and +dates use modification timestamps, not the formatted text in the list. Unknown +metadata sorts last within its folder/file group. Equal sizes or dates use a +deterministic alphabetical tie-break. + +Each pane keeps its own sort key and direction during refreshes and navigation. +Changing the sort preserves the same selected files/folders by name, even when +their list indexes move. Sorting the other pane does not clear your selection. +The Hash, MV, Del and transfer actions therefore still target the selected names. +A directory reload clears selections, as before; sort choices last for the GUI +session, not across application restarts. + +Portable tests cover every key/direction, large and unknown values, ties and +selection remapping. Native Windows tests drive the packaged executable and the +published release against temporary local files and a loopback SSH server, +checking both panes, selection/hash preservation, refresh and navigation. The +existing hash tests remain enabled. diff --git a/e2e/wingui_sort_windows_test.go b/e2e/wingui_sort_windows_test.go new file mode 100644 index 0000000..62b6193 --- /dev/null +++ b/e2e/wingui_sort_windows_test.go @@ -0,0 +1,240 @@ +//go:build windows + +package e2e_test + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + "time" + "unsafe" +) + +// Exercise the independently packaged/released executable, using the same +// native Windows driver and real loopback SSH fixture as the hash tests. +func TestWindowsGUISorting(t *testing.T) { + binary := os.Getenv("FLYSSH_GUI_BINARY") + if binary == "" { + t.Skip("set FLYSSH_GUI_BINARY to an extracted Windows release executable") + } + binary, err := filepath.Abs(binary) + if err != nil { + t.Fatal(err) + } + bash := filepath.Join(os.Getenv("ProgramFiles"), "Git", "bin", "bash.exe") + if _, err := os.Stat(bash); err != nil { + t.Fatal(err) + } + localDir, remoteDir, home := t.TempDir(), t.TempDir(), t.TempDir() + names := []string{"a.txt", "B 'quote' 数据.bin", "c.txt"} + sizes := []int{2, 1024 * 1024, 100} + times := []int64{1700000300, 1700000100, 1700000200} + for _, dir := range []string{localDir, remoteDir} { + if err := os.Mkdir(filepath.Join(dir, "folder"), 0700); err != nil { + t.Fatal(err) + } + for i, name := range names { + file := filepath.Join(dir, name) + if err := os.WriteFile(file, make([]byte, sizes[i]), 0600); err != nil { + t.Fatal(err) + } + when := time.Unix(times[i], 0) + if err := os.Chtimes(file, when, when); err != nil { + t.Fatal(err) + } + } + } + addr := startHashSSHFixture(t, bash, remoteDir) + var output hashLockedBuffer + cmd := exec.Command(binary, "gui:fixture-password@"+addr, "--wingui", "--no-reconnect") + cmd.Dir = localDir + cmd.Env = hashTestEnv(os.Environ(), map[string]string{"HOME": home, "USERPROFILE": home}) + cmd.Stdout, cmd.Stderr = &output, &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + u := newHashUIDriver(t, uint32(cmd.Process.Pid)) + var main uintptr + t.Cleanup(func() { + if dir := os.Getenv("FLYSSH_GUI_TEST_ARTIFACTS"); dir != "" { + _ = os.MkdirAll(dir, 0755) + _ = os.WriteFile(filepath.Join(dir, "sort-console.log"), []byte(output.String()), 0600) + } + // Always reap the process, even after a failed UI assertion. + _ = cmd.Process.Kill() + <-done + if t.Failed() { + t.Logf("executable output:\n%s", output.String()) + } + }) + u.wait("main sorting window", func() bool { main = u.window("FlySSH Transfer"); return main != 0 }) + var lists, combos, directions, hashes, refresh []uintptr + u.wait("sorting controls and listings ready", func() bool { + lists = u.controls(main, "ListBox", "") + combos = u.controls(main, "ComboBox", "") + directions = u.controls(main, "Button", "Asc") + hashes = u.controls(main, "Button", "Hash") + refresh = u.controls(main, "Button", "Refresh") + return len(lists) == 2 && len(combos) == 2 && len(directions) == 2 && len(hashes) == 2 && len(refresh) == 2 && + u.send(lists[0], 0x018B, 0, 0) == 4 && u.send(lists[1], 0x018B, 0, 0) == 4 && strings.Contains(output.String(), "ready") + }) + for _, combo := range combos { + if got := sortComboItems(u, combo); !reflect.DeepEqual(got, []string{"Name", "Size", "Date"}) { + t.Fatalf("sort options: %v", got) + } + } + + for pane := range lists { + t.Run([]string{"local", "remote"}[pane], func(t *testing.T) { + parentTest := u.t + u.t = t + defer func() { u.t = parentTest }() + // Real selections must follow names, not old numeric indexes. + u.selectItems(lists[pane], names[0], names[2]) + u.wait("hash enabled for selected sort targets", func() bool { return u.enabled(hashes[pane]) }) + chooseSortKey(u, combos[pane], 1) // Change away from Name before testing its default. + otherBefore := sortListNames(u, lists[1-pane]) + for _, key := range []struct { + index int + label string + initial, reverse []string + initialDirection string + }{ + {0, "Name", []string{"folder/", names[0], names[1], names[2]}, []string{"folder/", names[2], names[1], names[0]}, "Asc"}, + {1, "Size", []string{"folder/", names[1], names[2], names[0]}, []string{"folder/", names[0], names[2], names[1]}, "Desc"}, + {2, "Date", []string{"folder/", names[0], names[2], names[1]}, []string{"folder/", names[1], names[2], names[0]}, "Desc"}, + } { + chooseSortKey(u, combos[pane], key.index) + u.wait(key.label+" default order", func() bool { + return reflect.DeepEqual(sortListNames(u, lists[pane]), key.initial) && u.text(directions[pane]) == key.initialDirection + }) + assertSortSelection(t, u, lists[pane], names[0], names[2]) + u.click(directions[pane]) + u.wait(key.label+" reversed order", func() bool { + return reflect.DeepEqual(sortListNames(u, lists[pane]), key.reverse) && u.text(directions[pane]) != key.initialDirection + }) + assertSortSelection(t, u, lists[pane], names[0], names[2]) + if !u.enabled(hashes[pane]) || u.enabled(hashes[1-pane]) { + t.Fatal("sorting changed selection-dependent button state") + } + if got := sortListNames(u, lists[1-pane]); !reflect.DeepEqual(got, otherBefore) { + t.Fatalf("sorting affected the other pane: %v", got) + } + } + + // Sorting the inactive pane must not clear the active selection. + u.click(directions[1-pane]) + u.wait("inactive pane order changed", func() bool { return !reflect.DeepEqual(sortListNames(u, lists[1-pane]), otherBefore) }) + assertSortSelection(t, u, lists[pane], names[0], names[2]) + if !u.enabled(hashes[pane]) { + t.Fatal("inactive sort cleared the active selection") + } + + // Hash after a reorder, without selecting again: verify the selected + // paths, not the files now occupying their original indexes. + u.click(u.button(main, "Clear")) + u.wait("sort log cleared", func() bool { return u.logText(main) == "" }) + u.click(hashes[pane]) + u.click(u.button(u.waitDialog(), "Calculate")) + u.wait("hashing preserved selections", func() bool { + return strings.Contains(u.logText(main), "hash complete: 2 file(s), sha256") && u.enabled(hashes[pane]) + }) + log := u.logText(main) + for _, i := range []int{0, 2} { + if !strings.Contains(log, hashFixtureDigest("sha256", make([]byte, sizes[i]))+" ") { + t.Fatalf("missing selected checksum after sorting: %s", log) + } + } + if strings.Contains(log, hashFixtureDigest("sha256", make([]byte, sizes[1]))+" ") { + t.Fatal("hash used an obsolete selection index") + } + + // Refresh must retain this pane's Date/ascending order. Adding an + // actual file provides a completion signal rather than a sleep. + dir := []string{localDir, remoteDir}[pane] + added := filepath.Join(dir, "new.txt") + if err := os.WriteFile(added, []byte("new"), 0600); err != nil { + t.Fatal(err) + } + stamp := time.Unix(1700000400, 0) + if err := os.Chtimes(added, stamp, stamp); err != nil { + t.Fatal(err) + } + u.click(refresh[pane]) + want := []string{"folder/", names[1], names[2], names[0], "new.txt"} + u.wait("refresh retains Date/ascending", func() bool { return reflect.DeepEqual(sortListNames(u, lists[pane]), want) }) + if u.text(directions[pane]) != "Asc" || u.send(combos[pane], 0x0147, 0, 0) != 2 { + t.Fatal("refresh reset sort controls") + } + // Enter an empty child folder then go Up. Sorting survives navigation. + u.selectItems(lists[pane], "folder/") + u.send(lists[pane], 0x019E, 0, 0) // LB_SETCARETINDEX for MultiSelection ListBox + notifySortControl(u, lists[pane], 2) // LBN_DBLCLK -> OnItemActivated + u.wait("empty child folder", func() bool { return reflect.DeepEqual(sortListNames(u, lists[pane]), []string{"(empty)"}) }) + u.click(u.controls(main, "Button", "Up")[pane]) + u.wait("navigation retains Date/ascending", func() bool { return reflect.DeepEqual(sortListNames(u, lists[pane]), want) }) + if pane == 1 { + hashScreenshot(t, "name-size-date-sorting.png") + } + }) + } + t.Log("Published/packaged executable: Name/Size/Date, both directions, numeric metadata, folders first, pane independence, selection/hash preservation, refresh and navigation passed") +} + +func notifySortControl(u *hashUIDriver, hwnd uintptr, notification uintptr) { + parent, _, _ := hashUser32.NewProc("GetParent").Call(hwnd) + id, _, _ := hashUser32.NewProc("GetDlgCtrlID").Call(hwnd) + u.send(parent, 0x0111, (id&0xffff)|(notification<<16), hwnd) +} + +func chooseSortKey(u *hashUIDriver, combo uintptr, index int) { + u.send(combo, 0x014E, uintptr(index), 0) // CB_SETCURSEL + // CB_SETCURSEL does not emit CBN_SELCHANGE; simulate the user's event. + notifySortControl(u, combo, 1) +} + +func sortComboItems(u *hashUIDriver, combo uintptr) []string { + var items []string + for i, count := uintptr(0), u.send(combo, 0x0146, 0, 0); i < count; i++ { + n := u.send(combo, 0x0149, i, 0) + buf := make([]uint16, n+1) + u.send(combo, 0x0148, i, uintptr(unsafe.Pointer(&buf[0]))) + items = append(items, syscall.UTF16ToString(buf)) + } + return items +} + +func sortListNames(u *hashUIDriver, list uintptr) []string { + var names []string + for i, count := uintptr(0), u.send(list, 0x018B, 0, 0); i < count; i++ { + n := u.send(list, 0x018A, i, 0) + buf := make([]uint16, n+1) + u.send(list, 0x0189, i, uintptr(unsafe.Pointer(&buf[0]))) + name, _, _ := strings.Cut(syscall.UTF16ToString(buf), " ") + names = append(names, name) + } + return names +} + +func assertSortSelection(t *testing.T, u *hashUIDriver, list uintptr, selected ...string) { + t.Helper() + want := make(map[string]bool) + for _, name := range selected { + want[name] = true + } + got := make(map[string]bool) + for i, name := range sortListNames(u, list) { + if u.send(list, 0x0187, uintptr(i), 0) == 1 { + got[name] = true + } // LB_GETSEL + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("selected %v, want %v", got, want) + } +} diff --git a/pkg/wingui/sorting.go b/pkg/wingui/sorting.go new file mode 100644 index 0000000..cf11bfd --- /dev/null +++ b/pkg/wingui/sorting.go @@ -0,0 +1,132 @@ +package wingui + +import ( + "sort" + "strings" +) + +type sortMode string + +const ( + sortByName sortMode = "name" + sortByTime sortMode = "time" // Modification date, labelled Date in the GUI. + sortBySize sortMode = "size" +) + +type fileEntry struct { + Name string + IsDir bool + Size int64 + MTime int64 + Mode string + User string + Group string + Display string +} + +func sortModeLabels() []string { return []string{"Name", "Size", "Date"} } + +func sortModeFromIndex(index int) sortMode { + switch index { + case 1: + return sortBySize + case 2: + return sortByTime + default: + return sortByName + } +} + +func defaultSortDescending(mode sortMode) bool { + return mode == sortBySize || mode == sortByTime +} + +func sortDirectionText(descending bool) string { + if descending { + return "Desc" + } + return "Asc" +} + +func sortDirectionToolTip(mode sortMode, descending bool) string { + order := "A to Z" + switch mode { + case sortBySize: + order = "smallest first" + if descending { + order = "largest first" + } + case sortByTime: + order = "oldest first" + if descending { + order = "newest first" + } + default: + if descending { + order = "Z to A" + } + } + return "Sort " + order + "; click to reverse. Folders stay first." +} + +func sortEntries(entries []fileEntry, mode sortMode) { + sortEntriesWithDirection(entries, mode, defaultSortDescending(mode)) +} + +// Compare the underlying byte counts and modification timestamps, not their +// formatted display text. Keep folders first and unknown metadata last in +// either direction, with a deterministic alphabetical tie-break. +func sortEntriesWithDirection(entries []fileEntry, mode sortMode, descending bool) { + sort.SliceStable(entries, func(i, j int) bool { + left, right := entries[i], entries[j] + if left.IsDir != right.IsDir { + return left.IsDir + } + switch mode { + case sortBySize: + if less, decided := sortValueUnknownLast(left.Size, right.Size, descending); decided { + return less + } + case sortByTime: + if less, decided := sortValueUnknownLast(left.MTime, right.MTime, descending); decided { + return less + } + } + order := strings.Compare(strings.ToLower(left.Name), strings.ToLower(right.Name)) + if order == 0 { + order = strings.Compare(left.Name, right.Name) + } + if mode != sortBySize && mode != sortByTime && descending { + return order > 0 + } + return order < 0 + }) +} + +func sortValueUnknownLast(left, right int64, descending bool) (less, decided bool) { + if left < 0 || right < 0 { + if (left < 0) != (right < 0) { + return left >= 0, true + } + return false, false + } + if left == right { + return false, false + } + if descending { + return left > right, true + } + return left < right, true +} + +// Restore selection by filename AND kind after replacing a sorted list model; +// indexes from the previous order must never select different files. +func sortedSelectionIndexes(entries []fileEntry, files, dirs map[string]bool) []int { + var indexes []int + for i, entry := range entries { + if (entry.IsDir && dirs[entry.Name]) || (!entry.IsDir && files[entry.Name]) { + indexes = append(indexes, i) + } + } + return indexes +} diff --git a/pkg/wingui/sorting_test.go b/pkg/wingui/sorting_test.go new file mode 100644 index 0000000..052fe21 --- /dev/null +++ b/pkg/wingui/sorting_test.go @@ -0,0 +1,126 @@ +package wingui + +import ( + "math" + "reflect" + "testing" +) + +func TestSortChoicesAndDefaults(t *testing.T) { + if got := sortModeLabels(); !reflect.DeepEqual(got, []string{"Name", "Size", "Date"}) { + t.Fatalf("sort choices: %v", got) + } + for i, want := range []sortMode{sortByName, sortBySize, sortByTime} { + if got := sortModeFromIndex(i); got != want { + t.Fatalf("index %d: %s, want %s", i, got, want) + } + if got := defaultSortDescending(want); got != (i != 0) { + t.Fatalf("default direction for %s: %v", want, got) + } + } + for _, index := range []int{-1, 3, 99} { + if sortModeFromIndex(index) != sortByName { + t.Fatal("invalid index must fall back to Name") + } + } + if sortDirectionText(false) != "Asc" || sortDirectionText(true) != "Desc" { + t.Fatal("unexpected direction labels") + } + for _, mode := range []sortMode{sortByName, sortBySize, sortByTime} { + if sortDirectionToolTip(mode, false) == sortDirectionToolTip(mode, true) { + t.Fatalf("tooltip must describe direction for %s", mode) + } + } +} + +func TestSortEveryKeyAndDirection(t *testing.T) { + original := []fileEntry{ + {Name: "a.txt", Size: 2, MTime: 300, Display: "z"}, + {Name: "z-folder", IsDir: true, Size: 1, MTime: 10}, + {Name: "B.txt", Size: 1024 * 1024, MTime: 100, Display: "a"}, + {Name: "c.txt", Size: 100, MTime: 200}, + {Name: "a-folder", IsDir: true, Size: 20, MTime: 20}, + } + for _, tt := range []struct { + mode sortMode + desc bool + want []string + }{ + {sortByName, false, []string{"a-folder", "z-folder", "a.txt", "B.txt", "c.txt"}}, + {sortByName, true, []string{"z-folder", "a-folder", "c.txt", "B.txt", "a.txt"}}, + {sortBySize, false, []string{"z-folder", "a-folder", "a.txt", "c.txt", "B.txt"}}, + {sortBySize, true, []string{"a-folder", "z-folder", "B.txt", "c.txt", "a.txt"}}, + {sortByTime, false, []string{"z-folder", "a-folder", "B.txt", "c.txt", "a.txt"}}, + {sortByTime, true, []string{"a-folder", "z-folder", "a.txt", "c.txt", "B.txt"}}, + } { + t.Run(string(tt.mode)+sortDirectionText(tt.desc), func(t *testing.T) { + items := append([]fileEntry(nil), original...) + sortEntriesWithDirection(items, tt.mode, tt.desc) + assertSortedNames(t, items, tt.want) + sortEntriesWithDirection(items, tt.mode, tt.desc) + assertSortedNames(t, items, tt.want) + }) + } +} + +func TestSortUnknownAndZeroValues(t *testing.T) { + for _, mode := range []sortMode{sortBySize, sortByTime} { + for _, desc := range []bool{false, true} { + items := []fileEntry{ + {Name: "unknown", Size: -1, MTime: -1}, + {Name: "zero", Size: 0, MTime: 0}, + {Name: "max", Size: math.MaxInt64, MTime: math.MaxInt64}, + {Name: "unknown-folder", IsDir: true, Size: -1, MTime: -1}, + {Name: "zero-folder", IsDir: true}, + } + want := []string{"zero-folder", "unknown-folder", "zero", "max", "unknown"} + if desc { + want[2], want[3] = want[3], want[2] + } + sortEntriesWithDirection(items, mode, desc) + assertSortedNames(t, items, want) + } + } +} + +func TestSortTiesAreDeterministic(t *testing.T) { + for _, mode := range []sortMode{sortByName, sortBySize, sortByTime} { + for _, desc := range []bool{false, true} { + items := []fileEntry{{Name: "a"}, {Name: "A"}, {Name: "b"}, {Name: "B"}} + want := []string{"A", "a", "B", "b"} + if mode == sortByName && desc { + want = []string{"b", "B", "a", "A"} + } + sortEntriesWithDirection(items, mode, desc) + assertSortedNames(t, items, want) + } + } +} + +func TestSortedSelectionTracksNamesNotOldIndexes(t *testing.T) { + items := []fileEntry{{Name: "z.txt"}, {Name: "a.txt"}, {Name: "folder", IsDir: true}} + files, dirs := map[string]bool{"z.txt": true}, map[string]bool{"folder": true} + sortEntriesWithDirection(items, sortByName, false) + if got := sortedSelectionIndexes(items, files, dirs); !reflect.DeepEqual(got, []int{0, 2}) { + t.Fatalf("selection after sorting: %v", got) + } + // Deleted names and a name that changed kind must not select another item. + if got := sortedSelectionIndexes(items, map[string]bool{"missing": true, "folder": true}, nil); len(got) != 0 { + t.Fatalf("restored absent or wrong-kind selection: %v", got) + } + sortEntriesWithDirection(nil, sortByName, true) + if got := sortedSelectionIndexes(nil, files, dirs); len(got) != 0 { + t.Fatalf("empty selection: %v", got) + } +} + +func assertSortedNames(t *testing.T, items []fileEntry, want []string) { + t.Helper() + got := make([]string, len(items)) + for i, item := range items { + got[i] = item.Name + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/pkg/wingui/sorting_windows.go b/pkg/wingui/sorting_windows.go new file mode 100644 index 0000000..8d14b78 --- /dev/null +++ b/pkg/wingui/sorting_windows.go @@ -0,0 +1,99 @@ +//go:build windows + +package wingui + +func (a *app) localSortChanged() { + if a.localSort != nil { + mode := sortModeFromIndex(a.localSort.CurrentIndex()) + a.changePaneSort(sideLocal, mode, defaultSortDescending(mode)) + } +} + +func (a *app) remoteSortChanged() { + if a.remoteSort != nil { + mode := sortModeFromIndex(a.remoteSort.CurrentIndex()) + a.changePaneSort(sideRemote, mode, defaultSortDescending(mode)) + } +} + +func (a *app) reversePaneSort(which side) { + a.mu.Lock() + mode, descending := a.localSortMode, a.localSortDescending + if which == sideRemote { + mode, descending = a.remoteSortMode, a.remoteSortDescending + } + a.mu.Unlock() + a.changePaneSort(which, mode, !descending) +} + +func (a *app) changePaneSort(which side, mode sortMode, descending bool) { + // Initialization callbacks can precede the list widgets. The initial sort + // is already Name/ascending; defer user changes to the UI thread so list + // data and native controls cannot temporarily disagree during a reload. + if a.localLB == nil || a.remoteLB == nil { + return + } + a.ui(func() { + a.mu.Lock() + items, list, orderButton := a.localItems, a.localLB, a.localSortDirection + if which == sideRemote { + a.remoteSortMode, a.remoteSortDescending = mode, descending + items, list, orderButton = a.remoteItems, a.remoteLB, a.remoteSortDirection + } else { + a.localSortMode, a.localSortDescending = mode, descending + } + items = append([]fileEntry(nil), items...) + sortEntriesWithDirection(items, mode, descending) + if which == sideRemote { + a.remoteItems = items + } else { + a.localItems = items + } + var indexes []int + if a.selection.Side == which { + indexes = sortedSelectionIndexes(items, a.selection.Files, a.selection.Dirs) + } + a.suppressSelection = true + a.mu.Unlock() + + // SetModel emits selection events; suppress them while restoring the + // same names. Sorting the inactive pane leaves the other pane alone. + _ = list.SetModel(entryDisplays(items)) + list.SetSelectedIndexes(indexes) + _ = orderButton.SetText(sortDirectionText(descending)) + _ = orderButton.SetToolTipText(sortDirectionToolTip(mode, descending)) + a.mu.Lock() + a.suppressSelection = false + a.mu.Unlock() + a.setButtons() + }) +} + +// Publish listings on the same UI queue as sort changes. In particular, a +// background remote refresh must use the latest sort settings when displayed, +// not a stale ordering captured before the user changed the dropdown. +func (a *app) replacePaneItems(which side, dir string, entries []fileEntry) { + a.ui(func() { + a.mu.Lock() + list, pathEdit := a.localLB, a.localPath + if which == sideRemote { + sortEntriesWithDirection(entries, a.remoteSortMode, a.remoteSortDescending) + a.remoteItems = entries + list, pathEdit = a.remoteLB, a.remotePath + } else { + sortEntriesWithDirection(entries, a.localSortMode, a.localSortDescending) + a.localItems = entries + } + a.selection = newSelectionState() + a.suppressSelection = true + a.mu.Unlock() + _ = pathEdit.SetText(dir) + _ = list.SetModel(entryDisplays(entries)) + a.localLB.SetSelectedIndexes(nil) + a.remoteLB.SetSelectedIndexes(nil) + a.mu.Lock() + a.suppressSelection = false + a.mu.Unlock() + a.setButtons() + }) +} diff --git a/pkg/wingui/wingui_windows.go b/pkg/wingui/wingui_windows.go index 57dac9a..6b93bc6 100644 --- a/pkg/wingui/wingui_windows.go +++ b/pkg/wingui/wingui_windows.go @@ -35,14 +35,6 @@ const ( sideRemote side = "remote" ) -type sortMode string - -const ( - sortByName sortMode = "name" - sortByTime sortMode = "time" - sortBySize sortMode = "size" -) - const ( buttonHeight = 44 transferButtonHeight = 56 @@ -72,17 +64,6 @@ func listFont() Font { return Font{Family: "Consolas", PointSize: 10} } -type fileEntry struct { - Name string - IsDir bool - Size int64 - MTime int64 - Mode string - User string - Group string - Display string -} - type navState struct { Current string Back []string @@ -225,45 +206,49 @@ type app struct { rawArgs []string exe string - mw *walk.MainWindow - status *walk.LineEdit - summary *walk.LineEdit - localPath *walk.LineEdit - remotePath *walk.LineEdit - localLB *walk.ListBox - remoteLB *walk.ListBox - localSort *walk.ComboBox - remoteSort *walk.ComboBox - scpButton *walk.PushButton - rsyncButton *walk.PushButton - localNewDir *walk.PushButton - remoteNewDir *walk.PushButton - localHash *walk.PushButton - remoteHash *walk.PushButton - localRename *walk.PushButton - remoteRename *walk.PushButton - localDelete *walk.PushButton - remoteDelete *walk.PushButton - puttyButton *walk.PushButton - xshellButton *walk.PushButton - secureButton *walk.PushButton - log *walk.TextEdit - - mu sync.Mutex - localNav navState - remoteNav navState - localItems []fileEntry - remoteItems []fileEntry - localSortMode sortMode - remoteSortMode sortMode - selection selectionState - busy bool - rsyncAvailable bool - current *childProcess - hashCancel context.CancelFunc - shellClients map[shellClientKind]string - shellGateway *activeShellGateway - suppressSelection bool + mw *walk.MainWindow + status *walk.LineEdit + summary *walk.LineEdit + localPath *walk.LineEdit + remotePath *walk.LineEdit + localLB *walk.ListBox + remoteLB *walk.ListBox + localSort *walk.ComboBox + remoteSort *walk.ComboBox + localSortDirection *walk.PushButton + remoteSortDirection *walk.PushButton + scpButton *walk.PushButton + rsyncButton *walk.PushButton + localNewDir *walk.PushButton + remoteNewDir *walk.PushButton + localHash *walk.PushButton + remoteHash *walk.PushButton + localRename *walk.PushButton + remoteRename *walk.PushButton + localDelete *walk.PushButton + remoteDelete *walk.PushButton + puttyButton *walk.PushButton + xshellButton *walk.PushButton + secureButton *walk.PushButton + log *walk.TextEdit + + mu sync.Mutex + localNav navState + remoteNav navState + localItems []fileEntry + remoteItems []fileEntry + localSortMode sortMode + remoteSortMode sortMode + localSortDescending bool + remoteSortDescending bool + selection selectionState + busy bool + rsyncAvailable bool + current *childProcess + hashCancel context.CancelFunc + shellClients map[shellClientKind]string + shellGateway *activeShellGateway + suppressSelection bool } func Run(opts *cli.Options, rawArgs []string) error { @@ -314,7 +299,8 @@ func (a *app) run() error { PushButton{Text: "Up", Font: buttonFont(), MinSize: Size{Width: 52, Height: buttonHeight}, MaxSize: Size{Width: 52}, OnClicked: a.localUp}, PushButton{Text: "Refresh", Font: buttonFont(), MinSize: Size{Width: 82, Height: buttonHeight}, MaxSize: Size{Width: 82}, OnClicked: a.refreshLocal}, Label{Text: "Sort"}, - ComboBox{AssignTo: &a.localSort, Model: sortModeLabels(), CurrentIndex: 0, MaxSize: Size{Width: 92}, OnCurrentIndexChanged: a.localSortChanged}, + ComboBox{AssignTo: &a.localSort, Model: sortModeLabels(), CurrentIndex: 0, ToolTipText: "Sort by name, file size or modification date", MinSize: Size{Width: 78}, MaxSize: Size{Width: 78}, OnCurrentIndexChanged: a.localSortChanged}, + PushButton{AssignTo: &a.localSortDirection, Text: "Asc", ToolTipText: sortDirectionToolTip(sortByName, false), MinSize: Size{Width: 52, Height: buttonHeight}, MaxSize: Size{Width: 52}, OnClicked: func() { a.reversePaneSort(sideLocal) }}, }}, Composite{Layout: HBox{MarginsZero: true, Spacing: 4}, Children: []Widget{ LineEdit{AssignTo: &a.localPath, StretchFactor: 1, OnKeyDown: func(key walk.Key) { @@ -346,7 +332,8 @@ func (a *app) run() error { PushButton{Text: "Up", Font: buttonFont(), MinSize: Size{Width: 52, Height: buttonHeight}, MaxSize: Size{Width: 52}, OnClicked: a.remoteUp}, PushButton{Text: "Refresh", Font: buttonFont(), MinSize: Size{Width: 82, Height: buttonHeight}, MaxSize: Size{Width: 82}, OnClicked: a.refreshRemote}, Label{Text: "Sort"}, - ComboBox{AssignTo: &a.remoteSort, Model: sortModeLabels(), CurrentIndex: 0, MaxSize: Size{Width: 92}, OnCurrentIndexChanged: a.remoteSortChanged}, + ComboBox{AssignTo: &a.remoteSort, Model: sortModeLabels(), CurrentIndex: 0, ToolTipText: "Sort by name, file size or modification date", MinSize: Size{Width: 78}, MaxSize: Size{Width: 78}, OnCurrentIndexChanged: a.remoteSortChanged}, + PushButton{AssignTo: &a.remoteSortDirection, Text: "Asc", ToolTipText: sortDirectionToolTip(sortByName, false), MinSize: Size{Width: 52, Height: buttonHeight}, MaxSize: Size{Width: 52}, OnClicked: func() { a.reversePaneSort(sideRemote) }}, }}, Composite{Layout: HBox{MarginsZero: true, Spacing: 4}, Children: []Widget{ LineEdit{AssignTo: &a.remotePath, StretchFactor: 1, OnKeyDown: func(key walk.Key) { @@ -433,18 +420,7 @@ func (a *app) refreshLocal() { a.setStatus("local list failed: " + err.Error()) return } - a.mu.Lock() - sortEntries(items, a.localSortMode) - a.localItems = items - a.selection = newSelectionState() - a.mu.Unlock() - a.ui(func() { - a.localPath.SetText(dir) - a.localLB.SetModel(entryDisplays(items)) - a.localLB.SetSelectedIndexes(nil) - a.remoteLB.SetSelectedIndexes(nil) - }) - a.setButtons() + a.replacePaneItems(sideLocal, dir, items) } func (a *app) refreshRemote() { @@ -483,18 +459,7 @@ func (a *app) loadRemoteUnderOperation(dir string) error { Display: formatEntryDisplay(item.Name, item.IsDir, item.Size, item.MTime, item.Mode, item.User, item.Group), }) } - a.mu.Lock() - sortEntries(entries, a.remoteSortMode) - a.remoteItems = entries - a.selection = newSelectionState() - a.mu.Unlock() - a.ui(func() { - a.remotePath.SetText(dir) - a.remoteLB.SetModel(entryDisplays(entries)) - a.localLB.SetSelectedIndexes(nil) - a.remoteLB.SetSelectedIndexes(nil) - }) - a.setButtons() + a.replacePaneItems(sideRemote, dir, entries) return nil } @@ -765,54 +730,6 @@ func isASCIIAlpha(b byte) bool { return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') } -func (a *app) localSortChanged() { - if a.localSort == nil { - return - } - mode := sortModeFromIndex(a.localSort.CurrentIndex()) - a.mu.Lock() - a.localSortMode = mode - if a.localLB == nil || a.remoteLB == nil { - a.mu.Unlock() - return - } - items := append([]fileEntry(nil), a.localItems...) - sortEntries(items, mode) - a.localItems = items - a.selection = newSelectionState() - a.mu.Unlock() - a.ui(func() { - a.localLB.SetModel(entryDisplays(items)) - a.localLB.SetSelectedIndexes(nil) - a.remoteLB.SetSelectedIndexes(nil) - }) - a.setButtons() -} - -func (a *app) remoteSortChanged() { - if a.remoteSort == nil { - return - } - mode := sortModeFromIndex(a.remoteSort.CurrentIndex()) - a.mu.Lock() - a.remoteSortMode = mode - if a.localLB == nil || a.remoteLB == nil { - a.mu.Unlock() - return - } - items := append([]fileEntry(nil), a.remoteItems...) - sortEntries(items, mode) - a.remoteItems = items - a.selection = newSelectionState() - a.mu.Unlock() - a.ui(func() { - a.remoteLB.SetModel(entryDisplays(items)) - a.localLB.SetSelectedIndexes(nil) - a.remoteLB.SetSelectedIndexes(nil) - }) - a.setButtons() -} - func (a *app) startTransfer(protocol string) { a.mu.Lock() selection := a.selection @@ -1931,55 +1848,6 @@ func listLocal(dir string) ([]fileEntry, error) { return result, nil } -func sortEntries(entries []fileEntry, mode sortMode) { - sort.SliceStable(entries, func(i, j int) bool { - if entries[i].IsDir != entries[j].IsDir { - return entries[i].IsDir - } - switch mode { - case sortByTime: - if less, ok := sortValueDescUnknownLast(entries[i].MTime, entries[j].MTime); ok { - return less - } - case sortBySize: - if less, ok := sortValueDescUnknownLast(entries[i].Size, entries[j].Size); ok { - return less - } - } - return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) - }) -} - -func sortValueDescUnknownLast(left, right int64) (bool, bool) { - leftUnknown := left < 0 - rightUnknown := right < 0 - if leftUnknown || rightUnknown { - if leftUnknown != rightUnknown { - return !leftUnknown, true - } - return false, false - } - if left != right { - return left > right, true - } - return false, false -} - -func sortModeLabels() []string { - return []string{"Name", "Time", "Size"} -} - -func sortModeFromIndex(index int) sortMode { - switch index { - case 1: - return sortByTime - case 2: - return sortBySize - default: - return sortByName - } -} - func validateLocalDir(dir string) error { info, err := os.Stat(dir) if err != nil {