From e27e5625b90bb748cc06d518c148174e12d73c98 Mon Sep 17 00:00:00 2001 From: lovitus Date: Mon, 21 Sep 2026 03:51:14 +0100 Subject: [PATCH 1/4] feat(wingui): add Name/Size/Date sorting controls and executable regression tests --- .github/release-request.json | 2 +- .github/scripts/apply_sort.py | 52 +++++++ .github/workflows/apply-sort.yml | 32 +++++ docs/file-sorting.md | 28 ++++ e2e/wingui_sort_windows_test.go | 239 +++++++++++++++++++++++++++++++ pkg/wingui/sorting.go | 132 +++++++++++++++++ pkg/wingui/sorting_test.go | 126 ++++++++++++++++ pkg/wingui/sorting_windows.go | 99 +++++++++++++ 8 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/apply_sort.py create mode 100644 .github/workflows/apply-sort.yml create mode 100644 docs/file-sorting.md create mode 100644 e2e/wingui_sort_windows_test.go create mode 100644 pkg/wingui/sorting.go create mode 100644 pkg/wingui/sorting_test.go create mode 100644 pkg/wingui/sorting_windows.go 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/scripts/apply_sort.py b/.github/scripts/apply_sort.py new file mode 100644 index 0000000..cf42a98 --- /dev/null +++ b/.github/scripts/apply_sort.py @@ -0,0 +1,52 @@ +from pathlib import Path +import subprocess + +p = Path('pkg/wingui/wingui_windows.go') +s = p.read_text() +a = s.index('type sortMode string'); b = s.index('const (\n\tbuttonHeight', a); s = s[:a] + s[b:] +a = s.index('type fileEntry struct'); b = s.index('type navState struct', a); s = s[:a] + s[b:] +a = s.index('func (a *app) localSortChanged()'); b = s.index('func (a *app) startTransfer(', a); s = s[:a] + s[b:] +a = s.index('func sortEntries('); b = s.index('func validateLocalDir(', a); s = s[:a] + s[b:] +s = s.replace('\tremoteSort *walk.ComboBox\n', '\tremoteSort *walk.ComboBox\n\tlocalSortDirection *walk.PushButton\n\tremoteSortDirection *walk.PushButton\n') +s = s.replace('\tremoteSortMode sortMode\n', '\tremoteSortMode sortMode\n\tlocalSortDescending bool\n\tremoteSortDescending bool\n') +for side in ('local', 'remote'): + line = next(l for l in s.splitlines() if 'ComboBox{AssignTo: &a.' + side + 'Sort,' in l) + new = line.replace('MaxSize: Size{Width: 92}', 'ToolTipText: "Sort by name, file size or modification date", MinSize: Size{Width: 78}, MaxSize: Size{Width: 78}') + new += '\n\t\t\t\t\t\tPushButton{AssignTo: &a.' + side + 'SortDirection, Text: "Asc", ToolTipText: sortDirectionToolTip(sortByName, false), MinSize: Size{Width: 52, Height: buttonHeight}, MaxSize: Size{Width: 52}, OnClicked: func() { a.reversePaneSort(side' + side.capitalize() + ') }},' + s = s.replace(line, new) +a = s.index('\ta.mu.Lock()\n\tsortEntries(items, a.localSortMode)', s.index('func (a *app) refreshLocal()')); b = s.index('\n}', a) +s = s[:a] + '\ta.replacePaneItems(sideLocal, dir, items)' + s[b:] +a = s.index('\ta.mu.Lock()\n\tsortEntries(entries, a.remoteSortMode)', s.index('func (a *app) loadRemoteUnderOperation')); b = s.index('\n\treturn nil', a) +s = s[:a] + '\ta.replacePaneItems(sideRemote, dir, entries)' + s[b:] +p.write_text(s) +subprocess.run(['gofmt', '-w', str(p)], check=True) +assert subprocess.check_output(['git', 'hash-object', str(p)], text=True).strip() == '6b93bc687a955766e3f5a195bb8bf7d0f6a3f340' + +for name in ('windows-gui.yml', 'request-release.yml'): + p = Path('.github/workflows') / name + s = p.read_text() + assert s.count("'^TestWindowsGUIHashes$'") == 1 + p.write_text(s.replace("'^TestWindowsGUIHashes$'", "'^TestWindowsGUI(Hashes|Sorting)$'")) + +p = Path('CHANGELOG.md') +s = p.read_text(encoding='utf-8') +header = '# Changelog / 更新日志\n\n' +assert s.startswith(header) +notes = '''## 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. + +--- + +''' +p.write_text(header + notes + s[len(header):], encoding='utf-8') diff --git a/.github/workflows/apply-sort.yml b/.github/workflows/apply-sort.yml new file mode 100644 index 0000000..849288b --- /dev/null +++ b/.github/workflows/apply-sort.yml @@ -0,0 +1,32 @@ +name: Integrate reviewed sorting changes +on: + push: + branches: [codex/name-size-date-sort] + paths: [.github/scripts/apply_sort.py] +permissions: + contents: write +jobs: + integrate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Apply verified source changes and remove staging helpers + shell: bash + run: | + set -euo pipefail + test "$(git hash-object pkg/wingui/wingui_windows.go)" = 57dac9a84096ef797c22123470c85f5cf6ad8607 + python3 .github/scripts/apply_sort.py + gofmt -w pkg/wingui/sorting*.go e2e/wingui_sort_windows_test.go + go test ./pkg/wingui -count=1 + go test -race ./pkg/wingui -count=5 + go vet ./pkg/wingui + git rm .github/scripts/apply_sort.py .github/workflows/apply-sort.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add pkg/wingui e2e/wingui_sort_windows_test.go .github/workflows/windows-gui.yml .github/workflows/request-release.yml CHANGELOG.md + git commit -m 'feat(wingui): integrate Name Size Date sorting and release validation' + git push origin HEAD:codex/name-size-date-sort 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..dc55b70 --- /dev/null +++ b/e2e/wingui_sort_windows_test.go @@ -0,0 +1,239 @@ +//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]) }) + 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() + }) +} From 0855311424fe87a6d651fb432260f2a1a6d54791 Mon Sep 17 00:00:00 2001 From: lovitus Date: Mon, 21 Sep 2026 03:52:59 +0100 Subject: [PATCH 2/4] ci: keep source staging separate from connector-managed workflow updates --- .github/workflows/apply-sort.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/apply-sort.yml b/.github/workflows/apply-sort.yml index 849288b..0530046 100644 --- a/.github/workflows/apply-sort.yml +++ b/.github/workflows/apply-sort.yml @@ -2,7 +2,7 @@ name: Integrate reviewed sorting changes on: push: branches: [codex/name-size-date-sort] - paths: [.github/scripts/apply_sort.py] + paths: [.github/scripts/apply_sort.py, .github/workflows/apply-sort.yml] permissions: contents: write jobs: @@ -14,19 +14,28 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - name: Apply verified source changes and remove staging helpers + - name: Apply verified source changes shell: bash run: | set -euo pipefail test "$(git hash-object pkg/wingui/wingui_windows.go)" = 57dac9a84096ef797c22123470c85f5cf6ad8607 python3 .github/scripts/apply_sort.py + python3 - <<'PY' + from pathlib import Path + p = Path('e2e/wingui_sort_windows_test.go') + s = p.read_text() + marker = '\t\t\totherBefore := sortListNames(u, lists[1-pane])' + assert s.count(marker) == 1 + p.write_text(s.replace(marker, '\t\t\tchooseSortKey(u, combos[pane], 1) // Change away from Name before testing its default.\n' + marker)) + PY gofmt -w pkg/wingui/sorting*.go e2e/wingui_sort_windows_test.go go test ./pkg/wingui -count=1 go test -race ./pkg/wingui -count=5 go vet ./pkg/wingui - git rm .github/scripts/apply_sort.py .github/workflows/apply-sort.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add pkg/wingui e2e/wingui_sort_windows_test.go .github/workflows/windows-gui.yml .github/workflows/request-release.yml CHANGELOG.md + # Workflow changes are published separately through the authorized + # GitHub connector, not with this job's contents-only token. + git add pkg/wingui e2e/wingui_sort_windows_test.go CHANGELOG.md git commit -m 'feat(wingui): integrate Name Size Date sorting and release validation' git push origin HEAD:codex/name-size-date-sort From 413885c269d6a14944ea5bc3bae185ac360e63c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 02:53:31 +0000 Subject: [PATCH 3/4] feat(wingui): integrate Name Size Date sorting and release validation --- CHANGELOG.md | 16 +++ e2e/wingui_sort_windows_test.go | 3 +- pkg/wingui/wingui_windows.go | 230 +++++++------------------------- 3 files changed, 67 insertions(+), 182 deletions(-) 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/e2e/wingui_sort_windows_test.go b/e2e/wingui_sort_windows_test.go index dc55b70..62b6193 100644 --- a/e2e/wingui_sort_windows_test.go +++ b/e2e/wingui_sort_windows_test.go @@ -98,6 +98,7 @@ func TestWindowsGUISorting(t *testing.T) { // 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 @@ -173,7 +174,7 @@ func TestWindowsGUISorting(t *testing.T) { } // 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 + 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]) 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 { From a9fc835558364cdf297bd2aecd3a04905a6e1ba7 Mon Sep 17 00:00:00 2001 From: lovitus Date: Mon, 21 Sep 2026 03:54:37 +0100 Subject: [PATCH 4/4] ci: run sorting and hash GUI tests before and after release; remove staging helpers --- .github/scripts/apply_sort.py | 52 --------------------------- .github/workflows/apply-sort.yml | 41 --------------------- .github/workflows/request-release.yml | 2 +- .github/workflows/windows-gui.yml | 2 +- 4 files changed, 2 insertions(+), 95 deletions(-) delete mode 100644 .github/scripts/apply_sort.py delete mode 100644 .github/workflows/apply-sort.yml diff --git a/.github/scripts/apply_sort.py b/.github/scripts/apply_sort.py deleted file mode 100644 index cf42a98..0000000 --- a/.github/scripts/apply_sort.py +++ /dev/null @@ -1,52 +0,0 @@ -from pathlib import Path -import subprocess - -p = Path('pkg/wingui/wingui_windows.go') -s = p.read_text() -a = s.index('type sortMode string'); b = s.index('const (\n\tbuttonHeight', a); s = s[:a] + s[b:] -a = s.index('type fileEntry struct'); b = s.index('type navState struct', a); s = s[:a] + s[b:] -a = s.index('func (a *app) localSortChanged()'); b = s.index('func (a *app) startTransfer(', a); s = s[:a] + s[b:] -a = s.index('func sortEntries('); b = s.index('func validateLocalDir(', a); s = s[:a] + s[b:] -s = s.replace('\tremoteSort *walk.ComboBox\n', '\tremoteSort *walk.ComboBox\n\tlocalSortDirection *walk.PushButton\n\tremoteSortDirection *walk.PushButton\n') -s = s.replace('\tremoteSortMode sortMode\n', '\tremoteSortMode sortMode\n\tlocalSortDescending bool\n\tremoteSortDescending bool\n') -for side in ('local', 'remote'): - line = next(l for l in s.splitlines() if 'ComboBox{AssignTo: &a.' + side + 'Sort,' in l) - new = line.replace('MaxSize: Size{Width: 92}', 'ToolTipText: "Sort by name, file size or modification date", MinSize: Size{Width: 78}, MaxSize: Size{Width: 78}') - new += '\n\t\t\t\t\t\tPushButton{AssignTo: &a.' + side + 'SortDirection, Text: "Asc", ToolTipText: sortDirectionToolTip(sortByName, false), MinSize: Size{Width: 52, Height: buttonHeight}, MaxSize: Size{Width: 52}, OnClicked: func() { a.reversePaneSort(side' + side.capitalize() + ') }},' - s = s.replace(line, new) -a = s.index('\ta.mu.Lock()\n\tsortEntries(items, a.localSortMode)', s.index('func (a *app) refreshLocal()')); b = s.index('\n}', a) -s = s[:a] + '\ta.replacePaneItems(sideLocal, dir, items)' + s[b:] -a = s.index('\ta.mu.Lock()\n\tsortEntries(entries, a.remoteSortMode)', s.index('func (a *app) loadRemoteUnderOperation')); b = s.index('\n\treturn nil', a) -s = s[:a] + '\ta.replacePaneItems(sideRemote, dir, entries)' + s[b:] -p.write_text(s) -subprocess.run(['gofmt', '-w', str(p)], check=True) -assert subprocess.check_output(['git', 'hash-object', str(p)], text=True).strip() == '6b93bc687a955766e3f5a195bb8bf7d0f6a3f340' - -for name in ('windows-gui.yml', 'request-release.yml'): - p = Path('.github/workflows') / name - s = p.read_text() - assert s.count("'^TestWindowsGUIHashes$'") == 1 - p.write_text(s.replace("'^TestWindowsGUIHashes$'", "'^TestWindowsGUI(Hashes|Sorting)$'")) - -p = Path('CHANGELOG.md') -s = p.read_text(encoding='utf-8') -header = '# Changelog / 更新日志\n\n' -assert s.startswith(header) -notes = '''## 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. - ---- - -''' -p.write_text(header + notes + s[len(header):], encoding='utf-8') diff --git a/.github/workflows/apply-sort.yml b/.github/workflows/apply-sort.yml deleted file mode 100644 index 0530046..0000000 --- a/.github/workflows/apply-sort.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Integrate reviewed sorting changes -on: - push: - branches: [codex/name-size-date-sort] - paths: [.github/scripts/apply_sort.py, .github/workflows/apply-sort.yml] -permissions: - contents: write -jobs: - integrate: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Apply verified source changes - shell: bash - run: | - set -euo pipefail - test "$(git hash-object pkg/wingui/wingui_windows.go)" = 57dac9a84096ef797c22123470c85f5cf6ad8607 - python3 .github/scripts/apply_sort.py - python3 - <<'PY' - from pathlib import Path - p = Path('e2e/wingui_sort_windows_test.go') - s = p.read_text() - marker = '\t\t\totherBefore := sortListNames(u, lists[1-pane])' - assert s.count(marker) == 1 - p.write_text(s.replace(marker, '\t\t\tchooseSortKey(u, combos[pane], 1) // Change away from Name before testing its default.\n' + marker)) - PY - gofmt -w pkg/wingui/sorting*.go e2e/wingui_sort_windows_test.go - go test ./pkg/wingui -count=1 - go test -race ./pkg/wingui -count=5 - go vet ./pkg/wingui - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - # Workflow changes are published separately through the authorized - # GitHub connector, not with this job's contents-only token. - git add pkg/wingui e2e/wingui_sort_windows_test.go CHANGELOG.md - git commit -m 'feat(wingui): integrate Name Size Date sorting and release validation' - git push origin HEAD:codex/name-size-date-sort 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