diff --git a/cmd/syncthing/gui.go b/cmd/syncthing/gui.go index 13fc8bd6a..d93b3d8e8 100644 --- a/cmd/syncthing/gui.go +++ b/cmd/syncthing/gui.go @@ -1542,6 +1542,24 @@ func (s *apiService) getSystemBrowse(w http.ResponseWriter, r *http.Request) { sendJSON(w, browseFiles(current, fsType)) } +const ( + matchExact int = iota + matchCaseIns + noMatch +) + +func checkPrefixMatch(s, prefix string) int { + if strings.HasPrefix(s, prefix) { + return matchExact + } + + if strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) { + return matchCaseIns + } + + return noMatch +} + func browseFiles(current string, fsType fs.FilesystemType) []string { if current == "" { filesystem := fs.NewFilesystem(fsType, "") @@ -1567,16 +1585,29 @@ func browseFiles(current string, fsType fs.FilesystemType) []string { fs := fs.NewFilesystem(fsType, searchDir) - subdirectories, _ := fs.Glob(searchFile + "*") + subdirectories, _ := fs.DirNames(".") + + exactMatches := make([]string, 0, len(subdirectories)) + caseInsMatches := make([]string, 0, len(subdirectories)) - ret := make([]string, 0, len(subdirectories)) for _, subdirectory := range subdirectories { info, err := fs.Stat(subdirectory) - if err == nil && info.IsDir() { - ret = append(ret, filepath.Join(searchDir, subdirectory)+pathSeparator) + if err != nil || !info.IsDir() { + continue + } + + switch checkPrefixMatch(subdirectory, searchFile) { + case matchExact: + exactMatches = append(exactMatches, filepath.Join(searchDir, subdirectory)+pathSeparator) + case matchCaseIns: + caseInsMatches = append(caseInsMatches, filepath.Join(searchDir, subdirectory)+pathSeparator) } } - return ret + + // sort to return matches in deterministic order (don't depend on file system order) + sort.Strings(exactMatches) + sort.Strings(caseInsMatches) + return append(exactMatches, caseInsMatches...) } func (s *apiService) getCPUProf(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/syncthing/gui_test.go b/cmd/syncthing/gui_test.go index 0617f6bfd..0904412dd 100644 --- a/cmd/syncthing/gui_test.go +++ b/cmd/syncthing/gui_test.go @@ -988,10 +988,14 @@ func TestBrowse(t *testing.T) { if err := ioutil.WriteFile(filepath.Join(tmpDir, "file"), []byte("hello"), 0644); err != nil { t.Fatal(err) } + if err := os.Mkdir(filepath.Join(tmpDir, "MiXEDCase"), 0755); err != nil { + t.Fatal(err) + } // We expect completion to return the full path to the completed // directory, with an ending slash. dirPath := filepath.Join(tmpDir, "dir") + pathSep + mixedCaseDirPath := filepath.Join(tmpDir, "MiXEDCase") + pathSep cases := []struct { current string @@ -1002,13 +1006,15 @@ func TestBrowse(t *testing.T) { // With slash it's completed to its contents. // Dirs are given pathSeps. // Files are not returned. - {tmpDir + pathSep, []string{dirPath}}, + {tmpDir + pathSep, []string{mixedCaseDirPath, dirPath}}, // Globbing is automatic based on prefix. {tmpDir + pathSep + "d", []string{dirPath}}, {tmpDir + pathSep + "di", []string{dirPath}}, {tmpDir + pathSep + "dir", []string{dirPath}}, {tmpDir + pathSep + "f", nil}, {tmpDir + pathSep + "q", nil}, + // Globbing is case-insensitve + {tmpDir + pathSep + "mixed", []string{mixedCaseDirPath}}, } for _, tc := range cases { @@ -1019,6 +1025,26 @@ func TestBrowse(t *testing.T) { } } +func TestPrefixMatch(t *testing.T) { + cases := []struct { + s string + prefix string + expected int + }{ + {"aaaA", "aaa", matchExact}, + {"AAAX", "BBB", noMatch}, + {"AAAX", "aAa", matchCaseIns}, + {"äÜX", "äü", matchCaseIns}, + } + + for _, tc := range cases { + ret := checkPrefixMatch(tc.s, tc.prefix) + if ret != tc.expected { + t.Errorf("checkPrefixMatch(%q, %q) => %v, expected %v", tc.s, tc.prefix, ret, tc.expected) + } + } +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false