Files

158 lines
4.6 KiB
Go
Raw Permalink Normal View History

package syncer
import (
"io/fs"
"path"
"path/filepath"
"sort"
"strings"
)
// Entry is one line of the not-synced list. Path ends in "/" when a whole
// directory collapsed to a single line; Files is how many files it holds.
type Entry struct {
Path string
Files int
Nested bool // syncs through its own project — not excluded
}
// IsDir reports whether the entry stands for a whole directory rather than a
// single file.
func (e Entry) IsDir() bool { return strings.HasSuffix(e.Path, "/") }
// Explain reports what the sync cycle would and would not send for a folder.
// It is a pure read: no Session, no volume lock, no network, no writes — the
// answer comes from the same walk the cycle itself uses, so it cannot drift.
//
// accepted is the ignore text this device has accepted (store.SyncState's
// IgnoreAccepted; "" when there is none). It is a parameter rather than
// something loadFilter finds for itself because the answer lives in the volume
// store and this function deliberately does not open one — but it has to be
// passed, because the scan applies it (Filter.SkipUp) and an explanation that
// omits it would report a peer's `!` rule as syncing a file the cycle will not
// send. That drift is the one thing this function exists not to have.
func Explain(folder string, include []string, accepted string) (synced []string, notSynced []Entry, err error) {
// A fresh filter: addNestedMount mutates it during the walk, so this must
// never be shared with a live cycle.
filter, err := loadFilter(folder, include)
if err != nil {
return nil, nil, err
}
filter.AcceptRules(accepted)
var skipped []string
dirs := map[string]*Entry{}
var dirOrder []string // walk order: parents before children
keep := map[string]bool{} // dir holds something that must stay visible
count := map[string]int{} // files under a dir that do not sync
err = walkFolder(folder, filter, func(abs, rel string, d fs.DirEntry, v verdict) error {
switch v {
case vSync:
synced = append(synced, rel)
for _, a := range ancestors(rel) {
keep[a] = true
}
case vSkipFile:
skipped = append(skipped, rel)
for _, a := range ancestors(rel) {
count[a]++
}
case vDescend:
dirs[rel] = &Entry{Path: rel + "/"}
dirOrder = append(dirOrder, rel)
case vPruneDir:
n := countFiles(abs)
dirs[rel] = &Entry{Path: rel + "/", Files: n}
dirOrder = append(dirOrder, rel)
for _, a := range ancestors(rel) {
count[a] += n
}
case vNested:
// Not excluded — it syncs through its own project, so its files
// are not counted as "do not sync" and its parents stay visible
// rather than collapsing the annotation away.
dirs[rel] = &Entry{Path: rel + "/", Nested: true}
dirOrder = append(dirOrder, rel)
for _, a := range ancestors(rel) {
keep[a] = true
}
}
return nil
})
if err != nil {
return nil, nil, err
}
// Collapse: a directory with nothing to show individually prints as one
// counted line, and everything under it is dropped. Parents come first in
// walk order, so the topmost such directory wins.
collapsed := map[string]bool{}
for _, rel := range dirOrder {
if keep[rel] || underCollapsed(rel, collapsed) {
continue
}
collapsed[rel] = true
e := dirs[rel]
if !e.Nested && e.Files == 0 {
e.Files = count[rel]
}
notSynced = append(notSynced, *e)
}
for _, rel := range skipped {
if !underCollapsed(rel, collapsed) {
notSynced = append(notSynced, Entry{Path: rel})
}
}
sort.Strings(synced)
sort.Slice(notSynced, func(i, j int) bool { return notSynced[i].Path < notSynced[j].Path })
return synced, notSynced, nil
}
// NotSyncedFiles is how many files the not-synced list stands for: collapsed
// directories count their whole subtree, nested mounts count zero because
// they do sync — through their own project.
func NotSyncedFiles(notSynced []Entry) int {
n := 0
for _, e := range notSynced {
if e.IsDir() {
n += e.Files
} else {
n++
}
}
return n
}
func ancestors(rel string) []string {
var out []string
for d := path.Dir(rel); d != "."; d = path.Dir(d) {
out = append(out, d)
}
return out
}
func underCollapsed(rel string, collapsed map[string]bool) bool {
for _, a := range ancestors(rel) {
if collapsed[a] {
return true
}
}
return false
}
// countFiles counts the files under an already-excluded directory. Readdir
// only — never Stat — because this runs over trees like .git and
// node_modules; a partial count on an unreadable subtree is fine.
func countFiles(abs string) int {
n := 0
filepath.WalkDir(abs, func(_ string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
n++
}
return nil
})
return n
}