mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-31 09:18:29 +00:00
feat(backend,frontend): implement prefix and recursive substring search for bucket objects (#89)
* feat(search): implement recursive substring search for bucket objects * fix: update key formatting to remove trailing slashes in ObjectsTable * feat(search): implement debounced search functionality and improve UI text clarity * test(objects): add test for handling search error response
This commit is contained in:
@@ -20,13 +20,13 @@ import (
|
|||||||
// served from the same origin as the API, any uploader could otherwise plant
|
// served from the same origin as the API, any uploader could otherwise plant
|
||||||
// stored XSS by uploading a file with one of these Content-Types.
|
// stored XSS by uploading a file with one of these Content-Types.
|
||||||
var unsafeInlineContentTypes = map[string]struct{}{
|
var unsafeInlineContentTypes = map[string]struct{}{
|
||||||
"text/html": {},
|
"text/html": {},
|
||||||
"application/xhtml+xml": {},
|
"application/xhtml+xml": {},
|
||||||
"image/svg+xml": {},
|
"image/svg+xml": {},
|
||||||
"application/xml": {},
|
"application/xml": {},
|
||||||
"text/xml": {},
|
"text/xml": {},
|
||||||
"application/javascript": {},
|
"application/javascript": {},
|
||||||
"text/javascript": {},
|
"text/javascript": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeContentType rewrites Content-Types that the browser would treat as
|
// safeContentType rewrites Content-Types that the browser would treat as
|
||||||
@@ -89,6 +89,7 @@ func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param bucket path string true "Name of the bucket to list objects from"
|
// @Param bucket path string true "Name of the bucket to list objects from"
|
||||||
// @Param prefix query string false "Filter objects by prefix"
|
// @Param prefix query string false "Filter objects by prefix"
|
||||||
|
// @Param search query string false "Recursively search object keys under prefix by case-insensitive substring (best-effort; bypasses max_keys and continuation_token)"
|
||||||
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
|
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
|
||||||
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
|
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
|
||||||
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
|
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
|
||||||
@@ -109,6 +110,21 @@ func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
|||||||
|
|
||||||
// Get query parameters for filtering and pagination
|
// Get query parameters for filtering and pagination
|
||||||
prefix := c.Query("prefix", "")
|
prefix := c.Query("prefix", "")
|
||||||
|
|
||||||
|
// Search mode: a recursive, best-effort substring search across the whole
|
||||||
|
// subtree under prefix. S3/Garage has no server-side substring search, so
|
||||||
|
// the backend scans and filters. This bypasses page-token pagination and
|
||||||
|
// max_keys, see S3Service.SearchObjects -> pagination is handled frontend side.
|
||||||
|
if search := c.Query("search", ""); search != "" {
|
||||||
|
results, err := h.s3Service.SearchObjects(ctx, bucketName, prefix, search)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
||||||
|
models.ErrorResponse(models.ErrCodeListFailed, "Failed to search objects: "+err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return c.JSON(models.SuccessResponse(results))
|
||||||
|
}
|
||||||
|
|
||||||
continuationToken := c.Query("continuation_token", "")
|
continuationToken := c.Query("continuation_token", "")
|
||||||
|
|
||||||
maxKeysStr := c.Query("max_keys", "100")
|
maxKeysStr := c.Query("max_keys", "100")
|
||||||
|
|||||||
@@ -120,6 +120,52 @@ func TestListObjects_ServiceError500(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListObjects_SearchRoutesToSearchObjects(t *testing.T) {
|
||||||
|
app, s3 := newObjectsTestApp(t)
|
||||||
|
// Intentionally leave ListObjectsFn unset: if the handler wrongly falls
|
||||||
|
// through to a normal listing, the mock returns an error and this fails.
|
||||||
|
s3.SearchObjectsFn = func(_ context.Context, bucket, prefix, search string) (*models.ObjectListResponse, error) {
|
||||||
|
if bucket != "b1" || prefix != "docs/" || search != "target" {
|
||||||
|
t.Errorf("args = (%q, %q, %q)", bucket, prefix, search)
|
||||||
|
}
|
||||||
|
return &models.ObjectListResponse{
|
||||||
|
Bucket: bucket, Count: 1,
|
||||||
|
Objects: []models.ObjectInfo{{Key: "docs/target.pdf", Size: 20}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=docs/&search=target", nil)
|
||||||
|
resp, err := app.Test(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("app.Test: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Data models.ObjectListResponse `json:"data"`
|
||||||
|
}
|
||||||
|
decodeJSON(t, resp.Body, &body)
|
||||||
|
if body.Data.Count != 1 || len(body.Data.Objects) != 1 || body.Data.Objects[0].Key != "docs/target.pdf" {
|
||||||
|
t.Errorf("unexpected search results: %+v", body.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListObjects_SearchError500(t *testing.T) {
|
||||||
|
app, s3 := newObjectsTestApp(t)
|
||||||
|
s3.SearchObjectsFn = func(_ context.Context, _, _, _ string) (*models.ObjectListResponse, error) {
|
||||||
|
return nil, errors.New("boom")
|
||||||
|
}
|
||||||
|
resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?search=target", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("app.Test: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want 500", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- GetObjectMetadata ---
|
// --- GetObjectMetadata ---
|
||||||
|
|
||||||
func TestGetObjectMetadata_Success(t *testing.T) {
|
func TestGetObjectMetadata_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type AdminService interface {
|
|||||||
// GetBucketStatistics) are intentionally excluded.
|
// GetBucketStatistics) are intentionally excluded.
|
||||||
type S3Storage interface {
|
type S3Storage interface {
|
||||||
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
||||||
|
SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error)
|
||||||
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
||||||
CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
|
CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
|
||||||
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ var _ services.S3Storage = (*S3Mock)(nil)
|
|||||||
// per-method function fields they care about; unset methods return
|
// per-method function fields they care about; unset methods return
|
||||||
// s3NotConfigured.
|
// s3NotConfigured.
|
||||||
type S3Mock struct {
|
type S3Mock struct {
|
||||||
ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error)
|
||||||
UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
SearchObjectsFn func(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error)
|
||||||
|
UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error)
|
||||||
CreateDirectoryMarkerFn func(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
|
CreateDirectoryMarkerFn func(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error)
|
||||||
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error)
|
||||||
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
|
ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error)
|
||||||
DeleteObjectFn func(ctx context.Context, bucketName, key string) error
|
DeleteObjectFn func(ctx context.Context, bucketName, key string) error
|
||||||
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
|
GetObjectMetadataFn func(ctx context.Context, bucketName, key string) (*models.ObjectInfo, error)
|
||||||
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
|
GetPresignedURLFn func(ctx context.Context, bucketName, key string, expiresIn time.Duration) (string, error)
|
||||||
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error
|
DeleteMultipleObjectsFn func(ctx context.Context, bucketName string, keys []string) error
|
||||||
UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct {
|
UploadMultipleObjectsFn func(ctx context.Context, bucketName string, files []struct {
|
||||||
Key string
|
Key string
|
||||||
@@ -55,6 +56,14 @@ func (m *S3Mock) ListObjects(ctx context.Context, bucketName, prefix string, max
|
|||||||
return m.ListObjectsFn(ctx, bucketName, prefix, maxKeys, continuationToken)
|
return m.ListObjectsFn(ctx, bucketName, prefix, maxKeys, continuationToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *S3Mock) SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error) {
|
||||||
|
m.record("SearchObjects", bucketName, prefix, search)
|
||||||
|
if m.SearchObjectsFn == nil {
|
||||||
|
return nil, s3NotConfigured("SearchObjects")
|
||||||
|
}
|
||||||
|
return m.SearchObjectsFn(ctx, bucketName, prefix, search)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *S3Mock) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
func (m *S3Mock) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
||||||
m.record("UploadObject", bucketName, key, contentType)
|
m.record("UploadObject", bucketName, key, contentType)
|
||||||
if m.UploadObjectFn == nil {
|
if m.UploadObjectFn == nil {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"Noooste/garage-ui/internal/config"
|
"Noooste/garage-ui/internal/config"
|
||||||
"Noooste/garage-ui/internal/models"
|
"Noooste/garage-ui/internal/models"
|
||||||
|
logpkg "Noooste/garage-ui/pkg/logger"
|
||||||
"Noooste/garage-ui/pkg/utils"
|
"Noooste/garage-ui/pkg/utils"
|
||||||
|
|
||||||
"github.com/minio/minio-go/v7"
|
"github.com/minio/minio-go/v7"
|
||||||
@@ -240,17 +241,10 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
|||||||
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop directory marker objects (zero-byte keys ending in "/"). Garage
|
|
||||||
// returns them in Contents, but the UI renders folders from Prefixes — a
|
|
||||||
// marker shown as both a folder and a file is confusing. Any marker not
|
|
||||||
// already covered by a CommonPrefix is promoted to Prefixes below.
|
|
||||||
contents := make([]minio.ObjectInfo, 0, len(result.Contents))
|
contents := make([]minio.ObjectInfo, 0, len(result.Contents))
|
||||||
markerKeys := make([]string, 0)
|
markerKeys := make([]string, 0)
|
||||||
for _, obj := range result.Contents {
|
for _, obj := range result.Contents {
|
||||||
if strings.HasSuffix(obj.Key, "/") && obj.Size == 0 {
|
if strings.HasSuffix(obj.Key, "/") && obj.Size == 0 {
|
||||||
// A marker whose key equals the current listing prefix is the
|
|
||||||
// folder itself — drop it entirely so it doesn't render as a
|
|
||||||
// nameless child of itself.
|
|
||||||
if obj.Key != prefix {
|
if obj.Key != prefix {
|
||||||
markerKeys = append(markerKeys, obj.Key)
|
markerKeys = append(markerKeys, obj.Key)
|
||||||
}
|
}
|
||||||
@@ -331,6 +325,97 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
searchMaxScan = 10000 // stop after scanning this many objects
|
||||||
|
searchMaxResults = 1000 // stop after collecting this many matches
|
||||||
|
searchPageSize = 1000 // objects requested per ListObjectsV2 page
|
||||||
|
)
|
||||||
|
|
||||||
|
func objectMatchesSearch(key string, size int64, lowerQuery string) bool {
|
||||||
|
if strings.HasSuffix(key, "/") && size == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(key), lowerQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchObjects performs a recursive, best-effort substring search over object
|
||||||
|
// keys under the given prefix.
|
||||||
|
func (s *S3Service) SearchObjects(ctx context.Context, bucketName, prefix, search string) (*models.ObjectListResponse, error) {
|
||||||
|
client, err := s.getMinioClient(ctx, bucketName, OpRead)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
core := &minio.Core{Client: client}
|
||||||
|
lowerQuery := strings.ToLower(search)
|
||||||
|
|
||||||
|
matches := make([]models.ObjectInfo, 0, 64)
|
||||||
|
scanned := 0
|
||||||
|
truncated := false
|
||||||
|
token := ""
|
||||||
|
|
||||||
|
scan:
|
||||||
|
for {
|
||||||
|
result, err := core.ListObjectsV2(
|
||||||
|
bucketName,
|
||||||
|
prefix,
|
||||||
|
"",
|
||||||
|
token,
|
||||||
|
"",
|
||||||
|
searchPageSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search objects in bucket %s: %w", bucketName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, obj := range result.Contents {
|
||||||
|
scanned++
|
||||||
|
if objectMatchesSearch(obj.Key, obj.Size, lowerQuery) {
|
||||||
|
matches = append(matches, models.ObjectInfo{
|
||||||
|
Key: obj.Key,
|
||||||
|
Size: obj.Size,
|
||||||
|
LastModified: obj.LastModified,
|
||||||
|
ETag: obj.ETag,
|
||||||
|
StorageClass: obj.StorageClass,
|
||||||
|
})
|
||||||
|
if len(matches) >= searchMaxResults {
|
||||||
|
truncated = true
|
||||||
|
break scan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scanned >= searchMaxScan {
|
||||||
|
truncated = true
|
||||||
|
break scan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.IsTruncated || result.NextContinuationToken == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
token = result.NextContinuationToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncated {
|
||||||
|
logpkg.FromCtx(ctx).Warn().
|
||||||
|
Str("bucket", bucketName).
|
||||||
|
Str("prefix", prefix).
|
||||||
|
Int("scanned", scanned).
|
||||||
|
Int("matches", len(matches)).
|
||||||
|
Msg("search hit scan/result cap; results are partial")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.ObjectListResponse{
|
||||||
|
Bucket: bucketName,
|
||||||
|
Objects: matches,
|
||||||
|
Prefixes: []string{},
|
||||||
|
Count: len(matches),
|
||||||
|
IsTruncated: truncated,
|
||||||
|
// Search returns all matches up to the cap in one response; there is no
|
||||||
|
// token-based pagination for search results.
|
||||||
|
NextContinuationToken: "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UploadObject uploads an object to a bucket
|
// UploadObject uploads an object to a bucket
|
||||||
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) {
|
||||||
// Get bucket-specific MinIO client
|
// Get bucket-specific MinIO client
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// s3SearchHandler serves a two-page recursive ListObjectsV2 response and counts
|
||||||
|
// HEAD (StatObject) requests. Page selection is driven by the
|
||||||
|
// `continuation-token` query parameter, mirroring how the MinIO SDK paginates.
|
||||||
|
func s3SearchHandler(bucket string, page1, page2 []struct {
|
||||||
|
Key string
|
||||||
|
Size int64
|
||||||
|
LastModified string
|
||||||
|
ETag string
|
||||||
|
}) (http.Handler, *int, *string) {
|
||||||
|
var heads int
|
||||||
|
var lastDelimiter string
|
||||||
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
heads++
|
||||||
|
w.Header().Set("Content-Length", "0")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastDelimiter = r.URL.Query().Get("delimiter")
|
||||||
|
w.Header().Set("Content-Type", "application/xml")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.URL.Query().Get("continuation-token") == "PAGE2" {
|
||||||
|
_, _ = io.WriteString(w, listBucketResultXML(bucket, false, "", page2, nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, listBucketResultXML(bucket, true, "PAGE2", page1, nil))
|
||||||
|
})
|
||||||
|
return h, &heads, &lastDelimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestS3_SearchObjects_FindsMatchOnLaterPage reproduces issue #87: an object
|
||||||
|
// that lives on the second page of a listing must still be found by search.
|
||||||
|
// SearchObjects should page through the whole listing recursively.
|
||||||
|
func TestS3_SearchObjects_FindsMatchOnLaterPage(t *testing.T) {
|
||||||
|
bucket := "b-TestS3_SearchObjects_FindsMatchOnLaterPage"
|
||||||
|
page1 := []struct {
|
||||||
|
Key string
|
||||||
|
Size int64
|
||||||
|
LastModified string
|
||||||
|
ETag string
|
||||||
|
}{
|
||||||
|
{Key: "docs/alpha.txt", Size: 10},
|
||||||
|
{Key: "docs/beta.txt", Size: 10},
|
||||||
|
}
|
||||||
|
page2 := []struct {
|
||||||
|
Key string
|
||||||
|
Size int64
|
||||||
|
LastModified string
|
||||||
|
ETag string
|
||||||
|
}{
|
||||||
|
{Key: "docs/target-report.pdf", Size: 20},
|
||||||
|
{Key: "docs/gamma.txt", Size: 10},
|
||||||
|
}
|
||||||
|
h, heads, lastDelimiter := s3SearchHandler(bucket, page1, page2)
|
||||||
|
s3 := newS3TestService(t, h)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got, err := s3.SearchObjects(ctx, bucket, "", "target")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchObjects: %v", err)
|
||||||
|
}
|
||||||
|
if got.Count != 1 || len(got.Objects) != 1 || got.Objects[0].Key != "docs/target-report.pdf" {
|
||||||
|
t.Fatalf("expected to find docs/target-report.pdf on page 2, got %+v", got.Objects)
|
||||||
|
}
|
||||||
|
// Search must be recursive (no delimiter) so it descends into folders.
|
||||||
|
if *lastDelimiter != "" {
|
||||||
|
t.Errorf("delimiter = %q, want empty (recursive listing)", *lastDelimiter)
|
||||||
|
}
|
||||||
|
// Search must not fetch ContentType per object — that would be N stat calls.
|
||||||
|
if *heads != 0 {
|
||||||
|
t.Errorf("StatObject (HEAD) calls = %d, want 0 during search", *heads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestS3_SearchObjects_ExcludesDirectoryMarkersAndIsCaseInsensitive verifies
|
||||||
|
// that zero-byte directory markers never appear as matches and that matching
|
||||||
|
// is a case-insensitive substring test on the full key.
|
||||||
|
func TestS3_SearchObjects_ExcludesDirectoryMarkersAndIsCaseInsensitive(t *testing.T) {
|
||||||
|
bucket := "b-TestS3_SearchObjects_ExcludesDirectoryMarkers"
|
||||||
|
page1 := []struct {
|
||||||
|
Key string
|
||||||
|
Size int64
|
||||||
|
LastModified string
|
||||||
|
ETag string
|
||||||
|
}{
|
||||||
|
{Key: "Reports/", Size: 0}, // directory marker — must be excluded
|
||||||
|
{Key: "Reports/Q1-REPORT.csv", Size: 5}, // matches "report" case-insensitively
|
||||||
|
{Key: "images/logo.png", Size: 5}, // no match
|
||||||
|
}
|
||||||
|
h, _, _ := s3SearchHandler(bucket, page1, nil)
|
||||||
|
s3 := newS3TestService(t, h)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got, err := s3.SearchObjects(ctx, bucket, "", "report")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchObjects: %v", err)
|
||||||
|
}
|
||||||
|
if got.Count != 1 || got.Objects[0].Key != "Reports/Q1-REPORT.csv" {
|
||||||
|
t.Fatalf("expected only Reports/Q1-REPORT.csv, got %+v", got.Objects)
|
||||||
|
}
|
||||||
|
for _, o := range got.Objects {
|
||||||
|
if strings.HasSuffix(o.Key, "/") {
|
||||||
|
t.Errorf("directory marker leaked into results: %q", o.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {ObjectsTable} from './ObjectsTable';
|
|||||||
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
|
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
|
||||||
import {DeleteObjectDialog} from './DeleteObjectDialog';
|
import {DeleteObjectDialog} from './DeleteObjectDialog';
|
||||||
import {UploadProgress} from './UploadProgress';
|
import {UploadProgress} from './UploadProgress';
|
||||||
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload} from 'lucide-react';
|
import {ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, ScanSearch, Search, Trash, Upload} from 'lucide-react';
|
||||||
import {getBreadcrumbs} from '@/lib/file-utils';
|
import {getBreadcrumbs} from '@/lib/file-utils';
|
||||||
import type {S3Object, UploadTask} from '@/types';
|
import type {S3Object, UploadTask} from '@/types';
|
||||||
|
|
||||||
@@ -15,11 +15,14 @@ interface ObjectBrowserViewProps {
|
|||||||
objects: S3Object[];
|
objects: S3Object[];
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
filterQuery: string;
|
||||||
|
deepSearch: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isTruncated?: boolean;
|
isTruncated?: boolean;
|
||||||
nextContinuationToken?: string;
|
nextContinuationToken?: string;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
|
onDeepSearchChange: (enabled: boolean) => void;
|
||||||
onNavigateToFolder: (path: string) => void;
|
onNavigateToFolder: (path: string) => void;
|
||||||
onBackToBuckets: () => void;
|
onBackToBuckets: () => void;
|
||||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||||
@@ -41,11 +44,14 @@ export function ObjectBrowserView({
|
|||||||
objects,
|
objects,
|
||||||
currentPath,
|
currentPath,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
filterQuery,
|
||||||
|
deepSearch,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isTruncated = false,
|
isTruncated = false,
|
||||||
nextContinuationToken,
|
nextContinuationToken,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
onDeepSearchChange,
|
||||||
onNavigateToFolder,
|
onNavigateToFolder,
|
||||||
onBackToBuckets,
|
onBackToBuckets,
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
@@ -199,14 +205,31 @@ export function ObjectBrowserView({
|
|||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
<div className="flex flex-1 items-center gap-2 max-w-full sm:max-w-md">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<div className="relative flex-1">
|
||||||
<Input
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
placeholder="Search objects..."
|
<Input
|
||||||
value={searchQuery}
|
placeholder={deepSearch ? 'Deep search names…' : 'Search by name prefix…'}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
value={searchQuery}
|
||||||
className="pl-8"
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
/>
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={deepSearch ? 'primary' : 'secondary'}
|
||||||
|
onClick={() => onDeepSearchChange(!deepSearch)}
|
||||||
|
aria-pressed={deepSearch}
|
||||||
|
title={
|
||||||
|
deepSearch
|
||||||
|
? 'Deep search: ON. Matches names anywhere and descends into subfolders. Scans the bucket, results may be partial on very large buckets. Click for fast prefix search.'
|
||||||
|
: 'Fast prefix search: matches the start of object names in this folder (like the AWS S3 / Cloudflare R2 console). Click to enable deep search (substring + subfolders).'
|
||||||
|
}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ScanSearch className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Deep</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{selectedFileKeys.size > 0 && (
|
{selectedFileKeys.size > 0 && (
|
||||||
@@ -352,6 +375,8 @@ export function ObjectBrowserView({
|
|||||||
objects={objects}
|
objects={objects}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
deepSearch={deepSearch}
|
||||||
selectedFileKeys={selectedFileKeys}
|
selectedFileKeys={selectedFileKeys}
|
||||||
isDragActive={isDragActive}
|
isDragActive={isDragActive}
|
||||||
isLoading={isLoading && !isRefreshing && !isNavigating}
|
isLoading={isLoading && !isRefreshing && !isNavigating}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface ObjectsTableProps {
|
|||||||
objects: S3Object[];
|
objects: S3Object[];
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
filterQuery: string;
|
||||||
|
deepSearch: boolean;
|
||||||
selectedFileKeys: Set<string>;
|
selectedFileKeys: Set<string>;
|
||||||
isDragActive: boolean;
|
isDragActive: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -46,6 +48,8 @@ export function ObjectsTable({
|
|||||||
objects,
|
objects,
|
||||||
currentPath,
|
currentPath,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
|
filterQuery,
|
||||||
|
deepSearch,
|
||||||
selectedFileKeys,
|
selectedFileKeys,
|
||||||
isDragActive,
|
isDragActive,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -86,7 +90,9 @@ export function ObjectsTable({
|
|||||||
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
}, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]);
|
||||||
|
|
||||||
const filteredObjects = useMemo(() => {
|
const filteredObjects = useMemo(() => {
|
||||||
const query = searchQuery.toLowerCase();
|
// Filter on the debounced query, not the raw input, so the list only
|
||||||
|
// updates once typing pauses (matches the debounced server request).
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
|
const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query));
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
const aIsFolder = a.isFolder ? 1 : 0;
|
const aIsFolder = a.isFolder ? 1 : 0;
|
||||||
@@ -96,8 +102,8 @@ export function ObjectsTable({
|
|||||||
let compareValue = 0;
|
let compareValue = 0;
|
||||||
switch (sortColumn) {
|
switch (sortColumn) {
|
||||||
case 'name': {
|
case 'name': {
|
||||||
const aName = a.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
const aName = a.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase();
|
||||||
const bName = b.key.replace(currentPath, '').replace('/', '').toLowerCase();
|
const bName = b.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase();
|
||||||
compareValue = aName.localeCompare(bName);
|
compareValue = aName.localeCompare(bName);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -114,13 +120,15 @@ export function ObjectsTable({
|
|||||||
|
|
||||||
return sortDirection === 'asc' ? compareValue : -compareValue;
|
return sortDirection === 'asc' ? compareValue : -compareValue;
|
||||||
});
|
});
|
||||||
}, [objects, searchQuery, sortColumn, sortDirection, currentPath]);
|
}, [objects, filterQuery, sortColumn, sortDirection, currentPath]);
|
||||||
|
|
||||||
// Effect 2: Reset pagination ONLY on path navigation
|
// Effect 2: Reset pagination on path navigation or when a search begins/ends.
|
||||||
|
// Search results are a single flat list, so page-token state must not leak
|
||||||
|
// across the search/browse boundary.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageTokens([undefined]);
|
setPageTokens([undefined]);
|
||||||
setCurrentPageIndex(0);
|
setCurrentPageIndex(0);
|
||||||
}, [currentPath]);
|
}, [currentPath, searchQuery, deepSearch]);
|
||||||
|
|
||||||
// Update page tokens when we get a new next token
|
// Update page tokens when we get a new next token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -137,11 +145,33 @@ export function ObjectsTable({
|
|||||||
}
|
}
|
||||||
}, [nextContinuationToken, isTruncated, currentPageIndex]);
|
}, [nextContinuationToken, isTruncated, currentPageIndex]);
|
||||||
|
|
||||||
const hasPrevious = currentPageIndex > 0;
|
// Prefix search and normal browsing are server-paginated (query folded into
|
||||||
const hasNext = isTruncated;
|
// the prefix; continuation tokens for pages). Deep search loads the whole
|
||||||
|
// capped result set in one response, so we paginate that on the client by
|
||||||
|
// itemsPerPage instead of dumping every match at once.
|
||||||
|
const isDeepSearching = deepSearch && searchQuery.trim().length > 0;
|
||||||
|
const clientPaginated = isDeepSearching;
|
||||||
|
const totalPages = clientPaginated
|
||||||
|
? Math.max(1, Math.ceil(filteredObjects.length / itemsPerPage))
|
||||||
|
: 1;
|
||||||
|
// Clamp during render (not via a setState effect) so a shrinking result set
|
||||||
|
// or a larger page size can't strand us on an out-of-range page.
|
||||||
|
const pageIndex = clientPaginated ? Math.min(currentPageIndex, totalPages - 1) : currentPageIndex;
|
||||||
|
const pageObjects = clientPaginated
|
||||||
|
? filteredObjects.slice(pageIndex * itemsPerPage, (pageIndex + 1) * itemsPerPage)
|
||||||
|
: filteredObjects;
|
||||||
|
const hasPrevious = pageIndex > 0;
|
||||||
|
const hasNext = clientPaginated ? pageIndex < totalPages - 1 : isTruncated;
|
||||||
|
|
||||||
const handleNextPage = () => {
|
const handleNextPage = () => {
|
||||||
if (hasNext && nextContinuationToken) {
|
if (!hasNext) return;
|
||||||
|
// Client-paginated (deep search): just advance the slice, no server fetch.
|
||||||
|
if (clientPaginated) {
|
||||||
|
setCurrentPageIndex(pageIndex + 1);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextContinuationToken) {
|
||||||
const nextIndex = currentPageIndex + 1;
|
const nextIndex = currentPageIndex + 1;
|
||||||
setCurrentPageIndex(nextIndex);
|
setCurrentPageIndex(nextIndex);
|
||||||
onPageChange(nextContinuationToken);
|
onPageChange(nextContinuationToken);
|
||||||
@@ -150,13 +180,16 @@ export function ObjectsTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviousPage = () => {
|
const handlePreviousPage = () => {
|
||||||
if (hasPrevious) {
|
if (!hasPrevious) return;
|
||||||
const prevIndex = currentPageIndex - 1;
|
if (clientPaginated) {
|
||||||
setCurrentPageIndex(prevIndex);
|
setCurrentPageIndex(pageIndex - 1);
|
||||||
const previousToken = pageTokens[prevIndex];
|
|
||||||
onPageChange(previousToken);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const prevIndex = currentPageIndex - 1;
|
||||||
|
setCurrentPageIndex(prevIndex);
|
||||||
|
onPageChange(pageTokens[prevIndex]);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleItemsPerPageChange = (value: string) => {
|
const handleItemsPerPageChange = (value: string) => {
|
||||||
@@ -235,7 +268,7 @@ export function ObjectsTable({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredObjects.map((obj) => (
|
pageObjects.map((obj) => (
|
||||||
<TableRow key={obj.key}>
|
<TableRow key={obj.key}>
|
||||||
<TableCell className="w-[50px]">
|
<TableCell className="w-[50px]">
|
||||||
{obj.isFolder ? (
|
{obj.isFolder ? (
|
||||||
@@ -265,7 +298,7 @@ export function ObjectsTable({
|
|||||||
onClick={() => onNavigateToFolder(obj.key)}
|
onClick={() => onNavigateToFolder(obj.key)}
|
||||||
className="font-medium cursor-pointer underline hover:text-primary"
|
className="font-medium cursor-pointer underline hover:text-primary"
|
||||||
>
|
>
|
||||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
{obj.key.replace(currentPath, '').replace(/\/$/, '')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -395,7 +428,9 @@ export function ObjectsTable({
|
|||||||
{/* Pagination info and controls */}
|
{/* Pagination info and controls */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Page {currentPageIndex + 1} • Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
|
{isDeepSearching
|
||||||
|
? `Page ${pageIndex + 1} of ${totalPages} • ${filteredObjects.length} match${filteredObjects.length !== 1 ? 'es' : ''}${isTruncated ? ' (capped, refine to narrow)' : ''}`
|
||||||
|
: `Page ${pageIndex + 1} • Showing ${pageObjects.length} item${pageObjects.length !== 1 ? 's' : ''}`}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { objectsApi } from '@/lib/api';
|
|||||||
import type { S3Object, UploadTask } from '@/types';
|
import type { S3Object, UploadTask } from '@/types';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function useBucketObjects(bucketName: string | null, currentPath: string = '') {
|
// How long to wait after the last keystroke before actually searching. Keeps
|
||||||
|
// typing from firing a request (and a client-side re-filter) on every key.
|
||||||
|
const SEARCH_DEBOUNCE_MS = 750;
|
||||||
|
|
||||||
|
export function useBucketObjects(bucketName: string | null, currentPath: string = '', searchQuery: string = '', deepSearch: boolean = false) {
|
||||||
const [objects, setObjects] = useState<S3Object[]>([]);
|
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
@@ -13,13 +17,25 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
|||||||
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
const [nextContinuationToken, setNextContinuationToken] = useState<string | undefined>(undefined);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||||
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
const [currentContinuationToken, setCurrentContinuationToken] = useState<string | undefined>(undefined);
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const previousPathRef = useRef<string>(currentPath);
|
const previousPathRef = useRef<string>(currentPath);
|
||||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||||
const clearTasksTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const clearTasksTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// Monotonic sequence guarding against stale responses: when a newer fetch or
|
||||||
|
// search starts, older in-flight responses are discarded instead of clobbering
|
||||||
|
// the current view (e.g. a slow search resolving after the query was cleared).
|
||||||
|
const fetchSeqRef = useRef(0);
|
||||||
|
|
||||||
|
// Prefix search (the default) narrows the Garage listing to keys starting with
|
||||||
|
// the query, within the current folder — server-side, paginated, and O(matches)
|
||||||
|
// like the AWS S3 / R2 consoles. Deep search instead uses a recursive scan
|
||||||
|
// (see searchObjects) and does not touch listPrefix.
|
||||||
|
const listPrefix = debouncedSearch && !deepSearch ? currentPath + debouncedSearch : currentPath;
|
||||||
|
|
||||||
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
const fetchObjects = useCallback(async (continuationToken?: string, isRefresh = false, isNav = false) => {
|
||||||
if (!bucketName) return;
|
if (!bucketName) return;
|
||||||
|
|
||||||
|
const seq = ++fetchSeqRef.current;
|
||||||
try {
|
try {
|
||||||
if (isRefresh) {
|
if (isRefresh) {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
@@ -29,30 +45,70 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await objectsApi.list(bucketName, currentPath, itemsPerPage, continuationToken);
|
const response = await objectsApi.list(bucketName, listPrefix, itemsPerPage, continuationToken);
|
||||||
|
if (seq !== fetchSeqRef.current) return;
|
||||||
setObjects(response.objects);
|
setObjects(response.objects);
|
||||||
setIsTruncated(response.isTruncated);
|
setIsTruncated(response.isTruncated);
|
||||||
setNextContinuationToken(response.nextContinuationToken);
|
setNextContinuationToken(response.nextContinuationToken);
|
||||||
setCurrentContinuationToken(continuationToken);
|
setCurrentContinuationToken(continuationToken);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (seq !== fetchSeqRef.current) return;
|
||||||
setError(err as Error);
|
setError(err as Error);
|
||||||
console.error('Failed to fetch objects:', err);
|
console.error('Failed to fetch objects:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (seq === fetchSeqRef.current) {
|
||||||
setIsRefreshing(false);
|
setIsLoading(false);
|
||||||
setIsNavigating(false);
|
setIsRefreshing(false);
|
||||||
|
setIsNavigating(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [bucketName, currentPath, itemsPerPage]);
|
}, [bucketName, listPrefix, itemsPerPage]);
|
||||||
|
|
||||||
|
const searchObjects = useCallback(async (query: string) => {
|
||||||
|
if (!bucketName) return;
|
||||||
|
|
||||||
|
const seq = ++fetchSeqRef.current;
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await objectsApi.search(bucketName, query, currentPath || undefined);
|
||||||
|
if (seq !== fetchSeqRef.current) return;
|
||||||
|
setObjects(response.objects);
|
||||||
|
setIsTruncated(response.isTruncated);
|
||||||
|
// Search results are not token-paginated.
|
||||||
|
setNextContinuationToken(undefined);
|
||||||
|
setCurrentContinuationToken(undefined);
|
||||||
|
} catch (err) {
|
||||||
|
if (seq !== fetchSeqRef.current) return;
|
||||||
|
setError(err as Error);
|
||||||
|
console.error('Failed to search objects:', err);
|
||||||
|
} finally {
|
||||||
|
if (seq === fetchSeqRef.current) setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [bucketName, currentPath]);
|
||||||
|
|
||||||
|
// Debounce the search query so we don't fire a recursive scan per keystroke.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSearch(searchQuery.trim()), SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bucketName) return;
|
if (!bucketName) return;
|
||||||
|
|
||||||
|
// Deep search: recursive substring scan across the current subtree.
|
||||||
|
if (debouncedSearch && deepSearch) {
|
||||||
|
searchObjects(debouncedSearch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal listing, or prefix-filtered listing (listPrefix carries the query).
|
||||||
const isPathChange = previousPathRef.current !== currentPath && objects.length > 0;
|
const isPathChange = previousPathRef.current !== currentPath && objects.length > 0;
|
||||||
previousPathRef.current = currentPath;
|
previousPathRef.current = currentPath;
|
||||||
|
|
||||||
fetchObjects(undefined, false, isPathChange);
|
fetchObjects(undefined, false, isPathChange);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [bucketName, currentPath, itemsPerPage]);
|
}, [bucketName, currentPath, itemsPerPage, debouncedSearch, deepSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -194,6 +250,9 @@ export function useBucketObjects(bucketName: string | null, currentPath: string
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
objects,
|
objects,
|
||||||
|
// The debounced query the current results reflect — use this (not the raw
|
||||||
|
// input) to filter/label results so the view waits instead of twitching.
|
||||||
|
debouncedSearch,
|
||||||
isLoading,
|
isLoading,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
isNavigating,
|
isNavigating,
|
||||||
|
|||||||
@@ -293,6 +293,39 @@ export const objectsApi = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Recursive, best-effort substring search across all objects under `prefix`.
|
||||||
|
// The backend scans and filters (S3/Garage has no server-side substring
|
||||||
|
// search), so this finds matches regardless of which page they'd be on.
|
||||||
|
search: async (bucket: string, query: string, prefix?: string): Promise<ObjectListResponse> => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const params: any = { search: query };
|
||||||
|
if (prefix) params.prefix = prefix;
|
||||||
|
|
||||||
|
const response = await api.get(`/v1/buckets/${bucket}/objects`, { params });
|
||||||
|
const data = response.data.data;
|
||||||
|
|
||||||
|
// Search returns a flat list of matching objects (no folders/prefixes).
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const objects: S3Object[] = data.objects?.map((obj: any) => ({
|
||||||
|
key: obj.key,
|
||||||
|
size: obj.size,
|
||||||
|
lastModified: obj.last_modified,
|
||||||
|
etag: obj.etag,
|
||||||
|
contentType: obj.content_type,
|
||||||
|
storageClass: obj.storage_class,
|
||||||
|
isFolder: false,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
bucket: data.bucket,
|
||||||
|
objects,
|
||||||
|
prefixes: [],
|
||||||
|
count: data.count,
|
||||||
|
isTruncated: data.is_truncated || false,
|
||||||
|
nextContinuationToken: data.next_continuation_token,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
get: async (bucket: string, key: string): Promise<Blob> => {
|
get: async (bucket: string, key: string): Promise<Blob> => {
|
||||||
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
|
const response = await api.get(`/v1/buckets/${bucket}/objects/${encodeObjectKey(key)}`, {
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export function AccessControl() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<DialogTitle>API key created</DialogTitle>
|
<DialogTitle>API key created</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Copy your secret access key now — this is the only time it will be shown.
|
Copy your secret access key now, this is the only time it will be shown.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -742,7 +742,7 @@ export function AccessControl() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-[13.5px] font-medium">Grant bucket permissions now</div>
|
<div className="text-[13.5px] font-medium">Grant bucket permissions now</div>
|
||||||
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
|
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
|
||||||
Optional — you can also do this later from the key's edit menu.
|
Optional, you can also do this later from the key's edit menu.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function BucketObjects() {
|
|||||||
|
|
||||||
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
|
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [deepSearch, setDeepSearch] = useState(false);
|
||||||
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
|
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
|
||||||
searchParams.get('page') ?? undefined,
|
searchParams.get('page') ?? undefined,
|
||||||
);
|
);
|
||||||
@@ -27,6 +28,7 @@ export function BucketObjects() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
objects,
|
objects,
|
||||||
|
debouncedSearch,
|
||||||
isLoading,
|
isLoading,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
isNavigating,
|
isNavigating,
|
||||||
@@ -40,10 +42,13 @@ export function BucketObjects() {
|
|||||||
deleteMultipleObjects,
|
deleteMultipleObjects,
|
||||||
createDirectory,
|
createDirectory,
|
||||||
fetchObjects,
|
fetchObjects,
|
||||||
} = useBucketObjects(bucketName, currentPath);
|
} = useBucketObjects(bucketName, currentPath, searchQuery, deepSearch);
|
||||||
|
|
||||||
const handleNavigateToFolder = (path: string) => {
|
const handleNavigateToFolder = (path: string) => {
|
||||||
setCurrentPath(path);
|
setCurrentPath(path);
|
||||||
|
// Navigating to a folder should show that folder's contents, not a stale
|
||||||
|
// filter carried over from the folder we came from.
|
||||||
|
setSearchQuery('');
|
||||||
const next = new URLSearchParams();
|
const next = new URLSearchParams();
|
||||||
if (path) next.set('prefix', path);
|
if (path) next.set('prefix', path);
|
||||||
setSearchParams(next);
|
setSearchParams(next);
|
||||||
@@ -97,11 +102,14 @@ export function BucketObjects() {
|
|||||||
objects={objects}
|
objects={objects}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
|
filterQuery={debouncedSearch}
|
||||||
|
deepSearch={deepSearch}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isTruncated={isTruncated}
|
isTruncated={isTruncated}
|
||||||
nextContinuationToken={nextContinuationToken}
|
nextContinuationToken={nextContinuationToken}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
|
onDeepSearchChange={setDeepSearch}
|
||||||
onNavigateToFolder={handleNavigateToFolder}
|
onNavigateToFolder={handleNavigateToFolder}
|
||||||
onBackToBuckets={handleBackToBuckets}
|
onBackToBuckets={handleBackToBuckets}
|
||||||
onUploadFiles={uploadFiles}
|
onUploadFiles={uploadFiles}
|
||||||
|
|||||||
Reference in New Issue
Block a user