c468b24d2b
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>
136 lines
3.2 KiB
Go
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 ""
|
|
}
|
|
}
|