From b94907f8af9bc74f7e6174ccff2ea6b88690df8f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:11:35 -0500 Subject: [PATCH] feat(ui): navigate slash-named secrets as folders (#9913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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=` 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 Co-authored-by: Paperclip --- ui/src/pages/Secrets.render.test.tsx | 294 ++++++++++++- ui/src/pages/Secrets.tsx | 507 ++++++++++++++++++++-- ui/src/pages/secrets/MyUserSecretsTab.tsx | 3 +- ui/src/pages/secrets/SecretPathName.tsx | 43 ++ ui/src/pages/secrets/secret-path.test.ts | 142 ++++++ ui/src/pages/secrets/secret-path.ts | 112 +++++ 6 files changed, 1065 insertions(+), 36 deletions(-) create mode 100644 ui/src/pages/secrets/SecretPathName.tsx create mode 100644 ui/src/pages/secrets/secret-path.test.ts create mode 100644 ui/src/pages/secrets/secret-path.ts diff --git a/ui/src/pages/Secrets.render.test.tsx b/ui/src/pages/Secrets.render.test.tsx index c42a0d6680..c4e3aef013 100644 --- a/ui/src/pages/Secrets.render.test.tsx +++ b/ui/src/pages/Secrets.render.test.tsx @@ -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 { @@ -373,9 +381,11 @@ describe("Secrets page layout", () => { await act(async () => { root.render( - - - , + + + + + , ); }); 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( + + + + + , + ); + }); + 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()); + }); +}); diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 7463862d57..4cf2153da4 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -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("company"); const [createMode, setCreateMode] = useState("managed"); const [editingDefinition, setEditingDefinition] = useState(null); + const [createNamePrefix, setCreateNamePrefix] = useState(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(null); const [vaultDiscoveryError, setVaultDiscoveryError] = useState(null); + const [newFolderOpen, setNewFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [newFolderError, setNewFolderError] = useState(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(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 ; + if (showFolderView) return ; + return {name}; + } + + function renderFolderTableRow(folder: SecretPathFolder) { + return ( + +
+
+ + {folder.name} +
+
+ {formatSecretPathCounts(folder.secretCount, folder.folderCount)} +
+
+