mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
## 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>
143 lines
4.9 KiB
TypeScript
143 lines
4.9 KiB
TypeScript
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();
|
|
});
|
|
});
|