Support live recursive folder shares and follow subject moves
This commit is contained in:
1 parent
89472fe6fd
commit
84e89c8fe4
17 files changed
+620
-127
No files matched your search
+63
-22
@@ -7,6 +7,8 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
@@ -26,6 +28,7 @@ type fileState struct {
|
||||
size int64
|
||||
mtime int64
|
||||
rev string
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
type Watcher struct {
|
||||
@@ -102,8 +105,17 @@ func (w *Watcher) rescan() {
|
||||
func notes(snap map[string]fileState) map[string]string {
|
||||
out := make(map[string]string, len(snap))
|
||||
for p, st := range snap {
|
||||
if !st.dir {
|
||||
out[p] = st.rev
|
||||
if !st.dir && vault.IsNote(p) {
|
||||
visible := true
|
||||
for _, segment := range strings.Split(p, "/") {
|
||||
if strings.HasSuffix(segment, vault.AssetsExt) {
|
||||
visible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if visible {
|
||||
out[p] = st.rev
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -111,25 +123,27 @@ func notes(snap map[string]fileState) map[string]string {
|
||||
|
||||
// scan 读取当前目录树;内容版本只对新出现或大小/修改时间变化的文件重新计算
|
||||
func (w *Watcher) scan() (map[string]fileState, error) {
|
||||
entries, err := w.v.Tree()
|
||||
entries, err := w.v.TreeAt("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := make(map[string]fileState, len(entries))
|
||||
dirs := map[string]bool{w.v.Root(): true}
|
||||
for _, e := range entries {
|
||||
st := fileState{dir: e.Dir, size: e.Size, mtime: e.ModTime}
|
||||
st := fileState{dir: e.Dir, size: e.Size, mtime: e.Info.ModTime().UnixNano(), info: e.Info}
|
||||
abs := filepath.Join(w.v.Root(), filepath.FromSlash(e.Path))
|
||||
if e.Dir {
|
||||
dirs[abs] = true
|
||||
} else if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime {
|
||||
st.rev = old.rev
|
||||
} else {
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
continue // 扫描期间被删除,下一轮会处理
|
||||
} else if vault.IsNote(e.Path) {
|
||||
if old, ok := w.snap[e.Path]; ok && !old.dir && old.size == st.size && old.mtime == st.mtime && os.SameFile(old.info, st.info) {
|
||||
st.rev = old.rev
|
||||
} else {
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
continue // 扫描期间被删除,下一轮会处理
|
||||
}
|
||||
st.rev = vault.Rev(data)
|
||||
}
|
||||
st.rev = vault.Rev(data)
|
||||
}
|
||||
next[e.Path] = st
|
||||
}
|
||||
@@ -150,28 +164,36 @@ func (w *Watcher) scan() (map[string]fileState, error) {
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// diff 比较两次快照:内容相同的"删除 + 新增"视为移动
|
||||
// diff 按文件系统身份识别移动,内容相同不代表同一个分享主体。
|
||||
func diff(prev, next map[string]fileState) []events.Event {
|
||||
var evs []events.Event
|
||||
structural := false
|
||||
removed := map[string][]string{} // rev -> 被删除的路径(内容相同的文件可能有多个)
|
||||
removed := map[string]fileState{}
|
||||
for p, old := range prev {
|
||||
if _, ok := next[p]; ok {
|
||||
continue
|
||||
}
|
||||
structural = true
|
||||
if !old.dir {
|
||||
removed[old.rev] = append(removed[old.rev], p)
|
||||
}
|
||||
removed[p] = old
|
||||
}
|
||||
candidates := map[string]string{} // 新路径 -> 唯一的原路径
|
||||
destinations := map[string]int{}
|
||||
for p, cur := range next {
|
||||
old, existed := prev[p]
|
||||
switch {
|
||||
case !existed:
|
||||
structural = true
|
||||
if froms := removed[cur.rev]; len(froms) > 0 && !cur.dir {
|
||||
removed[cur.rev] = froms[1:]
|
||||
evs = append(evs, events.Event{Type: "moved", From: froms[0], Path: p, Rev: cur.rev})
|
||||
from := ""
|
||||
matches := 0
|
||||
for oldPath, old := range removed {
|
||||
if os.SameFile(old.info, cur.info) {
|
||||
from = oldPath
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches == 1 {
|
||||
candidates[p] = from
|
||||
destinations[from]++
|
||||
}
|
||||
case old.dir != cur.dir:
|
||||
structural = true
|
||||
@@ -179,11 +201,30 @@ func diff(prev, next map[string]fileState) []events.Event {
|
||||
evs = append(evs, events.Event{Type: "changed", Path: p, Rev: cur.rev})
|
||||
}
|
||||
}
|
||||
for _, paths := range removed {
|
||||
for _, p := range paths {
|
||||
evs = append(evs, events.Event{Type: "deleted", Path: p})
|
||||
var moves []events.Event
|
||||
for to, from := range candidates {
|
||||
if destinations[from] == 1 {
|
||||
delete(removed, from)
|
||||
moves = append(moves, events.Event{Type: "moved", From: from, Path: to, Rev: next[to].rev})
|
||||
}
|
||||
}
|
||||
sort.Slice(moves, func(i, j int) bool { return len(moves[i].From) < len(moves[j].From) })
|
||||
var emitted []events.Event
|
||||
for _, move := range moves {
|
||||
for _, parent := range emitted {
|
||||
if strings.HasPrefix(move.From, parent.From+"/") {
|
||||
move.From = parent.Path + strings.TrimPrefix(move.From, parent.From)
|
||||
}
|
||||
}
|
||||
// 先应用父目录映射;子项另有改名时,从映射后的路径继续移动。
|
||||
if move.From != move.Path {
|
||||
emitted = append(emitted, move)
|
||||
}
|
||||
}
|
||||
evs = append(emitted, evs...)
|
||||
for p := range removed {
|
||||
evs = append(evs, events.Event{Type: "deleted", Path: p})
|
||||
}
|
||||
if structural {
|
||||
evs = append(evs, events.Event{Type: "tree"})
|
||||
}
|
||||
|
||||
@@ -1,44 +1,61 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{"d/a.md", "b.md", "c.md", "c2.md"} {
|
||||
if err := os.WriteFile(filepath.Join(root, p), []byte("相同内容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state := func(p, rev string) fileState {
|
||||
info, err := os.Stat(filepath.Join(root, p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fileState{dir: info.IsDir(), rev: rev, info: info}
|
||||
}
|
||||
prev := map[string]fileState{
|
||||
"d": {dir: true},
|
||||
"d/a.md": {rev: "r1"},
|
||||
"b.md": {rev: "r2"},
|
||||
"c.md": {rev: "same"},
|
||||
"c2.md": {rev: "same"},
|
||||
"d": state("d", ""),
|
||||
"d/a.md": state("d/a.md", "r1"),
|
||||
"b.md": state("b.md", "r2"),
|
||||
"c.md": state("c.md", "same"),
|
||||
"c2.md": state("c2.md", "same"),
|
||||
}
|
||||
for from, to := range map[string]string{"d/a.md": "d/x.md", "c2.md": "e.md"} {
|
||||
if err := os.Rename(filepath.Join(root, from), filepath.Join(root, to)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
next := map[string]fileState{
|
||||
"d": {dir: true},
|
||||
"d/x.md": {rev: "r1"}, // 由 d/a.md 移动而来
|
||||
"b.md": {rev: "r3"}, // 内容修改
|
||||
"e.md": {rev: "same"}, // c.md 与 c2.md 内容相同:其一视为移动到此,另一个为删除
|
||||
"d": state("d", ""),
|
||||
"d/x.md": state("d/x.md", "r1"),
|
||||
"b.md": state("b.md", "r3"),
|
||||
"e.md": state("e.md", "same"),
|
||||
}
|
||||
var got []string
|
||||
for _, e := range diff(prev, next) {
|
||||
f := []string{e.Type}
|
||||
if e.From != "" {
|
||||
f = append(f, "c*")
|
||||
if !strings.HasPrefix(e.From, "c") {
|
||||
f[1] = e.From
|
||||
}
|
||||
f = append(f, e.From)
|
||||
}
|
||||
if e.Path != "" {
|
||||
f = append(f, e.Path)
|
||||
if e.Type == "deleted" && strings.HasPrefix(e.Path, "c") {
|
||||
f[1] = "c*"
|
||||
}
|
||||
}
|
||||
got = append(got, strings.Join(f, " "))
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := "changed b.md|deleted c*|moved c* e.md|moved d/a.md d/x.md|tree"
|
||||
want := "changed b.md|deleted c.md|moved c2.md e.md|moved d/a.md d/x.md|tree"
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Fatalf("事件不符:\n got %q\nwant %q", strings.Join(got, "|"), want)
|
||||
}
|
||||
@@ -46,3 +63,53 @@ func TestDiff(t *testing.T) {
|
||||
t.Fatalf("快照相同不应产生事件: %v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryMovesCoverChildrenAndEmptyDirectories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "from", "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "from", "a.md"), []byte("正文"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := func(dir string) map[string]fileState {
|
||||
out := map[string]fileState{}
|
||||
for _, rel := range []string{dir, dir + "/empty", dir + "/a.md"} {
|
||||
info, err := os.Stat(filepath.Join(root, rel))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[rel] = fileState{dir: info.IsDir(), info: info, rev: "same"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
prev := snapshot("from")
|
||||
if err := os.Rename(filepath.Join(root, "from"), filepath.Join(root, "to")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events := diff(prev, snapshot("to"))
|
||||
if len(events) != 2 || events[0].Type != "moved" || events[0].From != "from" || events[0].Path != "to" || events[1].Type != "tree" {
|
||||
t.Fatalf("目录移动应只产生一次路径映射: %v", events)
|
||||
}
|
||||
next := snapshot("to")
|
||||
if err := os.Rename(filepath.Join(root, "to", "a.md"), filepath.Join(root, "to", "b.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next["to/b.md"] = next["to/a.md"]
|
||||
delete(next, "to/a.md")
|
||||
events = diff(prev, next)
|
||||
if len(events) != 3 || events[0].From != "from" || events[0].Path != "to" || events[1].From != "to/a.md" || events[1].Path != "to/b.md" || events[2].Type != "tree" {
|
||||
t.Fatalf("同一批次内子项改名应接续父目录映射: %v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesExcludeResourceContents(t *testing.T) {
|
||||
indexed := notes(map[string]fileState{
|
||||
"笔记.md": {rev: "r1"},
|
||||
"笔记.assets/说明.md": {rev: "r2"},
|
||||
"附件.txt": {},
|
||||
})
|
||||
if len(indexed) != 1 || indexed["笔记.md"] != "r1" {
|
||||
t.Fatalf("跟踪普通文件移动不能改变笔记索引范围: %v", indexed)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user