From a1089644579b3bcf661d3c68d1c5201a022a8005 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 20:15:00 -0400 Subject: [PATCH] Add GET /fs/browse directory-listing endpoint Co-Authored-By: Claude Opus 4.8 --- internal/web/settings_handlers.go | 57 ++++++++++++++++++++ internal/web/settings_handlers_test.go | 72 ++++++++++++++++++++++++++ internal/web/web.go | 1 + 3 files changed, 130 insertions(+) diff --git a/internal/web/settings_handlers.go b/internal/web/settings_handlers.go index c147391..31d0729 100644 --- a/internal/web/settings_handlers.go +++ b/internal/web/settings_handlers.go @@ -2,6 +2,9 @@ package web import ( "net/http" + "os" + "path/filepath" + "strings" "antidrift/internal/settings" @@ -61,3 +64,57 @@ func (s *Server) handlePostSettings(c *gin.Context) { s.broadcast() c.JSON(http.StatusOK, req) } + +type browseEntry struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` +} + +type browseResponse struct { + Dir string `json:"dir"` + Parent string `json:"parent"` + Entries []browseEntry `json:"entries"` +} + +// handleBrowse lists subdirectories plus .md files under dir, so the UI can build +// a file picker that returns a real server-side path. Dotfiles are NOT hidden — +// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail +// beyond OS permissions (same trust boundary as the rest of the UI). +func (s *Server) handleBrowse(c *gin.Context) { + dir := strings.TrimSpace(c.Query("dir")) + if dir == "" { + dir = s.browseStart() + } + dir = filepath.Clean(dir) + infos, err := os.ReadDir(dir) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + resp := browseResponse{Dir: dir, Parent: filepath.Dir(dir), Entries: []browseEntry{}} + for _, e := range infos { + name := e.Name() + full := filepath.Join(dir, name) + if e.IsDir() { + resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full, IsDir: true}) + } else if strings.HasSuffix(name, ".md") { + resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full}) + } + } + c.JSON(http.StatusOK, resp) +} + +// browseStart picks the directory of the current knowledge path, else $HOME. +func (s *Server) browseStart() string { + s.settingsMu.Lock() + kp := s.settings.KnowledgePath + s.settingsMu.Unlock() + if kp != "" { + return filepath.Dir(kp) + } + if home, err := os.UserHomeDir(); err == nil { + return home + } + return "/" +} diff --git a/internal/web/settings_handlers_test.go b/internal/web/settings_handlers_test.go index d49ae08..0d02db2 100644 --- a/internal/web/settings_handlers_test.go +++ b/internal/web/settings_handlers_test.go @@ -110,3 +110,75 @@ func TestPostSettingsInvalidJSONIs400(t *testing.T) { t.Errorf("applier called on invalid json; want 0, got %d", fa.called) } } + +func TestBrowseListsDirsAndMarkdown(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + s := newTestServer(t) + s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) + r := s.Router() + + req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp struct { + Dir string `json:"dir"` + Parent string `json:"parent"` + Entries []struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + } `json:"entries"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Parent != filepath.Dir(dir) { + t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir)) + } + var names []string + for _, e := range resp.Entries { + names = append(names, e.Name) + } + hasSub, hasMd, hasTxt := false, false, false + for _, e := range resp.Entries { + switch e.Name { + case "sub": + hasSub = e.IsDir + case "profile.md": + hasMd = !e.IsDir + case "notes.txt": + hasTxt = true + } + } + if !hasSub || !hasMd { + t.Errorf("missing dir or .md entry; names = %v", names) + } + if hasTxt { + t.Errorf(".txt should be filtered out; names = %v", names) + } +} + +func TestBrowseBadDirIs400(t *testing.T) { + s := newTestServer(t) + s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) + r := s.Router() + req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 6679b78..ed12747 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -77,6 +77,7 @@ func (s *Server) Router() *gin.Engine { r.POST("/knowledge/path", s.handleKnowledgePath) r.GET("/settings", s.handleGetSettings) r.POST("/settings", s.handlePostSettings) + r.GET("/fs/browse", s.handleBrowse) return r }