feat: miller column browser, offline cache, search, breadcrumbs
- Dynamic miller column sizing with flexbox layout - Root column (160px), middle slices (48px), active column (flex) - Auto-scroll browser to rightmost column on navigation - Offline indicator UI and IndexedDB cache integration - /api/documents endpoint for client-side document caching - Breadcrumb navigation in document content area - FTS5 search with SQLite virtual table - Client-side Mermaid and KaTeX rendering via CDN - Deep sample content (advanced-topics/raft-deep-dive) - Border removal (only search button keeps radius) - Full viewport layout with proper borders between sidebar/content
This commit is contained in:
@@ -91,6 +91,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
|
||||
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
go a.watchDocuments(ctx)
|
||||
go a.syncPoll(ctx)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -107,7 +108,26 @@ func (a *App) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) watchDocuments(ctx context.Context) {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
watcher, err := NewFileWatcher(a.cfg.Content.SourceDir, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Warn("file watcher failed, falling back to polling only", "error", err)
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
|
||||
watcher.SetOnChange(func(path string) {
|
||||
if _, err := a.docs.SyncSourceDir(ctx); err != nil {
|
||||
a.logger.Warn("document sync failed", "error", err)
|
||||
} else {
|
||||
a.logger.Debug("document synced", "path", path)
|
||||
}
|
||||
})
|
||||
|
||||
watcher.Run(ctx)
|
||||
}
|
||||
|
||||
func (a *App) syncPoll(ctx context.Context) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
|
||||
101
apps/server/internal/app/watcher.go
Normal file
101
apps/server/internal/app/watcher.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
type FileWatcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
rootDir string
|
||||
onChange func(path string)
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewFileWatcher(rootDir string, logger *slog.Logger) (*FileWatcher, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create fsnotify watcher: %w", err)
|
||||
}
|
||||
|
||||
fw := &FileWatcher{
|
||||
watcher: watcher,
|
||||
rootDir: rootDir,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
if err := fw.addWatches(); err != nil {
|
||||
watcher.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fw, nil
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) addWatches() error {
|
||||
return filepath.Walk(fw.rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := fw.watcher.Add(path); err != nil {
|
||||
fw.logger.Warn("watch directory", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) SetOnChange(fn func(path string)) {
|
||||
fw.onChange = fn
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) Run(ctx context.Context) {
|
||||
defer fw.watcher.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event, ok := <-fw.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if fw.shouldProcess(event) {
|
||||
if event.Op&fsnotify.Create == fsnotify.Create {
|
||||
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
||||
fw.watcher.Add(event.Name)
|
||||
}
|
||||
}
|
||||
if fw.onChange != nil {
|
||||
fw.onChange(event.Name)
|
||||
}
|
||||
}
|
||||
case err, ok := <-fw.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fw.logger.Warn("fsnotify error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (fw *FileWatcher) shouldProcess(event fsnotify.Event) bool {
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||
return false
|
||||
}
|
||||
name := filepath.Base(event.Name)
|
||||
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, "~") {
|
||||
return false
|
||||
}
|
||||
if filepath.Ext(event.Name) == ".md" || event.Op&fsnotify.Remove != 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user