-
-
-
+ <>
+ {isAnonymousVolumeName(volumeName) &&
}
+
+
+
+
-
- {!selectedPath && (
-
- Select a file to preview.
-
- )}
- {selectedPath && fileLoading && (
-
-
-
-
-
-
- )}
- {selectedPath && !fileLoading && fileResult && (
-
- )}
+
+ {!selectedPath && (
+
+ Select a file to preview.
+
+ )}
+ {selectedPath && fileLoading && (
+
+
+
+
+
+
+ )}
+ {selectedPath && !fileLoading && fileResult && (
+
+ )}
+
-
+ >
)}
);
diff --git a/frontend/src/components/resources/VolumeNameLabel.tsx b/frontend/src/components/resources/VolumeNameLabel.tsx
new file mode 100644
index 00000000..5189dca6
--- /dev/null
+++ b/frontend/src/components/resources/VolumeNameLabel.tsx
@@ -0,0 +1,31 @@
+import { isAnonymousVolumeName, shortVolumeLabel } from '@/lib/volumeName';
+
+interface VolumeNameLabelProps {
+ name: string;
+ /** Append a small `anonymous` chip when the name is an anonymous volume. */
+ showChip?: boolean;
+}
+
+/**
+ * Renders a volume name truncated to a readable prefix when anonymous, with the
+ * full name available on hover. Named volumes display in full. Used in the
+ * volumes table cell and the volume browser sheet title.
+ */
+export function VolumeNameLabel({ name, showChip = false }: VolumeNameLabelProps) {
+ const anonymous = isAnonymousVolumeName(name);
+ return (
+
+
+ {shortVolumeLabel(name)}
+
+ {showChip && anonymous && (
+
+ anonymous
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/resources/__tests__/AnonymousNameBand.test.tsx b/frontend/src/components/resources/__tests__/AnonymousNameBand.test.tsx
new file mode 100644
index 00000000..268bccf5
--- /dev/null
+++ b/frontend/src/components/resources/__tests__/AnonymousNameBand.test.tsx
@@ -0,0 +1,44 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+
+const { copyMock, toastMock } = vi.hoisted(() => ({
+ copyMock: vi.fn(),
+ toastMock: { success: vi.fn(), error: vi.fn() },
+}));
+vi.mock('@/lib/clipboard', () => ({ copyToClipboard: copyMock }));
+vi.mock('@/components/ui/toast-store', () => ({ toast: toastMock }));
+
+import { AnonymousNameBand } from '../AnonymousNameBand';
+
+const ANON = '079dfda49f2c483f80f1d4f6b1865be55af54a0298507a0e588aae551134ba62';
+
+describe('AnonymousNameBand', () => {
+ beforeEach(() => {
+ copyMock.mockReset();
+ toastMock.success.mockReset();
+ toastMock.error.mockReset();
+ });
+
+ it('shows the full hash and an anonymous chip', () => {
+ render(
);
+ expect(screen.getByText(ANON)).toBeInTheDocument();
+ expect(screen.getByText('anonymous')).toBeInTheDocument();
+ });
+
+ it('copies the full hash, not the truncated label, and toasts success', async () => {
+ copyMock.mockResolvedValue(undefined);
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: 'Copy full volume name' }));
+ await waitFor(() => expect(copyMock).toHaveBeenCalledWith(ANON));
+ await waitFor(() => expect(toastMock.success).toHaveBeenCalled());
+ expect(toastMock.error).not.toHaveBeenCalled();
+ });
+
+ it('toasts an error when the copy fails', async () => {
+ copyMock.mockRejectedValue(new Error('clipboard unavailable'));
+ render(
);
+ fireEvent.click(screen.getByRole('button', { name: 'Copy full volume name' }));
+ await waitFor(() => expect(toastMock.error).toHaveBeenCalled());
+ expect(toastMock.success).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/resources/__tests__/VolumeNameLabel.test.tsx b/frontend/src/components/resources/__tests__/VolumeNameLabel.test.tsx
new file mode 100644
index 00000000..7ced12f1
--- /dev/null
+++ b/frontend/src/components/resources/__tests__/VolumeNameLabel.test.tsx
@@ -0,0 +1,27 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { VolumeNameLabel } from '../VolumeNameLabel';
+
+const ANON = '079dfda49f2c483f80f1d4f6b1865be55af54a0298507a0e588aae551134ba62';
+
+describe('VolumeNameLabel', () => {
+ it('truncates an anonymous name and exposes the full hash on hover', () => {
+ render(
);
+ const text = screen.getByTestId('volume-name-text');
+ expect(text).toHaveTextContent('079dfda49f2c…');
+ expect(text).toHaveAttribute('title', ANON);
+ expect(screen.getByTestId('anon-volume-chip')).toBeInTheDocument();
+ });
+
+ it('renders a named volume in full with no chip', () => {
+ render(
);
+ expect(screen.getByTestId('volume-name-text')).toHaveTextContent('app_pgdata');
+ expect(screen.queryByTestId('anon-volume-chip')).not.toBeInTheDocument();
+ });
+
+ it('omits the chip when showChip is not set, but still truncates', () => {
+ render(
);
+ expect(screen.getByTestId('volume-name-text')).toHaveTextContent('079dfda49f2c…');
+ expect(screen.queryByTestId('anon-volume-chip')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/__tests__/volumeName.test.ts b/frontend/src/lib/__tests__/volumeName.test.ts
new file mode 100644
index 00000000..3fe4dd18
--- /dev/null
+++ b/frontend/src/lib/__tests__/volumeName.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { isAnonymousVolumeName, shortVolumeLabel } from '../volumeName';
+
+const ANON = '079dfda49f2c483f80f1d4f6b1865be55af54a0298507a0e588aae551134ba62';
+
+describe('isAnonymousVolumeName', () => {
+ it('treats a 64 lowercase hex name as anonymous', () => {
+ expect(isAnonymousVolumeName(ANON)).toBe(true);
+ });
+
+ it('treats a friendly named volume as not anonymous', () => {
+ expect(isAnonymousVolumeName('app_pgdata')).toBe(false);
+ });
+
+ it('rejects names that are not exactly 64 chars', () => {
+ expect(isAnonymousVolumeName(ANON.slice(0, 63))).toBe(false);
+ expect(isAnonymousVolumeName(ANON + 'a')).toBe(false);
+ });
+
+ it('rejects uppercase hex and non-hex characters', () => {
+ expect(isAnonymousVolumeName(ANON.toUpperCase())).toBe(false);
+ expect(isAnonymousVolumeName('z'.repeat(64))).toBe(false);
+ });
+});
+
+describe('shortVolumeLabel', () => {
+ it('truncates an anonymous name to a 12-char prefix plus an ellipsis', () => {
+ expect(shortVolumeLabel(ANON)).toBe('079dfda49f2c…');
+ });
+
+ it('returns a named volume verbatim', () => {
+ expect(shortVolumeLabel('app_pgdata')).toBe('app_pgdata');
+ });
+});
diff --git a/frontend/src/lib/volumeName.ts b/frontend/src/lib/volumeName.ts
new file mode 100644
index 00000000..75c8a6f7
--- /dev/null
+++ b/frontend/src/lib/volumeName.ts
@@ -0,0 +1,25 @@
+// Helpers for rendering Docker volume names readably in the Resources Hub.
+//
+// A standard Docker anonymous volume name is exactly 64 lowercase hex characters
+// (the same shape as a SHA-256 digest). Named volumes are arbitrary strings.
+//
+// Note: a user who deliberately names a volume with 64 lowercase hex characters
+// is also treated as anonymous for display only. Nothing is lost: the full name
+// stays available via the title tooltip (and the copy button in the volume
+// browser sheet), and it is always passed verbatim to the API.
+
+const ANON_VOLUME_RE = /^[0-9a-f]{64}$/;
+
+/** True when the name looks like an anonymous Docker volume (64 lowercase hex chars). */
+export function isAnonymousVolumeName(name: string): boolean {
+ return ANON_VOLUME_RE.test(name);
+}
+
+/**
+ * A short, readable label for a volume name. Anonymous names are truncated to a
+ * 12-character prefix with an ellipsis (e.g. `079dfda49f2c…`); named volumes are
+ * returned verbatim so friendly names keep displaying in full.
+ */
+export function shortVolumeLabel(name: string): string {
+ return isAnonymousVolumeName(name) ? `${name.slice(0, 12)}…` : name;
+}