feat(ui): navigate slash-named secrets as folders (#9913)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators store company and user secrets under human-readable names
> - Slash-delimited names already express useful hierarchy, but the
Secrets page previously rendered them as one flat list
> - Large secret collections therefore became harder to scan, navigate,
and create within consistently named groups
> - A client-derived folder model preserves the existing server contract
while making those names navigable
> - This pull request adds folder browsing, URL-addressable paths,
global search, and create-in-folder behavior without adding folder
records
> - The benefit is a more scalable secrets workflow with no migration or
API compatibility risk

## Linked Issues or Issue Description

Slash-delimited secret names such as `dev/github/oauth/clientid`
currently appear as raw flat rows. This change treats name prefixes as
client-side navigation folders on the main Secrets tab while leaving
stored names, API contracts, validation, and the database unchanged.

- Wireframes and interaction specification:
https://pages.paperclip.ing/pap-14698-secrets-folders/
- Folder paths are derived only from secret names; there is no
server-side folder entity or data-model change.

## What Changed

- Added pure secret-path utilities for normalized segments, breadcrumbs,
nested folder listings, counts, and leaf/path rendering.
- Added a Folders/Flat view to the main Secrets tab with folder-first
sorting, breadcrumbs, empty-folder states, filters, and global search
results.
- Added URL navigation through `?path=<normalized/path>` so deep links,
reload, browser Back, and new-tab folder navigation work.
- Persisted the preferred view in `localStorage` under
`paperclip.secrets.viewMode`; an explicit `?path=` deep link takes
precedence for that visit.
- Added create-in-folder behavior with a removable prefix chip, staged
New folder paths, inline segment validation, and full-name key
derivation.
- Kept My secrets flat while rendering slash-delimited names with muted
paths and emphasized leaves.
- Added unit/render coverage for path helpers, folder navigation, and
create-in-folder behavior.

## Verification

- `pnpm exec vitest run ui/src/pages/secrets/secret-path.test.ts
ui/src/pages/Secrets.render.test.tsx` — 35 tests passed.
- `pnpm -r typecheck` — passed.
- `pnpm test:run` — server suite passed 2,707 tests and UI suite passed
3,022 tests; one unrelated CLI doctor test was environment-sensitive
because this agent inherited AWS variables.
- `env -u AWS_PROFILE -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u
AWS_SESSION_TOKEN -u AWS_REGION -u AWS_DEFAULT_REGION pnpm exec vitest
run cli/src/__tests__/secrets.test.ts` — 8 tests passed.
- `pnpm build` — passed.
- `pnpm check:token-gates` — feature files are clean; the command
reports five existing `#9627` color literals outside this diff.

## Risks

- Low data risk: folders are derived client-side from existing names,
with no schema, migration, API, or stored-name changes.
- URL behavior changes only the main Secrets tab and uses the additive
`?path=` contract.
- View preference is browser-local and scoped to the
`paperclip.secrets.viewMode` key.
- Renaming or deleting the last secret under an open prefix
intentionally leaves the user on an empty-folder state instead of
redirecting.
- “Move to folder…” bulk prefix rename remains deferred because it is a
multi-secret mutation with separate conflict and partial-failure
semantics.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI `gpt-5.5` via Codex CLI for PR preparation, verification,
GitHub/Paperclip tool use, and codebase analysis; the managed harness
does not expose the active context-window size.
- Anthropic Claude Opus 4.8 (1M context) and Claude Fable 5 assisted
earlier implementation/design commits, as recorded in their commit
trailers; both used repository and code-editing tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
— the assigned shared execution branch name is fixed for this work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-20 16:11:35 -05:00 committed by GitHub
parent 2f42a4968d
commit b94907f8af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1065 additions and 36 deletions

View File

@ -174,6 +174,14 @@ async function flushReact() {
});
}
async function waitForReact(predicate: () => boolean, attempts = 20) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (predicate()) return;
await flushReact();
}
throw new Error("Timed out waiting for React state to settle");
}
function makeDiscoveryPreview(
overrides: Partial<SecretProviderConfigDiscoveryPreviewResult> = {},
): SecretProviderConfigDiscoveryPreviewResult {
@ -373,9 +381,11 @@ describe("Secrets page layout", () => {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>,
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
@ -1440,3 +1450,281 @@ describe("Secrets page layout", () => {
});
});
});
describe("Secrets folder view (PAP-14698)", () => {
let container: HTMLDivElement;
function seedFolderSecrets() {
mockSecretsApi.list.mockResolvedValue([
makeCompanySecret({ id: "s1", key: "dev_github_oauth_clientid", name: "dev/github/oauth/clientid" }),
makeCompanySecret({ id: "s2", key: "dev_github_oauth_clientsecret", name: "dev/github/oauth/clientsecret" }),
makeCompanySecret({ id: "s3", key: "prod_api_token", name: "prod/api/token" }),
makeCompanySecret({ id: "s4", key: "standalone", name: "standalone" }),
]);
mockSecretsApi.providers.mockResolvedValue(providers);
mockSecretsApi.providerHealth.mockResolvedValue({ providers: [] });
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
mockSecretsApi.userSecretDefinitionCoverage.mockResolvedValue(userSecretCoverage);
mockSecretsApi.listMyUserSecrets.mockResolvedValue([]);
mockAgentsApi.list.mockResolvedValue([]);
}
async function renderAt(path: string) {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<MemoryRouter initialEntries={[path]}>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
return root;
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
try {
window.localStorage.clear();
} catch {
/* ignore */
}
seedFolderSecrets();
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("derives folders at the root with filtered counts and a flat standalone secret", async () => {
const root = await renderAt("/");
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
expect(table.textContent).toContain("dev");
expect(table.textContent).toContain("prod");
expect(table.textContent).toContain("standalone");
// dev groups both oauth secrets recursively; github and oauth are descendant folders.
expect(table.textContent).toContain("2 secrets · 2 folders");
expect(table.textContent).toContain("1 secret · 1 folder");
// Folder rows are real links carrying ?path=.
const links = [...container.querySelectorAll("a")].map((a) => a.getAttribute("href") ?? "");
expect(links.some((href) => href.includes("path=dev"))).toBe(true);
await act(async () => root.unmount());
});
it("opens a deep ?path= link into the folder with breadcrumb, leaves, and an up affordance", async () => {
const root = await renderAt("/?path=dev/github/oauth");
const breadcrumb = container.querySelector('nav[aria-label="Breadcrumb"]');
expect(breadcrumb).not.toBeNull();
const current = container.querySelector('[aria-current="page"]');
expect(current?.textContent).toContain("oauth");
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
expect(table.textContent).toContain("clientid");
expect(table.textContent).toContain("clientsecret");
expect(table.textContent).toContain("Up to github");
// Sibling trees are not shown while drilled in.
expect(table.textContent).not.toContain("standalone");
await act(async () => root.unmount());
});
it("renders the empty-folder state (breadcrumb intact) for an unknown path", async () => {
const root = await renderAt("/?path=does/not/exist");
expect(container.querySelector('nav[aria-label="Breadcrumb"]')).not.toBeNull();
expect(container.textContent).toContain("No secrets in this folder yet.");
const cta = [...container.querySelectorAll("button")].find((b) =>
b.textContent?.includes("New secret here"),
) as HTMLButtonElement;
expect(cta).toBeDefined();
await act(async () => cta.click());
await flushReact();
expect(document.body.textContent).toContain("does/not/exist/");
expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe("");
expect(document.querySelector('button[aria-label="Remove folder prefix"]')).not.toBeNull();
await act(async () => root.unmount());
});
it("distinguishes a filtered-empty folder from a genuinely empty folder", async () => {
const root = await renderAt("/?path=dev/github/oauth");
const filterButton = document.querySelector('button[title="Filter"]') as HTMLButtonElement;
await act(async () => filterButton.click());
await flushReact();
const archivedLabel = [...document.querySelectorAll("label")].find(
(label) => label.textContent?.trim() === "Archived",
) as HTMLLabelElement;
await act(async () => archivedLabel.click());
await waitForReact(() => container.textContent?.includes("No secrets match your filters.") ?? false);
expect(container.textContent).toContain("No secrets match your filters.");
expect(container.textContent).not.toContain("New secret here");
await act(async () => root.unmount());
});
it("creates a company secret from a folder prefix and derives the key from the full name", async () => {
mockSecretsApi.create.mockResolvedValue(
makeCompanySecret({ id: "created", name: "dev/github/oauth/clientsecret/deeper" }),
);
const root = await renderAt("/?path=dev/github/oauth");
const newSecretButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "New secret",
) as HTMLButtonElement;
await act(async () => newSecretButton.click());
await flushReact();
expect(document.body.textContent).toContain("dev/github/oauth/");
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
expect(nameInput.placeholder).toBe("clientsecret");
expect(nameInput.value).toBe("");
await act(async () => setInputValue(nameInput, "clientsecret/deeper"));
await flushReact();
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
"dev-github-oauth-clientsecret-deeper",
);
await act(async () =>
setTextareaValue(document.getElementById("new-secret-value") as HTMLTextAreaElement, "secret-value"),
);
const createButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Create secret",
) as HTMLButtonElement;
await act(async () => createButton.click());
await flushReact();
expect(mockSecretsApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ name: "dev/github/oauth/clientsecret/deeper" }),
);
await act(async () => root.unmount());
});
it("keeps the folder prefix for Each user and exposes the full name when the chip is removed", async () => {
const root = await renderAt("/?path=dev/github/oauth");
const newSecretButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "New secret",
) as HTMLButtonElement;
await act(async () => newSecretButton.click());
await flushReact();
const eachUserTab = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Each user",
) as HTMLButtonElement;
await act(async () => {
eachUserTab.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
eachUserTab.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
eachUserTab.click();
});
await flushReact();
const nameInput = document.getElementById("new-secret-name") as HTMLInputElement;
await act(async () => setInputValue(nameInput, "personal-token"));
await flushReact();
expect((document.getElementById("new-secret-key") as HTMLInputElement).value).toBe(
"DEV_GITHUB_OAUTH_PERSONAL_TOKEN",
);
const removePrefix = document.querySelector(
'button[aria-label="Remove folder prefix"]',
) as HTMLButtonElement;
await act(async () => removePrefix.click());
await flushReact();
expect((document.getElementById("new-secret-name") as HTMLInputElement).value).toBe(
"dev/github/oauth/personal-token",
);
expect(document.querySelector('button[aria-label="Remove folder prefix"]')).toBeNull();
await act(async () => root.unmount());
});
it("validates New folder inline and stages the trimmed segment in the URL-backed folder view", async () => {
const root = await renderAt("/?path=dev/github/oauth");
const newFolderButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "New folder",
) as HTMLButtonElement;
await act(async () => newFolderButton.click());
await flushReact();
const folderInput = container.querySelector('input[aria-label="Folder name"]') as HTMLInputElement;
await act(async () => setInputValue(folderInput, "bad/name"));
const createFolderButton = [...container.querySelectorAll("button")].find(
(button) => button.textContent?.trim() === "Create folder",
) as HTMLButtonElement;
await act(async () => createFolderButton.click());
await flushReact();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Folder name cannot contain slashes.",
);
await act(async () => setInputValue(folderInput, " staged "));
await flushReact();
await act(async () => createFolderButton.click());
await waitForReact(() =>
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
node.textContent?.includes("staged"),
),
);
expect(
[...container.querySelectorAll('[aria-current="page"]')].some((node) =>
node.textContent?.includes("staged"),
),
).toBe(true);
expect(container.textContent).toContain("No secrets in this folder yet.");
expect(container.querySelector('input[aria-label="Folder name"]')).toBeNull();
await act(async () => root.unmount());
});
it("Flat toggle reproduces the raw, ungrouped list", async () => {
const root = await renderAt("/");
const flatButton = [...container.querySelectorAll("button")].find(
(b) => b.textContent?.trim().toLowerCase() === "flat",
) as HTMLButtonElement | undefined;
expect(flatButton).toBeDefined();
await act(async () => flatButton!.click());
await flushReact();
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
expect(table.textContent).toContain("dev/github/oauth/clientid");
expect(table.textContent).not.toContain("2 secrets · 1 folder");
await act(async () => root.unmount());
});
it("search is global across folders and shows full muted-path names", async () => {
const root = await renderAt("/?path=dev/github/oauth");
const input = container.querySelector(
'input[aria-label="Search secrets"]',
) as HTMLInputElement;
await act(async () => setInputValue(input, "token"));
await flushReact();
expect(container.textContent).toContain("Search results");
expect(container.textContent).toContain("across all folders");
const table = container.querySelector('[data-testid="secrets-table-view"]')!;
// prod/api/token lives outside the current folder yet still matches.
expect(table.textContent).toContain("prod/api/");
expect(table.textContent).toContain("token");
await act(async () => root.unmount());
});
});

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@ -7,11 +7,16 @@ import {
Archive,
Ban,
CheckCircle2,
ChevronLeft,
ChevronRight,
Cloud,
CornerLeftUp,
Copy,
Database,
Edit3,
ExternalLink,
Folder,
FolderOpen,
KeyRound,
Link2,
Lock,
@ -30,7 +35,7 @@ import {
UserRound,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import type {
CompanySecret,
CompanySecretUsageBinding,
@ -97,6 +102,15 @@ import { copyTextToClipboard } from "../lib/clipboard";
import { PageTabBar } from "../components/PageTabBar";
import { ImportFromVaultDialog } from "./secrets/ImportFromVaultDialog";
import { MyUserSecretsTab } from "./secrets/MyUserSecretsTab";
import { SecretPathName } from "./secrets/SecretPathName";
import {
buildSecretPathBreadcrumbs,
buildSecretPathListing,
getSecretPathRowName,
normalizeSecretPath,
validateSecretFolderSegment,
type SecretPathFolder,
} from "./secrets/secret-path";
import { SetMyUserSecretDialog } from "./secrets/SetMyUserSecretDialog";
import {
coverageSummaryLabel,
@ -108,6 +122,27 @@ type CreateMode = "managed" | "external";
type SecretValueProvider = "company" | "user";
type ProvidedByFilter = "all" | SecretValueProvider;
type SecretsTab = "secrets" | "my-secrets" | "vaults";
type SecretsViewMode = "folders" | "flat";
const SECRETS_VIEW_MODE_STORAGE_KEY = "paperclip.secrets.viewMode";
function readStoredViewMode(): SecretsViewMode | null {
try {
const stored = window.localStorage.getItem(SECRETS_VIEW_MODE_STORAGE_KEY);
return stored === "folders" || stored === "flat" ? stored : null;
} catch {
return null;
}
}
/** "12 secrets · 3 folders" — folder part omitted when there are no subfolders. */
function formatSecretPathCounts(secretCount: number, folderCount: number): string {
const parts = [`${secretCount} ${secretCount === 1 ? "secret" : "secrets"}`];
if (folderCount > 0) {
parts.push(`${folderCount} ${folderCount === 1 ? "folder" : "folders"}`);
}
return parts.join(" · ");
}
type UnifiedSecretRow =
| { id: string; kind: "company"; secret: CompanySecret }
@ -616,6 +651,7 @@ export function Secrets() {
const [secretValueProvider, setSecretValueProvider] = useState<SecretValueProvider>("company");
const [createMode, setCreateMode] = useState<CreateMode>("managed");
const [editingDefinition, setEditingDefinition] = useState<UserSecretDefinition | null>(null);
const [createNamePrefix, setCreateNamePrefix] = useState<string | null>(null);
const [createKeyDirty, setCreateKeyDirty] = useState(false);
const [createKeyEditable, setCreateKeyEditable] = useState(false);
const [createForm, setCreateForm] = useState({
@ -645,6 +681,9 @@ export function Secrets() {
const [vaultDiscovery, setVaultDiscovery] =
useState<SecretProviderConfigDiscoveryPreviewResult | null>(null);
const [vaultDiscoveryError, setVaultDiscoveryError] = useState<unknown | null>(null);
const [newFolderOpen, setNewFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [newFolderError, setNewFolderError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Secrets" }]);
@ -817,6 +856,90 @@ export function Secrets() {
(providerFilter === "all" ? 0 : 1) +
(providedByFilter === "all" ? 0 : 1);
// --- Folder view (PAP-14698) --------------------------------------------
// Folders are derived purely from slash-delimited secret names; there is no
// server-side folder record. `?path=` holds the normalized current folder
// and is only meaningful on the main Secrets tab (inert on the others).
const [searchParams, setSearchParams] = useSearchParams();
const pathParam = normalizeSecretPath(searchParams.get("path") ?? "");
const folderPath = activeTab === "secrets" ? pathParam : "";
const searching = search.trim().length > 0;
const [storedViewMode, setStoredViewMode] = useState<SecretsViewMode | null>(readStoredViewMode);
const hasSlashNames = useMemo(
() => unifiedRows.some((row) => getSecretPathRowName(row).includes("/")),
[unifiedRows],
);
// No explicit preference → default to Folders once any name has a slash.
const resolvedViewMode: SecretsViewMode = storedViewMode ?? (hasSlashNames ? "folders" : "flat");
// A `?path=` deep link forces folder view for the visit even if the stored
// preference is Flat. Search always renders a flat global result set.
const effectiveViewMode: SecretsViewMode = folderPath ? "folders" : resolvedViewMode;
const showFolderView = effectiveViewMode === "folders" && !searching;
const goToFolder = useCallback(
(path: string) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
const normalized = normalizeSecretPath(path);
if (normalized) next.set("path", normalized);
else next.delete("path");
return next;
},
{ replace: false },
);
},
[setSearchParams],
);
function closeNewFolder() {
setNewFolderOpen(false);
setNewFolderName("");
setNewFolderError(null);
}
function stageNewFolder() {
const segment = newFolderName.trim();
const error = validateSecretFolderSegment(segment);
if (error) {
setNewFolderError(error);
return;
}
goToFolder(folderPath ? `${folderPath}/${segment}` : segment);
closeNewFolder();
}
const setViewMode = useCallback(
(mode: SecretsViewMode) => {
setStoredViewMode(mode);
try {
window.localStorage.setItem(SECRETS_VIEW_MODE_STORAGE_KEY, mode);
} catch {
// Ignore storage failures (private mode / disabled); view still works.
}
// Flat has no notion of a current folder — leaving it out of the URL.
if (mode === "flat") goToFolder("");
},
[goToFolder],
);
const folderListing = useMemo(
() => buildSecretPathListing(filteredRows, folderPath),
[filteredRows, folderPath],
);
const breadcrumbs = useMemo(() => buildSecretPathBreadcrumbs(folderPath), [folderPath]);
const parentFolderPath = useMemo(() => {
const segments = folderPath ? folderPath.split("/") : [];
return segments.slice(0, -1).join("/");
}, [folderPath]);
const currentFolderSecretCount =
folderListing.secrets.length +
folderListing.folders.reduce((total, folder) => total + folder.secretCount, 0);
const folderRows = showFolderView ? folderListing.folders : [];
const secretRows = showFolderView ? folderListing.secrets : filteredRows;
const showUpRow = showFolderView && folderPath.length > 0;
const usageQuery = useQuery({
queryKey: selectedSecret ? queryKeys.secrets.usage(selectedSecret.id) : ["secrets", "usage", "__disabled__"],
queryFn: () => secretsApi.usage(selectedSecret!.id),
@ -852,14 +975,16 @@ export function Secrets() {
}
function openCreateSecret() {
const prefix = folderPath ? `${folderPath}/` : null;
setEditingDefinition(null);
setCreateNamePrefix(prefix);
setSecretValueProvider("company");
setCreateMode("managed");
setCreateKeyDirty(false);
setCreateKeyEditable(false);
setCreateError(null);
setCreateForm({
name: "",
name: prefix ?? "",
key: "",
value: "",
description: "",
@ -873,6 +998,7 @@ export function Secrets() {
function openEditDefinition(definition: UserSecretDefinition) {
setEditingDefinition(definition);
setCreateNamePrefix(null);
setSecretValueProvider("user");
setCreateMode("managed");
setCreateKeyDirty(true);
@ -944,6 +1070,7 @@ export function Secrets() {
});
setCreateOpen(false);
setEditingDefinition(null);
setCreateNamePrefix(null);
setSecretValueProvider("company");
setCreateKeyDirty(false);
setCreateKeyEditable(false);
@ -1438,6 +1565,158 @@ export function Secrets() {
);
}
function folderLinkTo(path: string) {
const params = new URLSearchParams(searchParams);
const normalized = normalizeSecretPath(path);
if (normalized) params.set("path", normalized);
else params.delete("path");
const qs = params.toString();
return { search: qs ? `?${qs}` : "" };
}
/** Secret-name treatment: raw in flat view, muted-path/bold-leaf otherwise. */
function renderSecretName(name: string) {
if (searching) return <SecretPathName name={name} className="text-sm" />;
if (showFolderView) return <SecretPathName name={name} basePath={folderPath} className="text-sm" />;
return <span className="truncate font-medium text-foreground">{name}</span>;
}
function renderFolderTableRow(folder: SecretPathFolder) {
return (
<Link
key={`folder:${folder.path}`}
to={folderLinkTo(folder.path)}
role="row"
className="grid grid-cols-(--gtc-54) items-center gap-3 border-b border-border/60 px-3 py-3 hover:bg-accent/40"
>
<div role="cell" className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">{folder.name}</span>
</div>
<div className="mt-0.5 pl-6 text-xs text-muted-foreground">
{formatSecretPathCounts(folder.secretCount, folder.folderCount)}
</div>
</div>
<div role="cell" aria-hidden="true" />
<div role="cell" aria-hidden="true" />
<div role="cell" aria-hidden="true" />
<div role="cell" className="flex justify-end">
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</Link>
);
}
function renderFolderCard(folder: SecretPathFolder) {
return (
<Link
key={`folder:${folder.path}`}
to={folderLinkTo(folder.path)}
className="flex items-center justify-between gap-2 rounded-md border border-border bg-background p-3 hover:bg-accent/30"
>
<div className="flex min-w-0 items-center gap-2">
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{folder.name}</div>
<div className="text-xs text-muted-foreground">
{formatSecretPathCounts(folder.secretCount, folder.folderCount)}
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</Link>
);
}
function renderUpRow(variant: "table" | "card") {
const parentLabel = parentFolderPath ? parentFolderPath.split("/").pop()! : "All secrets";
return (
<Link
to={folderLinkTo(parentFolderPath)}
role={variant === "table" ? "row" : undefined}
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground hover:bg-accent/40",
variant === "table"
? "border-b border-border/60 px-3 py-2.5"
: "rounded-md border border-border bg-background px-3 py-2.5",
)}
>
<CornerLeftUp className="h-4 w-4 shrink-0" />
<span className="truncate">Up to {parentLabel}</span>
</Link>
);
}
function renderSecretsBreadcrumb() {
const currentName = breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1].name : "All secrets";
const parentLabel = parentFolderPath ? parentFolderPath.split("/").pop()! : "All secrets";
const fullTrail: { name: string; path: string }[] = [
{ name: "All secrets", path: "" },
...breadcrumbs,
];
// Middle-truncate deep paths: root · … · last two.
const collapsed =
fullTrail.length > 4
? [fullTrail[0], { name: "…", path: "" }, ...fullTrail.slice(-2)]
: fullTrail;
return (
<nav aria-label="Breadcrumb" className="min-w-0">
{/* Wide: full trail */}
<ol className="hidden min-w-0 items-center gap-1 text-sm @min-[40rem]:flex">
{collapsed.map((crumb, index) => {
const isLast = index === collapsed.length - 1;
const isEllipsis = crumb.name === "…" && crumb.path === "" && index > 0 && !isLast;
return (
<li key={`${crumb.path}:${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" /> : null}
{isEllipsis ? (
<span className="px-0.5 text-muted-foreground"></span>
) : isLast ? (
<span aria-current="page" className="truncate font-medium text-foreground">
{crumb.name}
</span>
) : (
<Link
to={folderLinkTo(crumb.path)}
className="max-w-40 truncate text-muted-foreground hover:text-foreground hover:underline"
>
{crumb.name}
</Link>
)}
</li>
);
})}
</ol>
{/* Narrow: back-chevron + parent/current */}
<div className="flex min-w-0 items-center gap-1.5 text-sm @min-[40rem]:hidden">
{folderPath ? (
<>
<Link
to={folderLinkTo(parentFolderPath)}
aria-label="Up one folder"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" />
</Link>
{parentLabel !== "All secrets" ? (
<span className="shrink-0 text-muted-foreground">{parentLabel} /</span>
) : null}
<span aria-current="page" className="truncate font-medium text-foreground">
{currentName}
</span>
</>
) : (
<span aria-current="page" className="truncate font-medium text-foreground">
All secrets
</span>
)}
</div>
</nav>
);
}
if (!selectedCompanyId) {
return (
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
@ -1492,16 +1771,87 @@ export function Secrets() {
onProviderChange={setProviderFilter}
onProvidedByChange={setProvidedByFilter}
/>
<div
role="group"
aria-label="View mode"
className={cn(
"inline-flex items-center rounded-md border border-border p-0.5",
searching && "opacity-50",
)}
>
{(["folders", "flat"] as const).map((mode) => (
<button
key={mode}
type="button"
aria-pressed={effectiveViewMode === mode}
disabled={searching}
onClick={() => setViewMode(mode)}
className={cn(
"rounded-sm px-2.5 py-1 text-xs font-medium capitalize transition-colors disabled:cursor-not-allowed",
effectiveViewMode === mode
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{mode}
</button>
))}
</div>
<ImportFromVaultButton
providerConfigs={providerConfigs}
onClick={() => openImportFromVault()}
onManageVaults={() => setActiveTab("vaults")}
className="ml-auto"
/>
{showFolderView ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setNewFolderOpen(true);
setNewFolderError(null);
}}
>
<Folder className="mr-1 h-3.5 w-3.5" /> New folder
</Button>
) : null}
<Button onClick={openCreateSecret} size="sm">
<Plus className="h-3.5 w-3.5 mr-1" /> New secret
</Button>
</div>
{newFolderOpen && showFolderView ? (
<div className="flex flex-wrap items-start gap-2" role="group" aria-label="Create folder">
<div className="min-w-48 flex-1 sm:max-w-80">
<Input
value={newFolderName}
onChange={(event) => {
setNewFolderName(event.target.value);
if (newFolderError) setNewFolderError(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") stageNewFolder();
if (event.key === "Escape") closeNewFolder();
}}
placeholder="Folder name"
aria-label="Folder name"
aria-invalid={Boolean(newFolderError)}
autoFocus
/>
{newFolderError ? (
<p className="mt-1 text-xs text-destructive" role="alert">
{newFolderError}
</p>
) : null}
</div>
<Button type="button" size="sm" onClick={stageNewFolder}>
Create folder
</Button>
<Button type="button" variant="ghost" size="sm" onClick={closeNewFolder}>
Cancel
</Button>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto">
{secretsQuery.isError || userDefinitionsQuery.isError ? (
<div className="text-sm text-destructive flex items-center gap-2 py-4">
@ -1518,17 +1868,57 @@ export function Secrets() {
Retry
</Button>
</div>
) : unifiedRows.length === 0 && !secretsQuery.isPending && !userDefinitionsQuery.isPending ? (
) : unifiedRows.length === 0 &&
!secretsQuery.isPending &&
!userDefinitionsQuery.isPending &&
!(showFolderView && folderPath) ? (
<EmptyState
icon={KeyRound}
message="No secrets yet. Create a shared company secret or one that each user supplies."
action="New secret"
onAction={openCreateSecret}
/>
) : filteredRows.length === 0 ? (
<EmptyState icon={Search} message="No secrets match your filters." />
) : (
<div className="@container min-w-0 overflow-x-hidden text-sm" data-testid="secrets-list-container">
{showFolderView ? (
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
{renderSecretsBreadcrumb()}
<span className="shrink-0 text-xs text-muted-foreground">
{formatSecretPathCounts(currentFolderSecretCount, folderListing.folders.length)}
</span>
</div>
) : searching ? (
<div className="mb-3">
<div className="text-sm font-medium text-foreground">Search results</div>
<div className="text-xs text-muted-foreground">
{filteredRows.length} {filteredRows.length === 1 ? "match" : "matches"} across all
folders{folderPath ? ` · searching everywhere, not just ${folderPath}` : ""}
</div>
</div>
) : null}
{folderRows.length === 0 && secretRows.length === 0 ? (
secretsQuery.isPending || userDefinitionsQuery.isPending ? (
<div className="space-y-2 py-2" aria-hidden="true" data-testid="secrets-loading-skeleton">
{[0, 1, 2, 3].map((index) => (
<div key={index} className="h-14 animate-pulse rounded-md bg-muted/40" />
))}
</div>
) : showFolderView && folderPath && activeSecretFilterCount === 0 ? (
<EmptyState
icon={FolderOpen}
message="No secrets in this folder yet."
action="New secret here"
onAction={openCreateSecret}
/>
) : (
<EmptyState
icon={Search}
message={searching ? "No secrets match your search." : "No secrets match your filters."}
/>
)
) : (
<>
<div
role="table"
aria-label="Secrets"
@ -1546,7 +1936,9 @@ export function Secrets() {
<div role="columnheader" className="sr-only">Actions</div>
</div>
<div role="rowgroup">
{filteredRows.map((row) => {
{showUpRow ? renderUpRow("table") : null}
{folderRows.map(renderFolderTableRow)}
{secretRows.map((row) => {
const status = row.kind === "company" ? row.secret.status : row.definition.status;
const updatedAt = row.kind === "company" ? row.secret.updatedAt : row.definition.updatedAt;
const updatedTooltip =
@ -1573,9 +1965,7 @@ export function Secrets() {
>
<div role="cell" className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-medium text-foreground">
{row.kind === "company" ? row.secret.name : row.definition.name}
</span>
{renderSecretName(row.kind === "company" ? row.secret.name : row.definition.name)}
{row.kind === "company" ? (
<SecretProviderIndicator
secret={row.secret}
@ -1635,7 +2025,9 @@ export function Secrets() {
</div>
<div className="space-y-2 @min-[40rem]:hidden" data-testid="secrets-card-view">
{filteredRows.map((row) => {
{showUpRow ? renderUpRow("card") : null}
{folderRows.map(renderFolderCard)}
{secretRows.map((row) => {
const status = row.kind === "company" ? row.secret.status : row.definition.status;
return (
<div
@ -1652,8 +2044,8 @@ export function Secrets() {
>
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate font-medium text-foreground">
{row.kind === "company" ? row.secret.name : row.definition.name}
<div className="min-w-0 truncate">
{renderSecretName(row.kind === "company" ? row.secret.name : row.definition.name)}
</div>
<code className="mt-0.5 block truncate text-(length:--text-micro) text-muted-foreground">
{row.kind === "company" ? row.secret.key : row.definition.key}
@ -1699,6 +2091,8 @@ export function Secrets() {
);
})}
</div>
</>
)}
</div>
)}
</div>
@ -2063,7 +2457,13 @@ export function Secrets() {
/>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) setCreateNamePrefix(null);
}}
>
<DialogContent className="max-h-(--sz-calc-18) overflow-y-auto p-4 sm:max-w-lg sm:p-6">
<DialogHeader>
<DialogTitle>{editingDefinition ? "Edit user-provided secret" : "Create secret"}</DialogTitle>
@ -2113,24 +2513,67 @@ export function Secrets() {
<div>
<label className="text-xs font-medium" htmlFor="new-secret-name">Name</label>
<Input
id="new-secret-name"
value={createForm.name}
onChange={(event) => {
const name = event.target.value;
setCreateForm((current) => ({
...current,
name,
key: createKeyDirty
? current.key
: secretValueProvider === "user"
? normalizeUserSecretKeyForPreview(name)
: normalizeSecretKeyForPreview(name),
}));
}}
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "/dev/foo/bar"}
autoFocus
/>
{createNamePrefix && !editingDefinition ? (
<div className="flex h-9 w-full min-w-0 items-center gap-1.5 rounded-md border border-input bg-transparent px-2 shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3">
<span
className="inline-flex min-w-0 shrink items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"
title={createNamePrefix}
>
<span className="truncate">{createNamePrefix}</span>
<button
type="button"
className="shrink-0 rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Remove folder prefix"
onClick={() => setCreateNamePrefix(null)}
>
<X className="h-3 w-3" />
</button>
</span>
<input
id="new-secret-name"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
value={createForm.name.slice(createNamePrefix.length)}
onChange={(event) => {
const name = createNamePrefix + event.target.value;
setCreateForm((current) => ({
...current,
name,
key: createKeyDirty
? current.key
: secretValueProvider === "user"
? normalizeUserSecretKeyForPreview(name)
: normalizeSecretKeyForPreview(name),
}));
}}
placeholder="clientsecret"
autoFocus
/>
</div>
) : (
<Input
id="new-secret-name"
value={createForm.name}
onChange={(event) => {
const name = event.target.value;
setCreateForm((current) => ({
...current,
name,
key: createKeyDirty
? current.key
: secretValueProvider === "user"
? normalizeUserSecretKeyForPreview(name)
: normalizeSecretKeyForPreview(name),
}));
}}
placeholder={secretValueProvider === "user" ? "Personal GitHub token" : "/dev/foo/bar"}
autoFocus
/>
)}
{createNamePrefix && !editingDefinition ? (
<p className="mt-1 text-xs text-muted-foreground">
Creating in {folderPath} remove the chip to type a different path.
</p>
) : null}
</div>
{secretValueProvider === "company" && createMode === "managed" ? (

View File

@ -10,6 +10,7 @@ import { queryKeys } from "../../lib/queryKeys";
import { cn } from "../../lib/utils";
import { useToastActions } from "../../context/ToastContext";
import { SetMyUserSecretDialog } from "./SetMyUserSecretDialog";
import { SecretPathName } from "./SecretPathName";
import {
myValueLabel,
myValueState,
@ -136,7 +137,7 @@ function MyUserSecretRow({
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{definition.name}</span>
<SecretPathName name={definition.name} />
<code className="rounded bg-muted px-1.5 py-0.5 text-(length:--text-micro) text-muted-foreground">
{definition.key}
</code>

View File

@ -0,0 +1,43 @@
import { cn } from "@/lib/utils";
import { splitSecretPath } from "./secret-path";
interface SecretPathNameProps {
/** Full stored secret name, e.g. `dev/github/oauth/clientid`. */
name: string;
/**
* Folder path currently being viewed. Segments shared with `name` are
* stripped so folder-view rows show only the part below the open folder.
* Omit (or leave empty) to render the full path used for global search
* results and the My secrets tab.
*/
basePath?: string;
className?: string;
/** Extra classes for the bold leaf segment. */
leafClassName?: string;
}
/**
* Renders a slash-delimited secret name with the directory portion muted and
* the trailing leaf bold. Shared across folder-view rows, global search
* results, and (rendering only) the My secrets tab so paths read the same way
* everywhere. See PAP-14698 plan §Search.
*/
export function SecretPathName({ name, basePath = "", className, leafClassName }: SecretPathNameProps) {
const segments = splitSecretPath(name);
const baseSegments = splitSecretPath(basePath);
const withinBase =
baseSegments.length > 0 &&
baseSegments.every((segment, index) => segments[index] === segment) &&
segments.length > baseSegments.length;
const relative = withinBase ? segments.slice(baseSegments.length) : segments;
const effective = relative.length > 0 ? relative : [name];
const leaf = effective[effective.length - 1];
const directory = effective.slice(0, -1).join("/");
return (
<span className={cn("min-w-0 truncate", className)}>
{directory ? <span className="text-muted-foreground">{directory}/</span> : null}
<span className={cn("font-medium text-foreground", leafClassName)}>{leaf}</span>
</span>
);
}

View File

@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import {
buildSecretPathBreadcrumbs,
buildSecretPathListing,
getSecretPathRowName,
normalizeSecretPath,
validateSecretFolderSegment,
type SecretPathRow,
} from "./secret-path";
type TestRow = SecretPathRow & { id: string };
function companyRow(id: string, name: string): TestRow {
return { id, kind: "company", secret: { name } };
}
function userRow(id: string, name: string): TestRow {
return { id, kind: "user", definition: { name } };
}
describe("secret path normalization", () => {
it("ignores leading, duplicate, and trailing slashes without changing stored names", () => {
const rows = [
companyRow("github", "/dev//github/"),
companyRow("token", "//prod///api//token/"),
userRow("standalone", "/standalone/"),
];
expect(normalizeSecretPath("//dev///github/")).toBe("dev/github");
expect(buildSecretPathListing(rows, "/")).toEqual({
folders: [
{ name: "dev", path: "dev", secretCount: 1, folderCount: 0 },
{ name: "prod", path: "prod", secretCount: 1, folderCount: 1 },
],
secrets: [rows[2]],
});
expect(getSecretPathRowName(rows[0])).toBe("/dev//github/");
});
it("keeps no-slash names at the root", () => {
const rows = [companyRow("one", "alpha"), userRow("two", "beta")];
expect(buildSecretPathListing(rows, "")).toEqual({ folders: [], secrets: rows });
});
});
describe("buildSecretPathListing", () => {
it("shows a name as both a direct secret and a folder when it is also a prefix", () => {
const exact = companyRow("exact", "dev");
const nested = companyRow("nested", "dev/oauth/token");
expect(buildSecretPathListing([exact, nested], "")).toEqual({
folders: [{ name: "dev", path: "dev", secretCount: 1, folderCount: 1 }],
secrets: [exact],
});
expect(buildSecretPathListing([exact, nested], "dev")).toEqual({
folders: [{ name: "oauth", path: "dev/oauth", secretCount: 1, folderCount: 0 }],
secrets: [exact],
});
});
it("groups case-sensitively while sorting case-insensitively", () => {
const rows = [
companyRow("lower-child", "dev/token"),
companyRow("upper-child", "Dev/token"),
companyRow("zebra", "zebra"),
companyRow("alpha-upper", "Alpha"),
companyRow("alpha-lower", "alpha"),
];
const listing = buildSecretPathListing(rows, "");
expect(listing.folders.map((folder) => folder.name)).toEqual(["dev", "Dev"]);
expect(listing.secrets.map(getSecretPathRowName)).toEqual(["Alpha", "alpha", "zebra"]);
});
it("naturally sorts folders and secrets", () => {
const rows = [
companyRow("env10", "env10/token"),
companyRow("env2", "env2/token"),
companyRow("key10", "key10"),
companyRow("key2", "key2"),
];
const listing = buildSecretPathListing(rows, "");
expect(listing.folders.map((folder) => folder.name)).toEqual(["env2", "env10"]);
expect(listing.secrets.map(getSecretPathRowName)).toEqual(["key2", "key10"]);
});
it("preserves encoded and special characters in segments and breadcrumbs", () => {
const row = companyRow("special", "team & ops/100%/api?key=a%2Fb");
expect(buildSecretPathListing([row], "team & ops").folders).toEqual([
{
name: "100%",
path: "team & ops/100%",
secretCount: 1,
folderCount: 0,
},
]);
expect(buildSecretPathBreadcrumbs("/team & ops//100%/api?key=a%2Fb/")).toEqual([
{ name: "team & ops", path: "team & ops" },
{ name: "100%", path: "team & ops/100%" },
{ name: "api?key=a%2Fb", path: "team & ops/100%/api?key=a%2Fb" },
]);
});
it("computes recursive secret and folder counts from the provided rows", () => {
const allRows = [
companyRow("direct", "dev/direct"),
companyRow("a", "dev/a/token"),
companyRow("b", "dev/b/token"),
companyRow("deep", "dev/b/deep/token"),
companyRow("filtered", "dev/c/hidden"),
];
const filteredRows = allRows.filter((row) => row.id !== "filtered");
expect(buildSecretPathListing(filteredRows, "").folders).toEqual([
{ name: "dev", path: "dev", secretCount: 4, folderCount: 3 },
]);
expect(buildSecretPathListing(filteredRows, "dev")).toEqual({
folders: [
{ name: "a", path: "dev/a", secretCount: 1, folderCount: 0 },
{ name: "b", path: "dev/b", secretCount: 2, folderCount: 1 },
],
secrets: [allRows[0]],
});
});
});
describe("validateSecretFolderSegment", () => {
it("rejects empty and slash-containing folder segments", () => {
expect(validateSecretFolderSegment(" ")).toBe("Folder name is required.");
expect(validateSecretFolderSegment("dev/prod")).toBe(
"Folder name cannot contain slashes.",
);
});
it("accepts trimmed and special-character segment names", () => {
expect(validateSecretFolderSegment(" dev tools ")).toBeNull();
expect(validateSecretFolderSegment("100% & encoded%2Fvalue")).toBeNull();
});
});

View File

@ -0,0 +1,112 @@
export type SecretPathRow =
| { kind: "company"; secret: { name: string } }
| { kind: "user"; definition: { name: string } };
export interface SecretPathFolder {
name: string;
path: string;
secretCount: number;
folderCount: number;
}
export interface SecretPathBreadcrumb {
name: string;
path: string;
}
export interface SecretPathListing<Row extends SecretPathRow> {
folders: SecretPathFolder[];
secrets: Row[];
}
const NATURAL_NAME_SORT_OPTIONS: Intl.CollatorOptions = {
numeric: true,
sensitivity: "base",
};
export function getSecretPathRowName(row: SecretPathRow): string {
return row.kind === "company" ? row.secret.name : row.definition.name;
}
export function splitSecretPath(path: string): string[] {
return path.split("/").filter((segment) => segment.length > 0);
}
export function normalizeSecretPath(path: string): string {
return splitSecretPath(path).join("/");
}
function startsWithSegments(segments: readonly string[], prefix: readonly string[]): boolean {
return prefix.every((segment, index) => segments[index] === segment);
}
function compareNames(left: string, right: string): number {
return left.localeCompare(right, undefined, NATURAL_NAME_SORT_OPTIONS);
}
export function buildSecretPathListing<Row extends SecretPathRow>(
rows: readonly Row[],
path: string,
): SecretPathListing<Row> {
const pathSegments = splitSecretPath(path);
const entries = rows.map((row) => ({
row,
name: getSecretPathRowName(row),
segments: splitSecretPath(getSecretPathRowName(row)),
}));
const folderNames = new Set<string>();
const secrets: Row[] = [];
for (const entry of entries) {
if (!startsWithSegments(entry.segments, pathSegments)) continue;
const relativeDepth = entry.segments.length - pathSegments.length;
if (relativeDepth === 0 || relativeDepth === 1) secrets.push(entry.row);
if (relativeDepth >= 2) folderNames.add(entry.segments[pathSegments.length]);
}
const folders = [...folderNames].map((name): SecretPathFolder => {
const folderSegments = [...pathSegments, name];
const descendantFolderPaths = new Set<string>();
let secretCount = 0;
for (const entry of entries) {
if (!startsWithSegments(entry.segments, folderSegments)) continue;
const relativeDepth = entry.segments.length - folderSegments.length;
if (relativeDepth < 1) continue;
secretCount += 1;
for (let depth = 1; depth < relativeDepth; depth += 1) {
descendantFolderPaths.add(
entry.segments.slice(folderSegments.length, folderSegments.length + depth).join("/"),
);
}
}
return {
name,
path: folderSegments.join("/"),
secretCount,
folderCount: descendantFolderPaths.size,
};
});
folders.sort((left, right) => compareNames(left.name, right.name));
secrets.sort((left, right) =>
compareNames(getSecretPathRowName(left), getSecretPathRowName(right)),
);
return { folders, secrets };
}
export function buildSecretPathBreadcrumbs(path: string): SecretPathBreadcrumb[] {
const segments = splitSecretPath(path);
return segments.map((name, index) => ({
name,
path: segments.slice(0, index + 1).join("/"),
}));
}
export function validateSecretFolderSegment(value: string): string | null {
if (!value.trim()) return "Folder name is required.";
if (value.includes("/")) return "Folder name cannot contain slashes.";
return null;
}