Files
fn_registry/functions/infra/watch_dir_fsnotify.go
T
Egutierrez c468b24d2b feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues
Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
  scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)

Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
  flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
  fsnotify watcher, parser bidireccional MD<->SQLite cache

Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend

CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).

End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:20:15 +02:00

136 lines
3.2 KiB
Go

package infra
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fsnotify/fsnotify"
)
// WatchDirFsnotify crea un watcher recursivo sobre root y todos sus subdirectorios.
// Emite FsEvent al canal devuelto con debounce de 200ms por path (si llegan multiples
// eventos del mismo archivo en la ventana, se emite solo el ultimo).
// Cierra el canal cuando ctx.Done() se dispara.
func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("watch_dir_fsnotify: new watcher: %w", err)
}
// Anadir root y todos los subdirectorios recursivamente.
if err := addDirsRecursive(watcher, root); err != nil {
watcher.Close()
return nil, fmt.Errorf("watch_dir_fsnotify: add dirs: %w", err)
}
ch := make(chan FsEvent, 64)
go func() {
defer watcher.Close()
defer close(ch)
// Mapa de debounce: path -> (timer, ultimo op)
type pending struct {
timer *time.Timer
op string
}
debounce := make(map[string]*pending)
const debounceDelay = 200 * time.Millisecond
for {
select {
case <-ctx.Done():
// Cancelar todos los timers pendientes antes de salir.
for _, p := range debounce {
p.timer.Stop()
}
return
case event, ok := <-watcher.Events:
if !ok {
return
}
op := fsnotifyOpToString(event.Op)
if op == "" {
continue
}
path := event.Name
// Si el directorio nuevo fue creado, anadirlo al watcher.
if event.Op&fsnotify.Create != 0 {
if info, err := os.Stat(path); err == nil && info.IsDir() {
if err := watcher.Add(path); err != nil {
log.Printf("watch_dir_fsnotify: add new dir %s: %v", path, err)
}
}
}
// Debounce: resetear el timer si ya habia uno para este path.
if p, exists := debounce[path]; exists {
p.timer.Stop()
p.op = op
p.timer.Reset(debounceDelay)
} else {
p = &pending{op: op}
p.timer = time.AfterFunc(debounceDelay, func() {
select {
case ch <- FsEvent{Path: path, Op: p.op}:
case <-ctx.Done():
}
delete(debounce, path)
})
debounce[path] = p
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Printf("watch_dir_fsnotify: watcher error: %v", err)
}
}
}()
return ch, nil
}
// addDirsRecursive anade root y todos sus subdirectorios al watcher.
// Retorna error si root no existe o no es accesible.
func addDirsRecursive(watcher *fsnotify.Watcher, root string) error {
if _, err := os.Stat(root); err != nil {
return fmt.Errorf("root dir %s: %w", root, err)
}
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // ignora errores de acceso en subdirs
}
if info.IsDir() {
return watcher.Add(path)
}
return nil
})
}
// fsnotifyOpToString convierte fsnotify.Op al string canonico del registry.
// Retorna "" para operaciones no mapeadas (CHMOD, etc.).
func fsnotifyOpToString(op fsnotify.Op) string {
switch {
case op&fsnotify.Create != 0:
return "create"
case op&fsnotify.Write != 0:
return "write"
case op&fsnotify.Remove != 0:
return "remove"
case op&fsnotify.Rename != 0:
return "rename"
default:
return ""
}
}