Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/ResourcePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export interface ResourcePickerConfig<T> {

/** Label for the create new action (default: "Create new") */
createNewLabel?: string;

/** Additional lines of overhead from wrapper components (e.g., tab headers) */
additionalOverhead?: number;
}

export interface ResourcePickerProps<T> {
Expand Down Expand Up @@ -134,7 +137,11 @@ export function ResourcePicker<T>({

// Calculate overhead for viewport height
// Matches list pages: breadcrumb(4) + table chrome(4) + stats(2) + nav tips(2) + buffer(1) = 13
const overhead = 13 + search.getSearchOverhead() + extraOverhead;
const overhead =
13 +
search.getSearchOverhead() +
extraOverhead +
(config.additionalOverhead || 0);
const { viewportHeight, terminalWidth } = useViewportHeight({
overhead,
minHeight: 5,
Expand Down
73 changes: 10 additions & 63 deletions src/screens/ObjectDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ import {
import { getClient } from "../utils/client.js";
import {
ResourceDetailPage,
formatTimestamp,
type DetailSection,
type ResourceOperation,
} from "../components/ResourceDetailPage.js";
import {
getObject,
deleteObject,
buildObjectDetailFields,
formatFileSize,
} from "../services/objectService.js";
import { useResourceDetail } from "../hooks/useResourceDetail.js";
Expand Down Expand Up @@ -175,68 +175,15 @@ export function ObjectDetailScreen({ objectId }: ObjectDetailScreenProps) {
// Build detail sections
const detailSections: DetailSection[] = [];

// Basic details section
const basicFields = [];
if (storageObject.content_type) {
basicFields.push({
label: "Content Type",
value: storageObject.content_type,
});
}
if (storageObject.size_bytes !== undefined) {
basicFields.push({
label: "Size",
value: formatFileSize(storageObject.size_bytes),
});
}
if (storageObject.state) {
basicFields.push({
label: "State",
value: storageObject.state,
});
}
if (storageObject.is_public !== undefined) {
basicFields.push({
label: "Public",
value: storageObject.is_public ? "Yes" : "No",
});
}
if (storageObject.create_time_ms) {
basicFields.push({
label: "Created",
value: formatTimestamp(storageObject.create_time_ms),
});
}

// TTL / Expires - show remaining time before auto-deletion
if (storageObject.delete_after_time_ms) {
const now = Date.now();
const remainingMs = storageObject.delete_after_time_ms - now;

let ttlValue: string;
let ttlColor = colors.text;

if (remainingMs <= 0) {
ttlValue = "Expired";
ttlColor = colors.error;
} else {
const remainingMinutes = Math.floor(remainingMs / 60000);
if (remainingMinutes < 60) {
ttlValue = `${remainingMinutes}m remaining`;
ttlColor = remainingMinutes < 10 ? colors.warning : colors.text;
} else {
const hours = Math.floor(remainingMinutes / 60);
const mins = remainingMinutes % 60;
ttlValue = `${hours}h ${mins}m remaining`;
}
}

basicFields.push({
label: "Expires",
value: <Text color={ttlColor}>{ttlValue}</Text>,
});
}

// Basic details section — reuse shared field builder
const colorMap: Record<string, string> = {
error: colors.error,
warning: colors.warning,
};
const basicFields = buildObjectDetailFields(storageObject).map((f) => ({
...f,
color: f.color ? (colorMap[f.color] ?? f.color) : undefined,
}));
if (basicFields.length > 0) {
detailSections.push({
title: "Details",
Expand Down
60 changes: 60 additions & 0 deletions src/services/objectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Object Service - Handles all storage object API calls
*/
import { getClient } from "../utils/client.js";
import { formatTimestamp } from "../utils/time.js";
import type { StorageObjectView } from "../store/objectStore.js";

export interface ListObjectsOptions {
Expand Down Expand Up @@ -118,6 +119,65 @@ export async function deleteObject(id: string): Promise<void> {
await client.objects.delete(id);
}

export interface ObjectDetailField {
label: string;
value: string;
color?: string;
}

/**
* Build standard detail fields for a storage object.
* Shared between ObjectDetailScreen and AgentDetailScreen.
*/
export function buildObjectDetailFields(
obj: StorageObjectView,
): ObjectDetailField[] {
const fields: ObjectDetailField[] = [];

if (obj.content_type) {
fields.push({ label: "Content Type", value: obj.content_type });
}
if (obj.size_bytes !== undefined && obj.size_bytes !== null) {
fields.push({ label: "Size", value: formatFileSize(obj.size_bytes) });
}
if (obj.state) {
fields.push({ label: "State", value: obj.state });
}
if (obj.is_public !== undefined) {
fields.push({ label: "Public", value: obj.is_public ? "Yes" : "No" });
}
if (obj.create_time_ms) {
fields.push({
label: "Created",
value: formatTimestamp(obj.create_time_ms) ?? "",
});
}
if (obj.delete_after_time_ms) {
const remainingMs = obj.delete_after_time_ms - Date.now();
if (remainingMs <= 0) {
fields.push({ label: "Expires", value: "Expired", color: "error" });
} else {
const remainingMinutes = Math.floor(remainingMs / 60000);
if (remainingMinutes < 60) {
fields.push({
label: "Expires",
value: `${remainingMinutes}m remaining`,
color: remainingMinutes < 10 ? "warning" : undefined,
});
} else {
const hours = Math.floor(remainingMinutes / 60);
const mins = remainingMinutes % 60;
fields.push({
label: "Expires",
value: `${hours}h ${mins}m remaining`,
});
}
}
}

return fields;
}

/**
* Format file size in human-readable format
*/
Expand Down
105 changes: 105 additions & 0 deletions tests/__tests__/services/objectService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
formatFileSize,
buildObjectDetailFields,
} from "../../../src/services/objectService.js";
import type { StorageObjectView } from "../../../src/store/objectStore.js";

describe("formatFileSize", () => {
it("returns Unknown for null", () => {
expect(formatFileSize(null)).toBe("Unknown");
});

it("returns Unknown for undefined", () => {
expect(formatFileSize(undefined)).toBe("Unknown");
});

it("formats bytes", () => {
expect(formatFileSize(500)).toBe("500 B");
});

it("formats kilobytes", () => {
expect(formatFileSize(1024)).toBe("1.00 KB");
});

it("formats megabytes", () => {
expect(formatFileSize(1024 * 1024)).toBe("1.00 MB");
});

it("formats gigabytes", () => {
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.00 GB");
});

it("formats zero bytes", () => {
expect(formatFileSize(0)).toBe("0 B");
});
});

describe("buildObjectDetailFields", () => {
const baseObject: StorageObjectView = {
id: "obj_123",
name: "test.txt",
content_type: "text/plain",
create_time_ms: 1700000000000,
state: "READY",
size_bytes: 1024,
};

it("includes content type", () => {
const fields = buildObjectDetailFields(baseObject);
expect(fields.find((f) => f.label === "Content Type")?.value).toBe(
"text/plain",
);
});

it("includes formatted size", () => {
const fields = buildObjectDetailFields(baseObject);
expect(fields.find((f) => f.label === "Size")?.value).toBe("1.00 KB");
});

it("includes state", () => {
const fields = buildObjectDetailFields(baseObject);
expect(fields.find((f) => f.label === "State")?.value).toBe("READY");
});

it("includes public field when set", () => {
const fields = buildObjectDetailFields({ ...baseObject, is_public: true });
expect(fields.find((f) => f.label === "Public")?.value).toBe("Yes");
});

it("includes created timestamp", () => {
const fields = buildObjectDetailFields(baseObject);
expect(fields.find((f) => f.label === "Created")).toBeDefined();
});

it("includes expires when delete_after_time_ms is set", () => {
const future = Date.now() + 3600000; // 1 hour from now
const fields = buildObjectDetailFields({
...baseObject,
delete_after_time_ms: future,
});
const expiresField = fields.find((f) => f.label === "Expires");
expect(expiresField).toBeDefined();
expect(expiresField?.value).toContain("remaining");
});

it("shows Expired with error color for past delete_after_time_ms", () => {
const past = Date.now() - 1000;
const fields = buildObjectDetailFields({
...baseObject,
delete_after_time_ms: past,
});
const expiresField = fields.find((f) => f.label === "Expires");
expect(expiresField?.value).toBe("Expired");
expect(expiresField?.color).toBe("error");
});

it("shows warning color when expiry is under 10 minutes", () => {
const soon = Date.now() + 5 * 60000; // 5 minutes from now
const fields = buildObjectDetailFields({
...baseObject,
delete_after_time_ms: soon,
});
const expiresField = fields.find((f) => f.label === "Expires");
expect(expiresField?.color).toBe("warning");
});
});
Loading