Add GET /fs/browse directory-listing endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"antidrift/internal/settings"
|
"antidrift/internal/settings"
|
||||||
|
|
||||||
@@ -61,3 +64,57 @@ func (s *Server) handlePostSettings(c *gin.Context) {
|
|||||||
s.broadcast()
|
s.broadcast()
|
||||||
c.JSON(http.StatusOK, req)
|
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 "/"
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,3 +110,75 @@ func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
|||||||
t.Errorf("applier called on invalid json; want 0, got %d", fa.called)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
r.POST("/knowledge/path", s.handleKnowledgePath)
|
r.POST("/knowledge/path", s.handleKnowledgePath)
|
||||||
r.GET("/settings", s.handleGetSettings)
|
r.GET("/settings", s.handleGetSettings)
|
||||||
r.POST("/settings", s.handlePostSettings)
|
r.POST("/settings", s.handlePostSettings)
|
||||||
|
r.GET("/fs/browse", s.handleBrowse)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user