@marimo-team/frontend 0.23.10-dev56 → 0.23.10-dev57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43,10 +43,12 @@ import {
43
43
  useStorage,
44
44
  useStorageActions,
45
45
  useStorageEntries,
46
+ useStoragePageFetcher,
46
47
  } from "@/core/storage/state";
47
48
  import type {
48
49
  StorageEntry,
49
50
  StorageNamespace,
51
+ StoragePageMetadata,
50
52
  StoragePathKey,
51
53
  } from "@/core/storage/types";
52
54
  import { storagePathKey } from "@/core/storage/types";
@@ -94,6 +96,10 @@ function displayName(path: string): string {
94
96
  return parts[parts.length - 1] || trimmed;
95
97
  }
96
98
 
99
+ function directoryPrefix(path: string): string {
100
+ return path.endsWith("/") ? path : `${path}/`;
101
+ }
102
+
97
103
  /**
98
104
  * Stable, unique identity for an entry row. Prefer the
99
105
  * backend's stable id when present and fall back to the list index
@@ -120,15 +126,19 @@ function entryMatchesSearch(
120
126
  entry: StorageEntry,
121
127
  { namespace, searchValue, entriesByPath }: SearchContext,
122
128
  ): boolean {
123
- const query = searchValue.toLowerCase();
129
+ const query = searchValue.trim().toLowerCase();
130
+ const path = entry.path.toLowerCase();
131
+ const name = displayName(entry.path).toLowerCase();
124
132
 
125
- if (displayName(entry.path).toLowerCase().includes(query)) {
133
+ if (name.includes(query) || path.includes(query)) {
126
134
  return true;
127
135
  }
128
136
 
129
137
  // For directories, check loaded children recursively
130
138
  if (entry.kind === "directory") {
131
- const children = entriesByPath.get(storagePathKey(namespace, entry.path));
139
+ const children = entriesByPath.get(
140
+ storagePathKey(namespace, directoryPrefix(entry.path)),
141
+ );
132
142
  if (children) {
133
143
  return children.some((child) =>
134
144
  entryMatchesSearch(child, { namespace, searchValue, entriesByPath }),
@@ -143,7 +153,7 @@ function entryMatchesSearch(
143
153
  * Filter entries to those matching the search (or having loaded descendants
144
154
  * that match). Returns all entries when there is no active search.
145
155
  */
146
- function filterEntries(
156
+ export function filterEntries(
147
157
  entries: StorageEntry[],
148
158
  context: SearchContext,
149
159
  ): StorageEntry[] {
@@ -153,6 +163,93 @@ function filterEntries(
153
163
  return entries.filter((entry) => entryMatchesSearch(entry, context));
154
164
  }
155
165
 
166
+ const MAX_REMOTE_SEARCH_PAGES = 5;
167
+
168
+ type RemoteSearchState =
169
+ | { query: string; status: "idle" }
170
+ | { query: string; status: "searching" }
171
+ | { query: string; status: "found" }
172
+ | { query: string; status: "exhausted" }
173
+ | { query: string; status: "capped" }
174
+ | { query: string; status: "error"; error: Error };
175
+
176
+ type RemoteSearchByNamespace = Record<string, RemoteSearchState>;
177
+
178
+ function idleRemoteSearch(query: string): RemoteSearchState {
179
+ return { query, status: "idle" };
180
+ }
181
+
182
+ function canRetryRemoteSearch(remoteSearch: RemoteSearchState): boolean {
183
+ return (
184
+ remoteSearch.status === "idle" ||
185
+ remoteSearch.status === "error" ||
186
+ remoteSearch.status === "capped"
187
+ );
188
+ }
189
+
190
+ function canSearchMoreRemoteEntries({
191
+ hasSearch,
192
+ hasLoadedMatches,
193
+ isPending,
194
+ remoteSearch,
195
+ searchKey,
196
+ entriesByPath,
197
+ pageMetadataByPath,
198
+ }: {
199
+ hasSearch: boolean;
200
+ hasLoadedMatches: boolean;
201
+ isPending: boolean;
202
+ remoteSearch: RemoteSearchState;
203
+ searchKey: StoragePathKey;
204
+ entriesByPath: ReadonlyMap<StoragePathKey, StorageEntry[]>;
205
+ pageMetadataByPath: ReadonlyMap<StoragePathKey, StoragePageMetadata>;
206
+ }): boolean {
207
+ if (!hasSearch || hasLoadedMatches || isPending) {
208
+ return false;
209
+ }
210
+ if (!canRetryRemoteSearch(remoteSearch)) {
211
+ return false;
212
+ }
213
+
214
+ return (
215
+ entriesByPath.get(searchKey) === undefined ||
216
+ pageMetadataByPath.get(searchKey)?.nextPageToken != null
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Returns the directory prefix to query the backend with for a given search.
222
+ *
223
+ * Object stores like obstore evaluate prefixes on a path-segment basis
224
+ * (`folder/x` would only match `folder/x/...`, never `folder/xsomething`), so
225
+ * for substring searches we list the parent directory and filter on the
226
+ * client. Returns `""` when the search has no directory component.
227
+ */
228
+ export function remoteSearchPrefix(searchValue: string): string {
229
+ const trimmed = searchValue.trim();
230
+ const lastSlash = trimmed.lastIndexOf("/");
231
+ return lastSlash === -1 ? "" : trimmed.slice(0, lastSlash + 1);
232
+ }
233
+
234
+ /**
235
+ * Shallow check (no recursion into loaded children) used inside the
236
+ * remote-search pagination loop to decide whether a fetched page has
237
+ * any candidates worth surfacing to the user.
238
+ */
239
+ function entryMatchesQueryShallow(
240
+ entry: StorageEntry,
241
+ searchValue: string,
242
+ ): boolean {
243
+ const query = searchValue.trim().toLowerCase();
244
+ if (!query) {
245
+ return true;
246
+ }
247
+ return (
248
+ entry.path.toLowerCase().includes(query) ||
249
+ displayName(entry.path).toLowerCase().includes(query)
250
+ );
251
+ }
252
+
156
253
  const LoadMoreStorageEntries: React.FC<{
157
254
  depth: number;
158
255
  isLoading: boolean;
@@ -325,7 +422,7 @@ const StorageEntryRow: React.FC<{
325
422
  isDir &&
326
423
  hasSearch &&
327
424
  !!entriesByPath
328
- .get(storagePathKey(namespace, entry.path))
425
+ .get(storagePathKey(namespace, directoryPrefix(entry.path)))
329
426
  ?.some((child) =>
330
427
  entryMatchesSearch(child, { namespace, searchValue, entriesByPath }),
331
428
  );
@@ -474,7 +571,7 @@ const StorageEntryRow: React.FC<{
474
571
  protocol={protocol}
475
572
  rootPath={rootPath}
476
573
  backendType={backendType}
477
- prefix={entry.path}
574
+ prefix={directoryPrefix(entry.path)}
478
575
  depth={depth + 1}
479
576
  locale={locale}
480
577
  searchValue={selfMatches ? "" : searchValue} // When a parent directory matches the search, we don't need to filter the children.
@@ -489,10 +586,19 @@ const StorageNamespaceSection: React.FC<{
489
586
  namespace: StorageNamespace;
490
587
  locale: string;
491
588
  searchValue: string;
589
+ remoteSearch: RemoteSearchState;
590
+ onContinueRemoteSearch: () => void;
492
591
  onOpenFile: (info: OpenFileInfo) => void;
493
- }> = ({ namespace, locale, searchValue, onOpenFile }) => {
592
+ }> = ({
593
+ namespace,
594
+ locale,
595
+ searchValue,
596
+ remoteSearch,
597
+ onContinueRemoteSearch,
598
+ onOpenFile,
599
+ }) => {
494
600
  const [isExpanded, setIsExpanded] = useState(true);
495
- const { entriesByPath } = useStorage();
601
+ const { entriesByPath, pageMetadataByPath } = useStorage();
496
602
  const { clearNamespaceCache } = useStorageActions();
497
603
  const namespaceName = namespace.name ?? namespace.displayName;
498
604
 
@@ -523,6 +629,103 @@ const StorageNamespaceSection: React.FC<{
523
629
  searchValue,
524
630
  entriesByPath,
525
631
  });
632
+ const searchPrefix = remoteSearchPrefix(searchValue);
633
+ const searchKey = storagePathKey(namespaceName, searchPrefix);
634
+ const remoteEntries =
635
+ searchPrefix === "" ? [] : (entriesByPath.get(searchKey) ?? []);
636
+ // The fetched page is the whole parent directory; we still need to filter
637
+ // it by the full search query before showing entries to the user.
638
+ const filteredRemoteEntries =
639
+ remoteEntries.length > 0
640
+ ? filterEntries(remoteEntries, {
641
+ namespace: namespaceName,
642
+ searchValue,
643
+ entriesByPath,
644
+ })
645
+ : remoteEntries;
646
+ const hasSearch = !!searchValue.trim();
647
+ const hasLoadedMatches =
648
+ filtered.length > 0 || filteredRemoteEntries.length > 0;
649
+ const canSearchMore =
650
+ searchPrefix !== "" &&
651
+ canSearchMoreRemoteEntries({
652
+ hasSearch,
653
+ hasLoadedMatches,
654
+ isPending,
655
+ remoteSearch,
656
+ searchKey,
657
+ entriesByPath,
658
+ pageMetadataByPath,
659
+ });
660
+
661
+ const showRemoteResults = hasSearch && filtered.length === 0;
662
+ const statusRow = (() => {
663
+ if (isPending && entries.length === 0) {
664
+ return (
665
+ <span className="flex items-center gap-1.5">
666
+ <LoaderCircle className="h-3 w-3 animate-spin" />
667
+ Loading...
668
+ </span>
669
+ );
670
+ }
671
+ if (remoteSearch.status === "searching") {
672
+ return (
673
+ <span className="flex items-center gap-1.5">
674
+ <LoaderCircle className="h-3 w-3 animate-spin" />
675
+ Searching more entries...
676
+ </span>
677
+ );
678
+ }
679
+ if (remoteSearch.status === "error") {
680
+ return (
681
+ <span className="text-destructive">
682
+ Search failed: {remoteSearch.error.message}
683
+ </span>
684
+ );
685
+ }
686
+ if (remoteSearch.status === "capped") {
687
+ return (
688
+ <span className="flex items-center gap-1.5">
689
+ Searched more entries.
690
+ <Button
691
+ variant="text"
692
+ size="xs"
693
+ className="h-5 px-0 text-xs hover:text-blue-600"
694
+ onClick={onContinueRemoteSearch}
695
+ >
696
+ Continue searching
697
+ </Button>
698
+ <span className="text-[10px]">(or press Enter)</span>
699
+ </span>
700
+ );
701
+ }
702
+ if (remoteSearch.status === "exhausted" && !hasLoadedMatches) {
703
+ return "No matches";
704
+ }
705
+ if (!hasSearch && !isPending && entries.length === 0 && !error) {
706
+ return "No entries";
707
+ }
708
+ if (canSearchMore) {
709
+ return (
710
+ <span className="flex items-center gap-1.5">
711
+ No loaded matches.
712
+ <Button
713
+ variant="text"
714
+ size="xs"
715
+ className="h-5 px-0 text-xs hover:text-blue-600"
716
+ onClick={onContinueRemoteSearch}
717
+ >
718
+ Search more entries
719
+ </Button>
720
+ <span className="text-[10px]">(or press Enter)</span>
721
+ </span>
722
+ );
723
+ }
724
+ if (hasSearch && !hasLoadedMatches && entries.length > 0) {
725
+ return "No matches";
726
+ }
727
+ return null;
728
+ })();
526
729
 
527
730
  return (
528
731
  <>
@@ -551,15 +754,6 @@ const StorageNamespaceSection: React.FC<{
551
754
  </CommandItem>
552
755
  {isExpanded && (
553
756
  <>
554
- {isPending && entries.length === 0 && (
555
- <div
556
- className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground"
557
- style={indentStyle(1)}
558
- >
559
- <LoaderCircle className="h-3 w-3 animate-spin" />
560
- Loading...
561
- </div>
562
- )}
563
757
  {error && entries.length === 0 && (
564
758
  <ErrorState
565
759
  error={error}
@@ -568,20 +762,12 @@ const StorageNamespaceSection: React.FC<{
568
762
  showIcon={false}
569
763
  />
570
764
  )}
571
- {!isPending && entries.length === 0 && !error && (
765
+ {!error && statusRow && (
572
766
  <div
573
767
  className="py-1 text-xs text-muted-foreground italic"
574
768
  style={indentStyle(1)}
575
769
  >
576
- No entries
577
- </div>
578
- )}
579
- {searchValue && filtered.length === 0 && entries.length > 0 && (
580
- <div
581
- className="py-1 text-xs text-muted-foreground italic"
582
- style={indentStyle(1)}
583
- >
584
- No matches
770
+ {statusRow}
585
771
  </div>
586
772
  )}
587
773
  {filtered.map((entry) => {
@@ -602,7 +788,29 @@ const StorageNamespaceSection: React.FC<{
602
788
  />
603
789
  );
604
790
  })}
605
- {hasMore && (
791
+ {showRemoteResults &&
792
+ filteredRemoteEntries.map((entry) => {
793
+ const rowKey = storageEntryKey(
794
+ entry,
795
+ remoteEntries.indexOf(entry),
796
+ );
797
+ return (
798
+ <StorageEntryRow
799
+ key={`remote-search:${rowKey}`}
800
+ rowKey={`remote-search:${rowKey}`}
801
+ entry={entry}
802
+ namespace={namespaceName}
803
+ protocol={namespace.protocol}
804
+ rootPath={namespace.rootPath}
805
+ backendType={namespace.backendType}
806
+ depth={1}
807
+ locale={locale}
808
+ searchValue={searchValue}
809
+ onOpenFile={onOpenFile}
810
+ />
811
+ );
812
+ })}
813
+ {hasMore && !canSearchMore && (
606
814
  <LoadMoreStorageEntries
607
815
  depth={1}
608
816
  isLoading={isLoadingMore}
@@ -617,11 +825,162 @@ const StorageNamespaceSection: React.FC<{
617
825
  };
618
826
 
619
827
  export const StorageInspector: React.FC = () => {
620
- const { namespaces } = useStorage();
828
+ const { namespaces, entriesByPath, pageMetadataByPath } = useStorage();
621
829
  const { locale } = useLocale();
622
830
  const [searchValue, setSearchValue] = useState("");
831
+ const [remoteSearchByNamespace, setRemoteSearchByNamespace] =
832
+ useState<RemoteSearchByNamespace>({});
623
833
  const [openFile, setOpenFile] = useState<OpenFileInfo | null>(null);
834
+ const fetchStoragePage = useStoragePageFetcher();
624
835
  const hasSearch = !!searchValue.trim();
836
+ const currentQuery = searchValue.trim();
837
+
838
+ const remoteSearchForNamespace = useCallback(
839
+ (namespaceName: string): RemoteSearchState => {
840
+ const remoteSearch = remoteSearchByNamespace[namespaceName];
841
+ if (remoteSearch?.query === currentQuery) {
842
+ return remoteSearch;
843
+ }
844
+ return idleRemoteSearch(currentQuery);
845
+ },
846
+ [currentQuery, remoteSearchByNamespace],
847
+ );
848
+
849
+ const setRemoteSearch = useCallback(
850
+ (namespaceName: string, remoteSearch: RemoteSearchState) => {
851
+ setRemoteSearchByNamespace((state) => ({
852
+ ...state,
853
+ [namespaceName]: remoteSearch,
854
+ }));
855
+ },
856
+ [],
857
+ );
858
+
859
+ const canContinueRemoteSearch = useCallback(
860
+ (namespace: StorageNamespace): boolean => {
861
+ if (!currentQuery) {
862
+ return false;
863
+ }
864
+
865
+ const namespaceName = namespace.name ?? namespace.displayName;
866
+ const searchPrefix = remoteSearchPrefix(currentQuery);
867
+ // No directory component in the query - the user is doing a fuzzy
868
+ // search and the backend can't help; rely on local filtering instead.
869
+ if (searchPrefix === "") {
870
+ return false;
871
+ }
872
+
873
+ const remoteSearch = remoteSearchForNamespace(namespaceName);
874
+ if (!canRetryRemoteSearch(remoteSearch)) {
875
+ return false;
876
+ }
877
+
878
+ // Already surfacing matches from loaded entries?
879
+ const rootEntries =
880
+ entriesByPath.get(storagePathKey(namespaceName, "")) ??
881
+ namespace.storageEntries;
882
+ const rootMatches = filterEntries(rootEntries, {
883
+ namespace: namespaceName,
884
+ searchValue: currentQuery,
885
+ entriesByPath,
886
+ });
887
+ if (rootMatches.length > 0) {
888
+ return false;
889
+ }
890
+
891
+ const searchKey = storagePathKey(namespaceName, searchPrefix);
892
+ const prefixEntries = entriesByPath.get(searchKey) ?? [];
893
+ const prefixMatches = filterEntries(prefixEntries, {
894
+ namespace: namespaceName,
895
+ searchValue: currentQuery,
896
+ entriesByPath,
897
+ });
898
+ if (prefixMatches.length > 0) {
899
+ return false;
900
+ }
901
+
902
+ const canFetchPrefix =
903
+ entriesByPath.get(searchKey) === undefined ||
904
+ pageMetadataByPath.get(searchKey)?.nextPageToken != null;
905
+ return canFetchPrefix;
906
+ },
907
+ [currentQuery, entriesByPath, pageMetadataByPath, remoteSearchForNamespace],
908
+ );
909
+
910
+ const continueRemoteSearch = useCallback(
911
+ async (namespace: StorageNamespace) => {
912
+ if (!canContinueRemoteSearch(namespace)) {
913
+ return;
914
+ }
915
+
916
+ const query = currentQuery;
917
+ const namespaceName = namespace.name ?? namespace.displayName;
918
+ const prefix = remoteSearchPrefix(query);
919
+ const key = storagePathKey(namespaceName, prefix);
920
+ const cachedEntries = entriesByPath.get(key);
921
+ let nextPageToken = pageMetadataByPath.get(key)?.nextPageToken ?? null;
922
+ let hasFetchedAny = cachedEntries !== undefined;
923
+
924
+ setRemoteSearch(namespaceName, { query, status: "searching" });
925
+ try {
926
+ for (let page = 0; page < MAX_REMOTE_SEARCH_PAGES; page++) {
927
+ // First iteration with a stale cache hit needs no fetch; just check
928
+ // the cached page before paginating.
929
+ const shouldFetch = !hasFetchedAny || nextPageToken !== null;
930
+ let newEntries: StorageEntry[] = [];
931
+ if (shouldFetch) {
932
+ const result = await fetchStoragePage({
933
+ namespace: namespaceName,
934
+ prefix,
935
+ pageToken: nextPageToken,
936
+ append: hasFetchedAny,
937
+ });
938
+ newEntries = result.entries;
939
+ nextPageToken = result.next_page_token ?? null;
940
+ hasFetchedAny = true;
941
+ }
942
+
943
+ const entriesToCheck = shouldFetch
944
+ ? newEntries
945
+ : (cachedEntries ?? []);
946
+ const hasMatches = entriesToCheck.some((entry) =>
947
+ entryMatchesQueryShallow(entry, query),
948
+ );
949
+ if (hasMatches) {
950
+ setRemoteSearch(namespaceName, { query, status: "found" });
951
+ return;
952
+ }
953
+
954
+ if (nextPageToken === null) {
955
+ setRemoteSearch(namespaceName, { query, status: "exhausted" });
956
+ return;
957
+ }
958
+ }
959
+ setRemoteSearch(namespaceName, { query, status: "capped" });
960
+ } catch (error) {
961
+ setRemoteSearch(namespaceName, {
962
+ query,
963
+ status: "error",
964
+ error: error instanceof Error ? error : new Error(String(error)),
965
+ });
966
+ }
967
+ },
968
+ [
969
+ canContinueRemoteSearch,
970
+ currentQuery,
971
+ entriesByPath,
972
+ fetchStoragePage,
973
+ pageMetadataByPath,
974
+ setRemoteSearch,
975
+ ],
976
+ );
977
+
978
+ const continueRemoteSearches = useCallback(() => {
979
+ const searchableNamespaces = namespaces.filter(canContinueRemoteSearch);
980
+ for (const namespace of searchableNamespaces) {
981
+ void continueRemoteSearch(namespace);
982
+ }
983
+ }, [canContinueRemoteSearch, continueRemoteSearch, namespaces]);
625
984
 
626
985
  if (namespaces.length === 0) {
627
986
  return (
@@ -680,6 +1039,15 @@ export const StorageInspector: React.FC = () => {
680
1039
  className="h-6 m-1"
681
1040
  value={searchValue}
682
1041
  onValueChange={setSearchValue}
1042
+ onKeyDown={(event) => {
1043
+ if (
1044
+ event.key === "Enter" &&
1045
+ namespaces.some(canContinueRemoteSearch)
1046
+ ) {
1047
+ event.preventDefault();
1048
+ continueRemoteSearches();
1049
+ }
1050
+ }}
683
1051
  rootClassName="flex-1 border-b-0"
684
1052
  />
685
1053
  {hasSearch && (
@@ -693,7 +1061,7 @@ export const StorageInspector: React.FC = () => {
693
1061
  </Button>
694
1062
  )}
695
1063
  <Tooltip
696
- content="Filters loaded entries only. Expand directories to include their contents in the search."
1064
+ content="Filters loaded entries only. Expand directories or press Enter to search more entries from the backend."
697
1065
  delayDuration={200}
698
1066
  >
699
1067
  <HelpCircleIcon className="h-3.5 w-3.5 shrink-0 cursor-help text-muted-foreground hover:text-foreground mr-2" />
@@ -709,15 +1077,20 @@ export const StorageInspector: React.FC = () => {
709
1077
  </AddConnectionDialog>
710
1078
  </div>
711
1079
  <CommandList className="flex flex-col">
712
- {namespaces.map((ns) => (
713
- <StorageNamespaceSection
714
- key={ns.name ?? ns.displayName}
715
- namespace={ns}
716
- locale={locale}
717
- searchValue={searchValue}
718
- onOpenFile={setOpenFile}
719
- />
720
- ))}
1080
+ {namespaces.map((ns) => {
1081
+ const namespaceName = ns.name ?? ns.displayName;
1082
+ return (
1083
+ <StorageNamespaceSection
1084
+ key={namespaceName}
1085
+ namespace={ns}
1086
+ locale={locale}
1087
+ searchValue={searchValue}
1088
+ remoteSearch={remoteSearchForNamespace(namespaceName)}
1089
+ onContinueRemoteSearch={() => void continueRemoteSearch(ns)}
1090
+ onOpenFile={setOpenFile}
1091
+ />
1092
+ );
1093
+ })}
721
1094
  </CommandList>
722
1095
  </Command>
723
1096
  </div>