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:
2026-04-30 11:55:03 -04:00
parent e45eeeb600
commit 780ff3a02c
40 changed files with 2986 additions and 190 deletions

View File

@@ -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 {

View 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
}