diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index 7a50e62..00222c5 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -15,7 +15,7 @@ import { Operation } from "../../components/OperationsMenu.js"; import { ActionsPopup } from "../../components/ActionsPopup.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { SearchBar } from "../../components/SearchBar.js"; -import { output, outputError } from "../../utils/output.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; import { getBlueprintUrl } from "../../utils/url.js"; import { colors } from "../../utils/theme.js"; import { getStatusDisplay } from "../../components/StatusBadge.js"; @@ -148,7 +148,7 @@ const ListBlueprintsUI = ({ const result = { items: pageBlueprints, hasMore: page.has_more || false, - totalCount: page.total_count || pageBlueprints.length, + totalCount: pageBlueprints.length, }; return result; @@ -179,7 +179,7 @@ const ListBlueprintsUI = ({ !executingOperation && !showDeleteConfirm && !search.searchMode, - deps: [PAGE_SIZE, search.submittedSearchQuery], + deps: [search.submittedSearchQuery], }); // Memoize columns array @@ -624,10 +624,18 @@ const ListBlueprintsUI = ({ bindings: { up: () => { if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1); + else if (!loading && !navigating && hasPrev) { + prevPage(); + setSelectedIndex(blueprints.length - 1); + } }, down: () => { if (selectedIndex < blueprints.length - 1) setSelectedIndex(selectedIndex + 1); + else if (!loading && !navigating && hasMore) { + nextPage(); + setSelectedIndex(0); + } }, n: goToNextPage, right: goToNextPage, @@ -875,7 +883,7 @@ const ListBlueprintsUI = ({ data={blueprints} keyExtractor={(blueprint: BlueprintListItem) => blueprint.id} selectedIndex={selectedIndex} - title={`blueprints[${totalCount}]`} + title={`blueprints[${hasMore ? `${totalCount}+` : totalCount}]`} columns={blueprintColumns} emptyState={ @@ -889,7 +897,7 @@ const ListBlueprintsUI = ({ {!showPopup && ( - {figures.hamburger} {totalCount} + {figures.hamburger} {hasMore ? `${totalCount}+` : totalCount} {" "} @@ -907,7 +915,8 @@ const ListBlueprintsUI = ({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} of{" "} + {hasMore ? `${totalPages}+` : totalPages} )} @@ -917,7 +926,8 @@ const ListBlueprintsUI = ({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} of{" "} + {hasMore ? `${totalCount}+` : totalCount} {search.submittedSearchQuery && ( <> @@ -982,6 +992,7 @@ const ListBlueprintsUI = ({ interface ListBlueprintsOptions { name?: string; + limit?: string; output?: string; } @@ -992,23 +1003,45 @@ export async function listBlueprints(options: ListBlueprintsOptions = {}) { try { const client = getClient(); - // Build query params - const queryParams: Record = { - limit: DEFAULT_PAGE_SIZE, - }; - if (options.name) { - queryParams.name = options.name; - } + const maxResults = parseLimit(options.limit); + const allBlueprints: unknown[] = []; + let startingAfter: string | undefined; - // Fetch blueprints - const page = (await client.blueprints.list( - queryParams, - )) as BlueprintsCursorIDPage<{ id: string }>; + do { + const remaining = maxResults - allBlueprints.length; + // Build query params + const queryParams: Record = { + limit: Math.min(DEFAULT_PAGE_SIZE, remaining), + }; + if (options.name) { + queryParams.name = options.name; + } + if (startingAfter) { + queryParams.starting_after = startingAfter; + } - // Extract blueprints array - const blueprints = page.blueprints || []; + // Fetch one page + const page = (await client.blueprints.list( + queryParams, + )) as BlueprintsCursorIDPage<{ id: string }>; + + const pageBlueprints = page.blueprints || []; + allBlueprints.push(...pageBlueprints); + + if ( + page.has_more && + pageBlueprints.length > 0 && + allBlueprints.length < maxResults + ) { + startingAfter = ( + pageBlueprints[pageBlueprints.length - 1] as { id: string } + ).id; + } else { + startingAfter = undefined; + } + } while (startingAfter !== undefined); - output(blueprints, { format: options.output, defaultFormat: "json" }); + output(allBlueprints, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list blueprints", error); } diff --git a/src/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 7abe0b1..e09c409 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -12,7 +12,7 @@ import type { Column } from "../../components/Table.js"; import { Table, createTextColumn } from "../../components/Table.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { SearchBar } from "../../components/SearchBar.js"; -import { output, outputError } from "../../utils/output.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; import { DevboxDetailPage } from "../../components/DevboxDetailPage.js"; import { DevboxCreatePage } from "../../components/DevboxCreatePage.js"; import { ResourceActionsMenu } from "../../components/ResourceActionsMenu.js"; @@ -117,7 +117,7 @@ const ListDevboxesUI = ({ const result = { items: pageDevboxes, hasMore: page.has_more || false, - totalCount: page.total_count || pageDevboxes.length, + totalCount: pageDevboxes.length, }; return result; @@ -148,7 +148,7 @@ const ListDevboxesUI = ({ !showActions && !showPopup && !search.searchMode, - deps: [status, search.submittedSearchQuery, PAGE_SIZE], + deps: [status, search.submittedSearchQuery], }); // Sync devboxes to store for detail screen @@ -559,10 +559,18 @@ const ListDevboxesUI = ({ bindings: { up: () => { if (selectedIndex > 0) setSelectedIndex(selectedIndex - 1); + else if (!loading && !navigating && hasPrev) { + prevPage(); + setSelectedIndex(devboxes.length - 1); + } }, down: () => { if (selectedIndex < devboxes.length - 1) setSelectedIndex(selectedIndex + 1); + else if (!loading && !navigating && hasMore) { + nextPage(); + setSelectedIndex(0); + } }, n: goToNextPage, right: goToNextPage, @@ -712,7 +720,7 @@ const ListDevboxesUI = ({ {!showPopup && ( - {figures.hamburger} {totalCount} + {figures.hamburger} {hasMore ? `${totalCount}+` : totalCount} {" "} @@ -730,7 +738,8 @@ const ListDevboxesUI = ({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} of{" "} + {hasMore ? `${totalPages}+` : totalPages} )} @@ -740,7 +749,8 @@ const ListDevboxesUI = ({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} of{" "} + {hasMore ? `${totalCount}+` : totalCount} {search.submittedSearchQuery && ( <> @@ -796,23 +806,45 @@ export async function listDevboxes(options: ListOptions) { try { const client = getClient(); - // Build query params - const queryParams: Record = { - limit: options.limit ? parseInt(options.limit, 10) : DEFAULT_PAGE_SIZE, - }; - if (options.status) { - queryParams.status = options.status; - } + const maxResults = parseLimit(options.limit); + const allDevboxes: unknown[] = []; + let startingAfter: string | undefined; - // Fetch devboxes - const page = (await client.devboxes.list( - queryParams, - )) as DevboxesCursorIDPage<{ id: string }>; + do { + const remaining = maxResults - allDevboxes.length; + // Build query params + const queryParams: Record = { + limit: Math.min(DEFAULT_PAGE_SIZE, remaining), + }; + if (options.status) { + queryParams.status = options.status; + } + if (startingAfter) { + queryParams.starting_after = startingAfter; + } - // Extract devboxes array - const devboxes = page.devboxes || []; + // Fetch one page + const page = (await client.devboxes.list( + queryParams, + )) as DevboxesCursorIDPage<{ id: string }>; + + const pageDevboxes = page.devboxes || []; + allDevboxes.push(...pageDevboxes); + + if ( + page.has_more && + pageDevboxes.length > 0 && + allDevboxes.length < maxResults + ) { + startingAfter = ( + pageDevboxes[pageDevboxes.length - 1] as { id: string } + ).id; + } else { + startingAfter = undefined; + } + } while (startingAfter !== undefined); - output(devboxes, { format: options.output, defaultFormat: "json" }); + output(allDevboxes, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list devboxes", error); } diff --git a/src/commands/gateway-config/list.tsx b/src/commands/gateway-config/list.tsx index 0726366..91b0a4b 100644 --- a/src/commands/gateway-config/list.tsx +++ b/src/commands/gateway-config/list.tsx @@ -14,7 +14,7 @@ import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { SearchBar } from "../../components/SearchBar.js"; -import { output, outputError } from "../../utils/output.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; import { colors } from "../../utils/theme.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; @@ -26,6 +26,7 @@ import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; interface ListOptions { name?: string; + limit?: string; output?: string; } @@ -162,7 +163,7 @@ const ListGatewayConfigsUI = ({ const result = { items: pageConfigs, hasMore: page.has_more || false, - totalCount: page.total_count || pageConfigs.length, + totalCount: pageConfigs.length, }; return result; @@ -195,7 +196,7 @@ const ListGatewayConfigsUI = ({ !showEditConfig && !showDeleteConfirm && !search.searchMode, - deps: [PAGE_SIZE, search.submittedSearchQuery], + deps: [search.submittedSearchQuery], }); // Operations for a specific gateway config (shown in popup) @@ -428,8 +429,26 @@ const ListGatewayConfigsUI = ({ // Handle list view navigation if (key.upArrow && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); + } else if ( + key.upArrow && + selectedIndex === 0 && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(pageConfigs - 1); } else if (key.downArrow && selectedIndex < pageConfigs - 1) { setSelectedIndex(selectedIndex + 1); + } else if ( + key.downArrow && + selectedIndex === pageConfigs - 1 && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); } else if ( (input === "n" || key.rightArrow) && !loading && @@ -630,7 +649,7 @@ const ListGatewayConfigsUI = ({ data={configs} keyExtractor={(config: GatewayConfigListItem) => config.id} selectedIndex={selectedIndex} - title={`gateway_configs[${totalCount}]`} + title={`gateway_configs[${hasMore ? `${totalCount}+` : totalCount}]`} columns={columns} emptyState={ @@ -645,7 +664,7 @@ const ListGatewayConfigsUI = ({ {!showPopup && ( - {figures.hamburger} {totalCount} + {figures.hamburger} {hasMore ? `${totalCount}+` : totalCount} {" "} @@ -663,7 +682,8 @@ const ListGatewayConfigsUI = ({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} of{" "} + {hasMore ? `${totalPages}+` : totalPages} )} @@ -673,7 +693,8 @@ const ListGatewayConfigsUI = ({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} of{" "} + {hasMore ? `${totalCount}+` : totalCount} {search.submittedSearchQuery && ( <> @@ -744,23 +765,44 @@ export async function listGatewayConfigs(options: ListOptions = {}) { try { const client = getClient(); - // Build query params - const queryParams: Record = { - limit: DEFAULT_PAGE_SIZE, - }; - if (options.name) { - queryParams.name = options.name; - } + const maxResults = parseLimit(options.limit); + const allConfigs: unknown[] = []; + let startingAfter: string | undefined; - // Fetch gateway configs - const page = (await client.gatewayConfigs.list( - queryParams, - )) as GatewayConfigsCursorIDPage<{ id: string }>; + do { + const remaining = maxResults - allConfigs.length; + // Build query params + const queryParams: Record = { + limit: Math.min(DEFAULT_PAGE_SIZE, remaining), + }; + if (options.name) { + queryParams.name = options.name; + } + if (startingAfter) { + queryParams.starting_after = startingAfter; + } - // Extract gateway configs array - const gatewayConfigs = page.gateway_configs || []; + // Fetch one page + const page = (await client.gatewayConfigs.list( + queryParams, + )) as GatewayConfigsCursorIDPage<{ id: string }>; + + const pageConfigs = page.gateway_configs || []; + allConfigs.push(...pageConfigs); + + if ( + page.has_more && + pageConfigs.length > 0 && + allConfigs.length < maxResults + ) { + startingAfter = (pageConfigs[pageConfigs.length - 1] as { id: string }) + .id; + } else { + startingAfter = undefined; + } + } while (startingAfter !== undefined); - output(gatewayConfigs, { format: options.output, defaultFormat: "json" }); + output(allConfigs, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list gateway configs", error); } diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index fa416c0..e6863e4 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -14,7 +14,7 @@ import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { SearchBar } from "../../components/SearchBar.js"; -import { output, outputError } from "../../utils/output.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; import { colors } from "../../utils/theme.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; @@ -26,6 +26,7 @@ import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; interface ListOptions { name?: string; + limit?: string; output?: string; } @@ -173,7 +174,7 @@ const ListNetworkPoliciesUI = ({ const result = { items: pagePolicies, hasMore: page.has_more || false, - totalCount: page.total_count || pagePolicies.length, + totalCount: pagePolicies.length, }; return result; @@ -206,7 +207,7 @@ const ListNetworkPoliciesUI = ({ !showEditPolicy && !showDeleteConfirm && !search.searchMode, - deps: [PAGE_SIZE, search.submittedSearchQuery], + deps: [search.submittedSearchQuery], }); // Operations for a specific network policy (shown in popup) @@ -461,8 +462,26 @@ const ListNetworkPoliciesUI = ({ // Handle list view navigation if (key.upArrow && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); + } else if ( + key.upArrow && + selectedIndex === 0 && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(pagePolicies - 1); } else if (key.downArrow && selectedIndex < pagePolicies - 1) { setSelectedIndex(selectedIndex + 1); + } else if ( + key.downArrow && + selectedIndex === pagePolicies - 1 && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); } else if ( (input === "n" || key.rightArrow) && !loading && @@ -660,7 +679,7 @@ const ListNetworkPoliciesUI = ({ data={policies} keyExtractor={(policy: NetworkPolicyListItem) => policy.id} selectedIndex={selectedIndex} - title={`network_policies[${totalCount}]`} + title={`network_policies[${hasMore ? `${totalCount}+` : totalCount}]`} columns={columns} emptyState={ @@ -674,7 +693,7 @@ const ListNetworkPoliciesUI = ({ {!showPopup && ( - {figures.hamburger} {totalCount} + {figures.hamburger} {hasMore ? `${totalCount}+` : totalCount} {" "} @@ -692,7 +711,8 @@ const ListNetworkPoliciesUI = ({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} of{" "} + {hasMore ? `${totalPages}+` : totalPages} )} @@ -702,7 +722,8 @@ const ListNetworkPoliciesUI = ({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} of{" "} + {hasMore ? `${totalCount}+` : totalCount} {search.submittedSearchQuery && ( <> @@ -773,23 +794,45 @@ export async function listNetworkPolicies(options: ListOptions = {}) { try { const client = getClient(); - // Build query params - const queryParams: Record = { - limit: DEFAULT_PAGE_SIZE, - }; - if (options.name) { - queryParams.name = options.name; - } + const maxResults = parseLimit(options.limit); + const allPolicies: unknown[] = []; + let startingAfter: string | undefined; - // Fetch network policies - const page = (await client.networkPolicies.list( - queryParams, - )) as NetworkPoliciesCursorIDPage<{ id: string }>; + do { + const remaining = maxResults - allPolicies.length; + // Build query params + const queryParams: Record = { + limit: Math.min(DEFAULT_PAGE_SIZE, remaining), + }; + if (options.name) { + queryParams.name = options.name; + } + if (startingAfter) { + queryParams.starting_after = startingAfter; + } - // Extract network policies array - const networkPolicies = page.network_policies || []; + // Fetch one page + const page = (await client.networkPolicies.list( + queryParams, + )) as NetworkPoliciesCursorIDPage<{ id: string }>; + + const pagePolicies = page.network_policies || []; + allPolicies.push(...pagePolicies); + + if ( + page.has_more && + pagePolicies.length > 0 && + allPolicies.length < maxResults + ) { + startingAfter = ( + pagePolicies[pagePolicies.length - 1] as { id: string } + ).id; + } else { + startingAfter = undefined; + } + } while (startingAfter !== undefined); - output(networkPolicies, { format: options.output, defaultFormat: "json" }); + output(allPolicies, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list network policies", error); } diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index c4e76cd..d757929 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -15,7 +15,7 @@ import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { SearchBar } from "../../components/SearchBar.js"; -import { output, outputError } from "../../utils/output.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; import { colors } from "../../utils/theme.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; @@ -30,6 +30,7 @@ interface ListOptions { contentType?: string; state?: string; public?: boolean; + limit?: string; output?: string; } @@ -172,14 +173,13 @@ const ListObjectsUI = ({ // Access pagination properties from the result const pageResult = result as unknown as { objects: unknown[]; - total_count?: number; has_more?: boolean; }; return { items: pageObjects, hasMore: pageResult.has_more || false, - totalCount: pageResult.total_count || pageObjects.length, + totalCount: pageObjects.length, }; }, [search.submittedSearchQuery], @@ -209,7 +209,7 @@ const ListObjectsUI = ({ !showDownloadPrompt && !showDeleteConfirm && !search.searchMode, - deps: [PAGE_SIZE, search.submittedSearchQuery], + deps: [search.submittedSearchQuery], }); // Operations for objects @@ -499,8 +499,26 @@ const ListObjectsUI = ({ // Handle list view navigation if (key.upArrow && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); + } else if ( + key.upArrow && + selectedIndex === 0 && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(pageObjects - 1); } else if (key.downArrow && selectedIndex < pageObjects - 1) { setSelectedIndex(selectedIndex + 1); + } else if ( + key.downArrow && + selectedIndex === pageObjects - 1 && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); } else if ( (input === "n" || key.rightArrow) && !loading && @@ -706,7 +724,7 @@ const ListObjectsUI = ({ data={objects} keyExtractor={(obj: ObjectListItem) => obj.id} selectedIndex={selectedIndex} - title={`storage_objects[${totalCount}]`} + title={`storage_objects[${hasMore ? `${totalCount}+` : totalCount}]`} columns={columns} emptyState={ @@ -721,7 +739,7 @@ const ListObjectsUI = ({ {!showPopup && ( - {figures.hamburger} {totalCount} + {figures.hamburger} {hasMore ? `${totalCount}+` : totalCount} {" "} @@ -739,7 +757,8 @@ const ListObjectsUI = ({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} of{" "} + {hasMore ? `${totalPages}+` : totalPages} )} @@ -749,7 +768,8 @@ const ListObjectsUI = ({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} of{" "} + {hasMore ? `${totalCount}+` : totalCount} {search.submittedSearchQuery && ( <> @@ -816,30 +836,53 @@ export async function listObjects(options: ListOptions) { try { const client = getClient(); - // Build query params - const queryParams: Record = { - limit: DEFAULT_PAGE_SIZE, - }; - if (options.name) { - queryParams.name = options.name; - } - if (options.contentType) { - queryParams.content_type = options.contentType; - } - if (options.state) { - queryParams.state = options.state; - } - if (options.public !== undefined) { - queryParams.is_public = options.public; - } + const maxResults = parseLimit(options.limit); + const allObjects: unknown[] = []; + let startingAfter: string | undefined; - // Fetch objects - const result = await client.objects.list(queryParams); + do { + const remaining = maxResults - allObjects.length; + // Build query params + const queryParams: Record = { + limit: Math.min(DEFAULT_PAGE_SIZE, remaining), + }; + if (options.name) { + queryParams.name = options.name; + } + if (options.contentType) { + queryParams.content_type = options.contentType; + } + if (options.state) { + queryParams.state = options.state; + } + if (options.public !== undefined) { + queryParams.is_public = options.public; + } + if (startingAfter) { + queryParams.starting_after = startingAfter; + } - // Extract objects array - const objects = result.objects || []; + // Fetch one page + const result = await client.objects.list(queryParams); + const pageResult = result as unknown as { + objects?: { id: string }[]; + has_more?: boolean; + }; + const pageObjects = pageResult.objects || []; + allObjects.push(...pageObjects); + + if ( + pageResult.has_more && + pageObjects.length > 0 && + allObjects.length < maxResults + ) { + startingAfter = pageObjects[pageObjects.length - 1].id; + } else { + startingAfter = undefined; + } + } while (startingAfter !== undefined); - output(objects, { format: options.output, defaultFormat: "json" }); + output(allObjects, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list storage objects", error); } diff --git a/src/commands/secret/list.tsx b/src/commands/secret/list.tsx index 48fe7ec..2f721c6 100644 --- a/src/commands/secret/list.tsx +++ b/src/commands/secret/list.tsx @@ -13,7 +13,7 @@ import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { SearchBar } from "../../components/SearchBar.js"; -import { output, outputError } from "../../utils/output.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; import { colors } from "../../utils/theme.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; @@ -167,7 +167,7 @@ const ListSecretsUI = ({ !executingOperation && !showDeleteConfirm && !search.searchMode, - deps: [PAGE_SIZE, search.submittedSearchQuery], + deps: [search.submittedSearchQuery], }); // Operations for a specific secret (shown in popup) @@ -339,8 +339,26 @@ const ListSecretsUI = ({ // Handle list view navigation if (key.upArrow && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); + } else if ( + key.upArrow && + selectedIndex === 0 && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(pageSecrets - 1); } else if (key.downArrow && selectedIndex < pageSecrets - 1) { setSelectedIndex(selectedIndex + 1); + } else if ( + key.downArrow && + selectedIndex === pageSecrets - 1 && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); } else if ( (input === "n" || key.rightArrow) && !loading && @@ -525,7 +543,7 @@ const ListSecretsUI = ({ {!showPopup && ( - {figures.hamburger} {totalCount} + {figures.hamburger} {hasMore ? `${totalCount}+` : totalCount} {" "} @@ -543,7 +561,8 @@ const ListSecretsUI = ({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} of{" "} + {hasMore ? `${totalPages}+` : totalPages} )} @@ -553,7 +572,8 @@ const ListSecretsUI = ({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} of{" "} + {hasMore ? `${totalCount}+` : totalCount} {search.submittedSearchQuery && ( <> @@ -619,18 +639,39 @@ export async function listSecrets(options: ListOptions = {}) { try { const client = getClient(); - const limit = options.limit - ? parseInt(options.limit, 10) - : DEFAULT_PAGE_SIZE; + const maxResults = parseLimit(options.limit); + const allSecrets: unknown[] = []; + let startingAfter: string | undefined; - // Fetch secrets - const result = await client.secrets.list({ limit }); + do { + const remaining = maxResults - allSecrets.length; + const queryParams: Record = { + limit: Math.min(DEFAULT_PAGE_SIZE, remaining), + }; + if (startingAfter) { + queryParams.starting_after = startingAfter; + } - // Extract secrets array - const secrets = result.secrets || []; + const result = await client.secrets.list(queryParams); + const pageResult = result as unknown as { + secrets?: { id: string }[]; + has_more?: boolean; + }; + const pageSecrets = pageResult.secrets || []; + allSecrets.push(...pageSecrets); + + if ( + pageResult.has_more && + pageSecrets.length > 0 && + allSecrets.length < maxResults + ) { + startingAfter = pageSecrets[pageSecrets.length - 1].id; + } else { + startingAfter = undefined; + } + } while (startingAfter !== undefined); - // Default: output JSON for lists - output(secrets, { format: options.output, defaultFormat: "json" }); + output(allSecrets, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list secrets", error); } diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index ff523a1..bf357a1 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -15,7 +15,7 @@ import { ActionsPopup } from "../../components/ActionsPopup.js"; import { Operation } from "../../components/OperationsMenu.js"; import { formatTimeAgo } from "../../components/ResourceListView.js"; import { SearchBar } from "../../components/SearchBar.js"; -import { output, outputError } from "../../utils/output.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; import { colors } from "../../utils/theme.js"; import { useViewportHeight } from "../../hooks/useViewportHeight.js"; import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js"; @@ -27,6 +27,7 @@ import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js"; interface ListOptions { devbox?: string; + limit?: string; output?: string; } @@ -142,7 +143,7 @@ const ListSnapshotsUI = ({ const result = { items: pageSnapshots, hasMore: page.has_more || false, - totalCount: page.total_count || pageSnapshots.length, + totalCount: pageSnapshots.length, }; return result; @@ -174,7 +175,7 @@ const ListSnapshotsUI = ({ !showCreateDevbox && !showDeleteConfirm && !search.searchMode, - deps: [devboxId, PAGE_SIZE, search.submittedSearchQuery], + deps: [devboxId, search.submittedSearchQuery], }); // Operations for snapshots @@ -378,8 +379,26 @@ const ListSnapshotsUI = ({ // Handle list view navigation if (key.upArrow && selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); + } else if ( + key.upArrow && + selectedIndex === 0 && + !loading && + !navigating && + hasPrev + ) { + prevPage(); + setSelectedIndex(pageSnapshots - 1); } else if (key.downArrow && selectedIndex < pageSnapshots - 1) { setSelectedIndex(selectedIndex + 1); + } else if ( + key.downArrow && + selectedIndex === pageSnapshots - 1 && + !loading && + !navigating && + hasMore + ) { + nextPage(); + setSelectedIndex(0); } else if ( (input === "n" || key.rightArrow) && !loading && @@ -576,7 +595,7 @@ const ListSnapshotsUI = ({ data={snapshots} keyExtractor={(snapshot: SnapshotListItem) => snapshot.id} selectedIndex={selectedIndex} - title={`snapshots[${totalCount}]`} + title={`snapshots[${hasMore ? `${totalCount}+` : totalCount}]`} columns={columns} emptyState={ @@ -591,7 +610,7 @@ const ListSnapshotsUI = ({ {!showPopup && ( - {figures.hamburger} {totalCount} + {figures.hamburger} {hasMore ? `${totalCount}+` : totalCount} {" "} @@ -609,7 +628,8 @@ const ListSnapshotsUI = ({ ) : ( - Page {currentPage + 1} of {totalPages} + Page {currentPage + 1} of{" "} + {hasMore ? `${totalPages}+` : totalPages} )} @@ -619,7 +639,8 @@ const ListSnapshotsUI = ({ •{" "} - Showing {startIndex + 1}-{endIndex} of {totalCount} + Showing {startIndex + 1}-{endIndex} of{" "} + {hasMore ? `${totalCount}+` : totalCount} {search.submittedSearchQuery && ( <> @@ -686,33 +707,56 @@ export async function listSnapshots(options: ListOptions) { try { const client = getClient(); - // Build query params - const queryParams: Record = { - limit: DEFAULT_PAGE_SIZE, - }; - if (options.devbox) { - queryParams.devbox_id = options.devbox; - } + const maxResults = parseLimit(options.limit); + const allSnapshots: ReturnType[] = []; + let startingAfter: string | undefined; - // Fetch snapshots - const page = (await client.devboxes.listDiskSnapshots( - queryParams, - )) as DiskSnapshotsCursorIDPage; - - // Extract snapshots array and strip to plain objects to avoid - // camelCase aliases added by the API client library - const snapshots = (page.snapshots || []).map((s) => ({ - id: s.id, - name: s.name ?? undefined, - create_time_ms: s.create_time_ms, - metadata: s.metadata, - source_devbox_id: s.source_devbox_id, - source_blueprint_id: s.source_blueprint_id ?? undefined, - commit_message: s.commit_message ?? undefined, - })); - - output(snapshots, { format: options.output, defaultFormat: "json" }); + do { + const remaining = maxResults - allSnapshots.length; + // Build query params + const queryParams: Record = { + limit: Math.min(DEFAULT_PAGE_SIZE, remaining), + }; + if (options.devbox) { + queryParams.devbox_id = options.devbox; + } + if (startingAfter) { + queryParams.starting_after = startingAfter; + } + + // Fetch one page + const page = (await client.devboxes.listDiskSnapshots( + queryParams, + )) as DiskSnapshotsCursorIDPage; + + const pageSnapshots = page.snapshots || []; + allSnapshots.push(...pageSnapshots.map(mapSnapshot)); + + if ( + page.has_more && + pageSnapshots.length > 0 && + allSnapshots.length < maxResults + ) { + startingAfter = pageSnapshots[pageSnapshots.length - 1].id; + } else { + startingAfter = undefined; + } + } while (startingAfter !== undefined); + + output(allSnapshots, { format: options.output, defaultFormat: "json" }); } catch (error) { outputError("Failed to list snapshots", error); } } + +function mapSnapshot(s: DevboxSnapshotView) { + return { + id: s.id, + name: s.name ?? undefined, + create_time_ms: s.create_time_ms, + metadata: s.metadata, + source_devbox_id: s.source_devbox_id, + source_blueprint_id: s.source_blueprint_id ?? undefined, + commit_message: s.commit_message ?? undefined, + }; +} diff --git a/src/hooks/useCursorPagination.ts b/src/hooks/useCursorPagination.ts index 6c8a132..5aae8c3 100644 --- a/src/hooks/useCursorPagination.ts +++ b/src/hooks/useCursorPagination.ts @@ -101,6 +101,7 @@ export function useCursorPagination( const [currentPage, setCurrentPage] = React.useState(0); const [hasMore, setHasMore] = React.useState(false); const [totalCount, setTotalCount] = React.useState(0); + const maxTotalCountRef = React.useRef(0); // Cursor history: cursorHistory[N] = last item ID of page N // Used to determine startingAt for page N+1 @@ -190,9 +191,12 @@ export function useCursorPagination( // Update pagination state setHasMore(result.hasMore); - if (result.totalCount !== undefined) { - setTotalCount(result.totalCount); - } + // Compute cumulative total: items seen on all previous pages + current page. + // Use a high-water mark so the count never decreases when navigating back. + const computed = page * pageSizeRef.current + result.items.length; + const newTotal = Math.max(computed, maxTotalCountRef.current); + maxTotalCountRef.current = newTotal; + setTotalCount(newTotal); } catch (err) { if (!isMountedRef.current) return; setError(err as Error); @@ -212,6 +216,7 @@ export function useCursorPagination( React.useEffect(() => { // Clear cursor history when deps change cursorHistoryRef.current = []; + maxTotalCountRef.current = 0; setCurrentPage(0); setItems([]); setHasMore(false); diff --git a/src/services/benchmarkJobService.ts b/src/services/benchmarkJobService.ts index 7cfacb5..43b2bd7 100644 --- a/src/services/benchmarkJobService.ts +++ b/src/services/benchmarkJobService.ts @@ -79,7 +79,7 @@ export async function listBenchmarkJobs( return { jobs, - totalCount: page.total_count || jobs.length, + totalCount: jobs.length, hasMore: page.has_more || false, }; } diff --git a/src/services/benchmarkService.ts b/src/services/benchmarkService.ts index c4d265d..17a164c 100644 --- a/src/services/benchmarkService.ts +++ b/src/services/benchmarkService.ts @@ -66,7 +66,7 @@ export async function listBenchmarkRuns( return { benchmarkRuns, - totalCount: page.total_count || benchmarkRuns.length, + totalCount: benchmarkRuns.length, hasMore: page.has_more || false, }; } @@ -105,7 +105,7 @@ export async function listScenarioRuns( return { scenarioRuns, - totalCount: page.total_count || scenarioRuns.length, + totalCount: scenarioRuns.length, hasMore: page.has_more || false, }; } @@ -124,7 +124,7 @@ export async function listScenarioRuns( return { scenarioRuns, - totalCount: page.total_count || scenarioRuns.length, + totalCount: scenarioRuns.length, hasMore: page.has_more || false, }; } @@ -166,7 +166,7 @@ export async function listBenchmarks( return { benchmarks, - totalCount: page.total_count || benchmarks.length, + totalCount: benchmarks.length, hasMore: page.has_more || false, }; } diff --git a/src/services/blueprintService.ts b/src/services/blueprintService.ts index 7e1b8f1..d0be6f6 100644 --- a/src/services/blueprintService.ts +++ b/src/services/blueprintService.ts @@ -79,7 +79,7 @@ export async function listBlueprints( const result = { blueprints, - totalCount: page.total_count || blueprints.length, + totalCount: blueprints.length, hasMore: page.has_more || false, }; diff --git a/src/services/devboxService.ts b/src/services/devboxService.ts index 4f0ce8c..8c93b5d 100644 --- a/src/services/devboxService.ts +++ b/src/services/devboxService.ts @@ -130,7 +130,7 @@ export async function listDevboxes( const result = { devboxes, - totalCount: page.total_count || devboxes.length, + totalCount: devboxes.length, hasMore: page.has_more || false, }; diff --git a/src/services/gatewayConfigService.ts b/src/services/gatewayConfigService.ts index b8849ca..e72a932 100644 --- a/src/services/gatewayConfigService.ts +++ b/src/services/gatewayConfigService.ts @@ -73,7 +73,7 @@ export async function listGatewayConfigs( const result = { gatewayConfigs, - totalCount: page.total_count || gatewayConfigs.length, + totalCount: gatewayConfigs.length, hasMore: page.has_more || false, }; diff --git a/src/services/networkPolicyService.ts b/src/services/networkPolicyService.ts index 6727581..1a12ee7 100644 --- a/src/services/networkPolicyService.ts +++ b/src/services/networkPolicyService.ts @@ -72,7 +72,7 @@ export async function listNetworkPolicies( const result = { networkPolicies, - totalCount: page.total_count || networkPolicies.length, + totalCount: networkPolicies.length, hasMore: page.has_more || false, }; diff --git a/src/services/objectService.ts b/src/services/objectService.ts index 2195cb2..b4c2647 100644 --- a/src/services/objectService.ts +++ b/src/services/objectService.ts @@ -77,13 +77,12 @@ export async function listObjects( // Access pagination properties from the result const pageResult = result as unknown as { objects: unknown[]; - total_count?: number; has_more?: boolean; }; return { objects, - totalCount: pageResult.total_count || objects.length, + totalCount: objects.length, hasMore: pageResult.has_more || false, }; } diff --git a/src/services/scenarioService.ts b/src/services/scenarioService.ts index 6a41326..0b32209 100644 --- a/src/services/scenarioService.ts +++ b/src/services/scenarioService.ts @@ -47,7 +47,7 @@ export async function listScenarios( return { scenarios, - totalCount: page.total_count || scenarios.length, + totalCount: scenarios.length, hasMore: page.has_more || false, }; } diff --git a/src/services/snapshotService.ts b/src/services/snapshotService.ts index bc8e8f0..5bb93b0 100644 --- a/src/services/snapshotService.ts +++ b/src/services/snapshotService.ts @@ -90,7 +90,7 @@ export async function listSnapshots( const result = { snapshots, - totalCount: page.total_count || snapshots.length, + totalCount: snapshots.length, hasMore: page.has_more || false, }; diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 1f196c9..dbf5e3d 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -359,6 +359,7 @@ export function createProgram(): Command { .command("list") .description("List all snapshots") .option("-d, --devbox ", "Filter by devbox ID") + .option("-l, --limit ", "Max results", "20") .option( "-o, --output [format]", "Output format: text|json|yaml (default: json)", @@ -446,6 +447,7 @@ export function createProgram(): Command { .command("list") .description("List all blueprints") .option("-n, --name ", "Filter by blueprint name") + .option("-l, --limit ", "Max results", "20") .option( "-o, --output [format]", "Output format: text|json|yaml (default: json)", diff --git a/src/utils/output.ts b/src/utils/output.ts index 8b973aa..9502601 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -11,6 +11,24 @@ import { processUtils } from "./processUtils.js"; export type OutputFormat = "text" | "json" | "yaml"; +/** + * Parse a --limit option string into a max results number. + * - undefined or "0" → Infinity (unlimited) + * - positive integer string → that number + * - negative integer → Infinity (treat as unlimited) + * - non-numeric non-empty string → throws an error + */ +export function parseLimit(limit: string | undefined): number { + if (!limit || limit.trim() === "") return Infinity; + const trimmed = limit.trim(); + if (!/^-?\d+$/.test(trimmed)) { + throw new Error(`Invalid --limit value: "${limit}". Must be an integer.`); + } + const n = parseInt(trimmed, 10); + if (n <= 0) return Infinity; + return n; +} + export interface OutputOptions { output?: string; }