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:
Noste
2026-07-11 17:09:27 +02:00
committed by GitHub
parent b28e975a56
commit 3098e474f2
12 changed files with 494 additions and 56 deletions
+22 -6
View File
@@ -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")
+46
View File
@@ -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) {
+1
View File
@@ -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)
+16 -7
View File
@@ -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 {
+92 -7
View File
@@ -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
+121
View File
@@ -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">
+66 -7
View File
@@ -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,
+33
View File
@@ -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'
+2 -2
View File
@@ -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>
+9 -1
View File
@@ -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}