PIT: UI views

This commit is contained in:
carlosmonastyrski
2025-05-20 08:22:14 -03:00
parent 8bfd3913da
commit 6319f53802
47 changed files with 4134 additions and 196 deletions
@@ -0,0 +1,327 @@
import { useEffect, useState } from "react";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
import { useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import {
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
IconButton,
Spinner
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { usePopUp } from "@app/hooks";
import { CommitWithChanges } from "@app/hooks/api/folderCommits";
import { useCommitRevert, useGetCommitDetails } from "@app/hooks/api/folderCommits/queries";
import { SecretVersionDiffView } from "../SecretVersionDiffView";
import { MergedItem } from "./types";
const formatDisplayDate = (dateString: string): string => {
try {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true
};
return new Intl.DateTimeFormat("en-US", options).format(date);
} catch {
return dateString;
}
};
export const CommitDetailsTab = ({
selectedCommitId,
workspaceId,
envSlug,
goBackToHistory,
goToRollbackPreview
}: {
selectedCommitId: string;
workspaceId: string;
envSlug: string;
goBackToHistory: () => void;
goToRollbackPreview: () => void;
}): JSX.Element => {
// State for tracking collapsed items (empty by default means all are expanded)
const [collapsedItems, setCollapsedItems] = useState<Record<string, boolean>>({});
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"revertChanges"
] as const);
const { data: commitDetails, isLoading } = useGetCommitDetails(workspaceId, selectedCommitId);
const routerQueryParams: { secretPath?: string } = useSearch({
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id
});
const secretPath = (routerQueryParams.secretPath as string) || "/";
const { mutateAsync: revert } = useCommitRevert({
commitId: selectedCommitId,
projectId: workspaceId,
environment: envSlug,
directory: secretPath
});
useEffect(() => {
setCollapsedItems({});
}, [selectedCommitId]);
const toggleItemCollapsed = (itemId: string): void => {
setCollapsedItems((prev) => ({ ...prev, [itemId]: !prev[itemId] }));
};
const handleRevertChanges = async (): Promise<void> => {
const response = await revert();
if (!response.success) {
createNotification({
type: "error",
text: response.message
});
return;
}
createNotification({
type: "success",
text: response.message
});
handlePopUpClose("revertChanges");
goBackToHistory();
};
// If no commit is selected or data is loading, show appropriate message
if (!selectedCommitId) {
return (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-400">Select a commit to view details</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
if (!commitDetails) {
return (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-400">No details found for this commit</p>
</div>
);
}
// Parse the commit details if it's a string
let parsedCommitDetails: CommitWithChanges;
try {
parsedCommitDetails =
typeof commitDetails === "string" ? JSON.parse(commitDetails) : commitDetails;
} catch (error) {
console.error("Failed to parse commit details:", error);
return (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-400">Error parsing commit details</p>
</div>
);
}
// Get all changes from the commit
const commitChanges = parsedCommitDetails.changes?.changes || [];
// Separate changes by type
const addedChanges = commitChanges.filter((c) => c.changeType === "add" && !c.isUpdate);
const updatedChanges = commitChanges.filter((c) => c.changeType === "add" && c.isUpdate);
const deletedChanges = commitChanges.filter((c) => c.changeType === "delete");
// Create merged item list from changes only
const changedItems: MergedItem[] = [];
// Add items from added changes
addedChanges.forEach((change) => {
changedItems.push({
id: change.id,
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
versionId: change.secretVersionId || change.id,
folderName: change.folderName,
folderVersion: change.folderVersion,
secretKey: change.secretKey,
secretVersion: change.secretVersion,
isAdded: true,
versions: change.versions,
changeId: change.id
});
});
// Add items from updated changes
updatedChanges.forEach((change) => {
changedItems.push({
id: change.id,
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
versionId: change.secretVersionId || change.id,
folderName: change.folderName,
folderVersion: change.folderVersion,
secretKey: change.secretKey,
secretVersion: change.secretVersion,
isUpdated: true,
versions: change.versions,
changeId: change.id
});
});
// Add deleted items
deletedChanges.forEach((change) => {
changedItems.push({
id: change.id,
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
secretKey: change.secretKey,
folderName: change.folderName,
secretVersion: change.secretVersion,
folderVersion: change.folderVersion,
isDeleted: true,
versions: change.versions,
changeId: change.id
});
});
// Sort items: deleted first, then folders, then secrets, all alphabetically
const sortedChangedItems = [...changedItems].sort((a, b) => {
// First sort deleted items to the top
if (a.isDeleted !== b.isDeleted) {
return a.isDeleted ? -1 : 1;
}
// Then sort by type (folders before secrets)
if (a.type !== b.type) {
return a.type === "folder" ? -1 : 1;
}
// Finally sort alphabetically by name
const aName = a.type === "folder" ? a.folderName || "" : a.secretKey || "";
const bName = b.type === "folder" ? b.folderName || "" : b.secretKey || "";
return aName.localeCompare(bName);
});
// Render an item from the merged list
const renderMergedItem = (item: MergedItem): JSX.Element => {
return (
<div key={item.id} className="mb-2">
<SecretVersionDiffView
item={item}
isCollapsed={collapsedItems[item.id]}
onToggleCollapse={(id) => toggleItemCollapsed(id)}
/>
</div>
);
};
// Format actor display
const actorDisplay =
parsedCommitDetails.changes?.actorMetadata?.name ||
parsedCommitDetails.changes?.actorType ||
"Unknown";
return (
<div className="w-full">
<div>
<div className="flex justify-between pb-2">
<div>
<div>
<div className="flex items-center">
<h1 className="mr-4 text-3xl font-semibold text-white">
{parsedCommitDetails.changes?.message || "No message"}
</h1>
</div>
</div>
<div className="font-small mb-4 mt-2 flex items-center text-sm">
<p>
<span> Commited by </span>
<b>{actorDisplay}</b>
<span> on </span>
<b>
{formatDisplayDate(
parsedCommitDetails.changes?.createdAt || new Date().toISOString()
)}
</b>
</p>
</div>
</div>
<div className="flex items-center justify-start">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="commit-options"
variant="outline_bg"
className="h-10 rounded border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-medium"
>
<p className="mr-2">Restore Options</p>
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={2}
className="animate-in fade-in-50 zoom-in-95 min-w-[200px] p-1"
style={{ marginTop: "0" }}
>
<DropdownMenuItem
className="h-10 w-full cursor-pointer px-3 py-2 text-sm transition-colors hover:bg-mineshaft-700"
onClick={() => goToRollbackPreview()}
>
<div className="flex w-full items-center justify-start">
<span>Restore entire folder</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
className="h-10 w-full cursor-pointer px-3 py-2 text-sm transition-colors hover:bg-mineshaft-700"
onClick={() => handlePopUpOpen("revertChanges")}
>
<div className="flex w-full items-center justify-start">
<span>Revert commit changes</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="py-2">
<div className="overflow-hidden">
<div className="space-y-2">
{sortedChangedItems.length > 0 ? (
sortedChangedItems.map((item) => renderMergedItem(item))
) : (
<div className="flex h-32 items-center justify-center rounded-lg border border-mineshaft-600 bg-mineshaft-800">
<p className="text-gray-400">No changed items found</p>
</div>
)}
</div>
</div>
</div>
</div>
<DeleteActionModal
isOpen={popUp.revertChanges.isOpen}
deleteKey="revert"
title="Are you sure you want to revert changes from this commit?"
subTitle="This action will undo all changes made in this commit."
onChange={(isOpen) => handlePopUpToggle("revertChanges", isOpen)}
onDeleteApproved={handleRevertChanges}
buttonText="Revert Changes"
/>
</div>
);
};
@@ -0,0 +1 @@
export { CommitDetailsTab } from "./CommitDetailsTab";
@@ -0,0 +1,14 @@
export interface MergedItem {
id: string;
type: "secret" | "folder";
versionId?: string;
folderName?: string;
folderVersion?: string;
secretKey?: string;
secretVersion?: string;
isAdded?: boolean;
isUpdated?: boolean;
isDeleted?: boolean;
versions?: any[];
changeId: string;
}
@@ -0,0 +1,376 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { useEffect, useState } from "react";
import { faFolder, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
Input,
PageHeader,
Spinner,
Switch,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useCommitRollback, useGetRollbackPreview } from "@app/hooks/api/folderCommits/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { SecretVersionDiffView } from "../SecretVersionDiffView";
interface Version {
// Common fields
id?: string;
version: number;
createdAt?: string;
updatedAt?: string;
// Secret-specific fields
secretKey?: string;
secretValue?: string;
secretComment?: string;
skipMultilineEncoding?: boolean;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
secretReminderRecipients?: string[];
tags?: string[];
metadata?: Record<string, unknown>;
// Folder-specific fields
name?: string;
envId?: string;
folderId?: string;
[key: string]: any;
}
interface RollbackChange {
type: "secret" | "folder";
id: string;
versionId: string;
fromVersion?: number;
changeType: "create" | "update" | "delete";
commitId: string;
secretKey?: string;
secretVersion?: number;
folderName?: string;
folderVersion?: number;
versions?: Version[];
createdAt?: string;
}
interface FolderChanges {
folderId: string;
folderName: string;
folderPath: string;
changes: RollbackChange[];
}
export const RollbackPreviewTab = (): JSX.Element => {
const [deepRollback, setDeepRollback] = useState<boolean>(false);
const [message, setMessage] = useState<string>("");
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const { currentWorkspace } = useWorkspace();
const envSlug = useParams({
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
select: (el) => el.environment
});
const selectedCommitId = useParams({
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
select: (el) => el.commitId
});
const folderId = useParams({
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
select: (el) => el.folderId
});
const navigate = useNavigate();
const routerQueryParams = useSearch({
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id
});
const secretPath = (routerQueryParams.secretPath as string) || "/";
const goBackToHistory = () => {
navigate({
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId` as const,
params: {
projectId: currentWorkspace.id,
folderId,
environment: envSlug
},
search: (query) => ({
...query,
secretPath
})
});
};
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"rollbackConfirm"
] as const);
const { mutateAsync: rollback } = useCommitRollback({
workspaceId: currentWorkspace.id,
commitId: selectedCommitId,
folderId,
deepRollback,
environment: envSlug,
directory: secretPath
});
const { data: rollbackChangesNested, isLoading } = useGetRollbackPreview(
folderId,
selectedCommitId,
currentWorkspace.environments.find((e) => e.slug === envSlug)?.id || "",
currentWorkspace.id,
deepRollback,
secretPath
);
const handleRollback = async (): Promise<void> => {
try {
await rollback(message);
createNotification({
type: "success",
text: "Rollback completed successfully"
});
handlePopUpClose("rollbackConfirm");
goBackToHistory();
} catch (error) {
createNotification({
type: "error",
text: error instanceof Error ? error.message : "Failed to rollback changes"
});
}
};
const folderChanges: FolderChanges[] = rollbackChangesNested || [];
const currentFolderChanges: FolderChanges = folderChanges.find(
(folder) => folder.folderId === folderId
) || {
folderId: folderId || "",
folderName: "Current Folder",
folderPath: secretPath,
changes: []
};
const nestedFolderChanges: FolderChanges[] = folderChanges.filter(
(folder) => folder.folderId !== folderId
);
useEffect(() => {
// Select the current folder by default
if (folderChanges.length > 0) {
setSelectedFolderId(currentFolderChanges.folderId);
}
}, [folderChanges, currentFolderChanges.folderId]);
if (!selectedCommitId) {
return (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-400">Select a commit to view rollback preview</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" />
</div>
);
}
const renderSidebar = (): JSX.Element => {
return (
<div className="h-[70vh] w-72 overflow-y-auto bg-mineshaft-900">
<div
className={`cursor-pointer border-b border-mineshaft-600 ${
selectedFolderId === currentFolderChanges.folderId ? "bg-mineshaft-700" : ""
}`}
onClick={() => setSelectedFolderId(currentFolderChanges.folderId)}
>
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center">
<FontAwesomeIcon icon={faFolder} className="mr-2 text-yellow-500" />
<span className="font-medium text-white">
{currentFolderChanges.folderPath || currentFolderChanges.folderName}
</span>
</div>
</div>
</div>
{deepRollback && nestedFolderChanges.length > 0 && (
<>
<div className="border-b border-mineshaft-600 bg-mineshaft-800 px-4 py-2">
<span className="text-sm font-semibold text-white">Affected Child Folders</span>
</div>
{nestedFolderChanges.map((folder) => (
<div
key={folder.folderId}
className={`cursor-pointer border-b border-mineshaft-600 ${
selectedFolderId === folder.folderId ? "bg-mineshaft-700" : ""
}`}
onClick={() => setSelectedFolderId(folder.folderId)}
>
<div className="flex items-center justify-between px-4 py-2">
<div className="flex items-center">
<FontAwesomeIcon icon={faFolder} className="mr-2 text-yellow-500" size="sm" />
<span className="max-w-[150px] truncate text-sm font-medium text-white">
{folder.folderPath || folder.folderName}
</span>
</div>
</div>
</div>
))}
</>
)}
</div>
);
};
const getSelectedFolderChanges = (): RollbackChange[] => {
if (!selectedFolderId) return [];
const folder = folderChanges.find((f) => f.folderId === selectedFolderId);
return folder?.changes || [];
};
const renderMainContent = (): JSX.Element => {
const selectedFolderChanges = getSelectedFolderChanges();
const selectedFolder = folderChanges.find((f) => f.folderId === selectedFolderId);
if (!selectedFolder || selectedFolderChanges.length === 0) {
return (
<div className="flex h-[70vh] w-full items-center justify-center border border-mineshaft-600">
<p className="text-gray-400">No changes in selected folder</p>
</div>
);
}
return (
<div className="h-[70vh] w-full overflow-y-auto border-l border-mineshaft-600">
<div className="space-y-4 p-4">
{selectedFolderChanges.map((change) => (
<div key={change.id} className="mb-4">
<SecretVersionDiffView
item={{
id: change.id,
type: change.type,
isAdded: change.changeType === "create",
isDeleted: change.changeType === "delete",
isUpdated: change.changeType === "update",
versions: change.versions,
isRollback: true,
secretKey: change.secretKey,
folderName: change.folderName
}}
/>
</div>
))}
</div>
</div>
);
};
return (
<div className="mx-auto flex w-full max-w-7xl justify-center bg-bunker-800 pb-4 pt-2 text-white">
<div className="w-full max-w-[75vw]">
<div className="h-full w-full">
<div>
<PageHeader
title={`Restore folder at commit ${selectedCommitId.substring(0, 8)}`}
description="This will restore all changes to how they appeared at the point in time of this commit. Any modifications made after this commit will be undone."
/>
{folderChanges.length > 0 ? (
<div className="flex w-full border border-mineshaft-600">
{renderSidebar()}
{renderMainContent()}
</div>
) : (
<div className="flex h-32 items-center justify-center">
<p className="text-gray-400">No changes will be applied with this restore</p>
</div>
)}
<div className="border-x border-mineshaft-600 bg-mineshaft-800 px-6 py-3">
<div className="flex items-center justify-end">
<div className="flex items-center">
<Tooltip content="Enable/Disable deep rollback to restore all nested folders to their previous state">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2 text-mineshaft-400" />
</Tooltip>
<Switch
className="ml-2 bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/50"
thumbClassName="bg-mineshaft-800"
isChecked={deepRollback}
onCheckedChange={setDeepRollback}
id="deep-rollback"
>
Recursively Restore Child Folders
</Switch>
</div>
</div>
</div>
<div className="border-t border-mineshaft-600 py-4">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex-1">
<Input
placeholder="Restore Message"
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full border-mineshaft-500 bg-mineshaft-700 text-sm"
/>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
if (!message) {
createNotification({
type: "error",
text: "Please enter a restore message"
});
return;
}
handlePopUpOpen("rollbackConfirm");
}}
colorSchema="primary"
className="px-6 py-2"
>
Restore
</Button>
</div>
</div>
</div>
</div>
<DeleteActionModal
isOpen={popUp.rollbackConfirm.isOpen}
deleteKey="restore"
title={`Are you sure you want to restore to commit ${selectedCommitId?.substring(0, 8)}?`}
subTitle={`
${
deepRollback
? "This will revert all changes to how they appeared at the point in time of this commit."
: "This will revert this folder and all child folders to how they appeared at the point in time of this commit."
}
Any changes made after this commit will be permanently removed.
`}
onChange={(isOpen) => handlePopUpToggle("rollbackConfirm", isOpen)}
onDeleteApproved={handleRollback}
buttonText="Restore"
/>
</div>{" "}
</div>{" "}
</div>
);
};
@@ -0,0 +1 @@
export { RollbackPreviewTab } from "./RollbackPreviewTab";
@@ -0,0 +1,91 @@
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SecretDashboardPathBreadcrumb } from "@app/components/navigation/SecretDashboardPathBreadcrumb";
import { BreadcrumbTypes } from "@app/components/v2";
import { RollbackPreviewTab } from "./RollbackPreviewTab";
const RollbackPreviewTabQueryParamsSchema = z.object({
secretPath: z.string().catch("/")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore"
)({
component: RollbackPreviewTab,
validateSearch: zodValidator(RollbackPreviewTabQueryParamsSchema),
search: {
middlewares: [stripSearchParams({ secretPath: "/" })]
},
beforeLoad: ({ context, params, search }) => {
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
return {
breadcrumbs: [
...context.breadcrumbs,
{
type: BreadcrumbTypes.Dropdown,
label:
context.project.environments.find((el) => el.slug === params.environment)?.name || "",
dropdownTitle: "Environments",
links: context.project.environments.map((el) => ({
label: el.name,
link: linkOptions({
to: "/secret-manager/$projectId/secrets/$envSlug",
params: {
projectId: params.projectId,
envSlug: el.slug
}
})
}))
},
...secretPathSegments.map((_, index) => ({
type: BreadcrumbTypes.Component,
component: () => (
<SecretDashboardPathBreadcrumb
secretPathSegments={secretPathSegments}
selectedPathSegmentIndex={index}
environmentSlug={params.environment}
projectId={params.projectId}
disableCopy
/>
)
})),
{
label: "Commits",
link: linkOptions({
to: "/secret-manager/$projectId/commits/$environment/$folderId",
params: {
projectId: params.projectId,
environment: params.environment,
folderId: params.folderId
},
search: {
secretPath: search.secretPath
}
})
},
{
label: params.commitId,
link: linkOptions({
to: "/secret-manager/$projectId/commits/$environment/$folderId/$commitId",
params: {
projectId: params.projectId,
environment: params.environment,
folderId: params.folderId,
commitId: params.commitId
},
search: {
secretPath: search.secretPath
}
})
},
{
label: "Restore"
}
]
};
}
});
@@ -0,0 +1,585 @@
/* eslint-disable react/prop-types */
import { useRef, useState } from "react";
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
export interface Version {
id?: string;
version: number;
// Secret-specific fields
secretKey?: string;
secretValue?: string;
secretComment?: string;
skipMultilineEncoding?: boolean;
// Folder-specific fields
name?: string;
// Allow other properties
[key: string]: any;
}
export interface DiffViewItem {
type: "secret" | "folder";
isAdded?: boolean;
isDeleted?: boolean;
isUpdated?: boolean;
versions?: Version[];
isRollback?: boolean;
id: string;
secretKey?: string;
folderName?: string;
}
interface SecretVersionDiffViewProps {
item: DiffViewItem;
isCollapsed?: boolean;
onToggleCollapse?: (id: string) => void;
showHeader?: boolean;
customHeader?: JSX.Element;
}
const highlightChangedFields = (
json: any,
changedFields: Set<string>,
isOldVersion: boolean
): JSX.Element => {
const lines = JSON.stringify(json, null, 2).split("\n");
return (
<div className="font-mono text-sm">
{lines.map((line, idx) => {
const fieldMatch = line.match(/"([^"]+)":/);
if (fieldMatch && changedFields.has(fieldMatch[1])) {
// Check for different value types and highlight them
// 1. String values: "field": "value"
const stringMatch = line.match(/: "([^"]*)"(,?)$/);
if (stringMatch) {
const beforeValue = line.substring(0, line.indexOf(': "'));
const value = `"${stringMatch[1]}"`;
const afterValue = stringMatch[2] || "";
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>
{beforeValue}:{" "}
<span
className={`rounded border border-none px-1 ${isOldVersion ? "bg-red-900" : "bg-green-900"}`}
>
{value}
</span>
{afterValue}
</div>
</div>
);
}
// 2. Number values: "field": 123
const numberMatch = line.match(/: (-?\d+(?:\.\d+)?)(,?)$/);
if (numberMatch) {
const beforeValue = line.substring(0, line.indexOf(": ") + 2);
const value = numberMatch[1];
const afterValue = numberMatch[2] || "";
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>
{beforeValue.substring(0, beforeValue.length - 2)}:{" "}
<span
className={`rounded border border-none px-1 ${isOldVersion ? "bg-red-900" : "bg-green-900"}`}
>
{value}
</span>
{afterValue}
</div>
</div>
);
}
// 3. Null values: "field": null
const nullMatch = line.match(/: (null)(,?)$/);
if (nullMatch) {
const beforeValue = line.substring(0, line.indexOf(": ") + 2);
const value = nullMatch[1];
const afterValue = nullMatch[2] || "";
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>
{beforeValue.substring(0, beforeValue.length - 2)}:{" "}
<span
className={`rounded border border-none px-1 ${isOldVersion ? "bg-red-900" : "bg-green-900"}`}
>
{value}
</span>
{afterValue}
</div>
</div>
);
}
// 4. Boolean values: "field": true|false
const booleanMatch = line.match(/: (true|false)(,?)$/);
if (booleanMatch) {
const beforeValue = line.substring(0, line.indexOf(": ") + 2);
const value = booleanMatch[1];
const afterValue = booleanMatch[2] || "";
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>
{beforeValue.substring(0, beforeValue.length - 2)}:{" "}
<span
className={`rounded border border-none px-1 ${isOldVersion ? "bg-red-900" : "bg-green-900"}`}
>
{value}
</span>
{afterValue}
</div>
</div>
);
}
// 5. Array values: Handle the first line of an array - "field": [
const arrayStartMatch = line.match(/: \[(,?)$/);
if (arrayStartMatch) {
// This is the start of an array, highlight the whole line
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>{line}</div>
</div>
);
}
// 6. Empty Array values: "field": []
const emptyArrayMatch = line.match(/: \[\](,?)$/);
if (emptyArrayMatch) {
const beforeValue = line.substring(0, line.indexOf(": ") + 2);
const value = "[]";
const afterValue = emptyArrayMatch[1] || "";
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>
{beforeValue.substring(0, beforeValue.length - 2)}:{" "}
<span
className={`rounded border border-none px-1 ${isOldVersion ? "bg-red-900" : "bg-green-900"}`}
>
{value}
</span>
{afterValue}
</div>
</div>
);
}
// Check if this is part of an array or object that belongs to a changed field
// This handles array items, closing brackets, and other complex structure contents
const belongsToChangedField = () => {
// If we're inside an array or object of a changed field, highlight it
let openBrackets = 0;
let openBraces = 0;
let currentFieldName = null;
for (let i = idx - 1; i >= 0; i -= 1) {
const prevLine = lines[i];
// Count brackets and braces to track nesting
openBrackets += (prevLine.match(/\[/g) || []).length;
openBrackets -= (prevLine.match(/\]/g) || []).length;
openBraces += (prevLine.match(/{/g) || []).length;
openBraces -= (prevLine.match(/}/g) || []).length;
// If we find a field and we're still inside its value, check if it's a changed field
const fieldNameMatch = prevLine.match(/"([^"]+)":/);
if (fieldNameMatch && (openBrackets > 0 || openBraces > 0)) {
[, currentFieldName] = fieldNameMatch;
return changedFields.has(currentFieldName);
}
// If we've reached the root level, stop looking
if (openBrackets <= 0 && openBraces <= 0) {
return false;
}
}
return false;
};
// Regular line with changed field or part of a changed complex structure
if (belongsToChangedField()) {
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>{line}</div>
</div>
);
}
// Simple fallback for any other cases of changed fields
return (
<div
key={`${idx + 1}`}
className={
isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300"
}
>
<div className="w-4 flex-shrink-0">{isOldVersion ? "-" : "+"}</div>
<div>{line}</div>
</div>
);
}
// Unchanged lines
return (
<div key={`${idx + 1}`} className="flex">
<div className="w-4 flex-shrink-0" />
<div>{line}</div>
</div>
);
})}
</div>
);
};
// Helper: Format added JSON with + in a separate column
const formatAddedJson = (json: any): JSX.Element => {
const lines = JSON.stringify(json, null, 2).split("\n");
return (
<div className="font-mono text-sm text-green-300">
{lines.map((line, idx) => (
<div key={`${idx + 1}`} className="flex">
<div className="w-4 flex-shrink-0">+</div>
<div>{line}</div>
</div>
))}
</div>
);
};
// Helper: Format deleted JSON with - in a separate column
const formatDeletedJson = (json: any): JSX.Element => {
const lines = JSON.stringify(json, null, 2).split("\n");
return (
<div className="font-mono text-sm text-red-300">
{lines.map((line, idx) => (
<div key={`${idx + 1}`} className="flex">
<div className="w-4 flex-shrink-0">-</div>
<div>{line}</div>
</div>
))}
</div>
);
};
// Helper: Get differences between secret versions
export const getVersionDifferences = (versions: Version[]) => {
if (!versions || versions.length === 0) return [];
// Sort versions by version number (descending)
const sortedVersions = [...versions].sort((a, b) => b.version - a.version);
const newVersion = sortedVersions[0];
// Fields to process
const fieldsToProcess = [
"secretKey",
"secretValue",
"secretComment",
"skipMultilineEncoding",
"secretReminderRepeatDays",
"secretReminderNote",
"metadata",
"tags",
"secretReminderRecipients",
"name"
];
// If only one version exists
if (sortedVersions.length === 1) {
return fieldsToProcess.reduce(
(differences, field) => {
if (newVersion[field] !== undefined && newVersion[field] !== null) {
let newVal = newVersion[field];
if (field === "tags" && Array.isArray(newVersion[field])) {
if (newVersion[field].length > 0 && typeof newVersion[field][0] === "object") {
newVal = newVersion[field].map((tag) => tag.name).join(", ");
} else if (Array.isArray(newVersion[field])) {
newVal = newVersion[field].join(", ");
}
}
differences.push({
field,
oldValue: null,
newValue: newVal
});
}
return differences;
},
[] as { field: string; oldValue: any; newValue: any }[]
);
}
// Otherwise, compare the two versions
const oldVersion = sortedVersions[1];
return fieldsToProcess.reduce(
(differences, field) => {
if (JSON.stringify(oldVersion[field]) !== JSON.stringify(newVersion[field])) {
let oldVal = oldVersion[field];
let newVal = newVersion[field];
if (field === "tags" && Array.isArray(oldVersion[field])) {
if (oldVersion[field].length > 0 && typeof oldVersion[field][0] === "object") {
oldVal = oldVersion[field].map((tag) => tag.name).join(", ");
} else if (Array.isArray(oldVersion[field])) {
oldVal = oldVersion[field].join(", ");
}
}
if (field === "tags" && Array.isArray(newVersion[field])) {
if (newVersion[field].length > 0 && typeof newVersion[field][0] === "object") {
newVal = newVersion[field].map((tag) => tag.name).join(", ");
} else if (Array.isArray(newVersion[field])) {
newVal = newVersion[field].join(", ");
}
}
differences.push({
field,
oldValue: oldVal,
newValue: newVal
});
}
return differences;
},
[] as { field: string; oldValue: any; newValue: any }[]
);
};
// Helper: Get differences between folder versions
export const getFolderDifferences = (versions: Version[]) => {
if (!versions || versions.length === 0) return [];
const sortedVersions = [...versions].sort((a, b) => b.version - a.version);
const newVersion = sortedVersions[0];
if (sortedVersions.length === 1) {
return [
{
field: "folderName",
oldValue: null,
newValue: newVersion.name
}
];
}
const oldVersion = sortedVersions[1];
return [
{
field: "folderName",
oldValue: oldVersion.name,
newValue: newVersion.name
}
];
};
export const SecretVersionDiffView: React.FC<SecretVersionDiffViewProps> = ({
item,
isCollapsed = false,
onToggleCollapse,
showHeader = true,
customHeader
}) => {
const oldContainerRef = useRef<HTMLDivElement>(null);
const newContainerRef = useRef<HTMLDivElement>(null);
const [isScrollingSynced, setIsScrollingSynced] = useState(false);
const [internalCollapsed, setInternalCollapsed] = useState(isCollapsed);
const collapsed = onToggleCollapse ? isCollapsed : internalCollapsed;
const handleToggle = () => {
if (onToggleCollapse && item.id) {
onToggleCollapse(item.id);
} else {
setInternalCollapsed(!internalCollapsed);
}
};
if (!item.versions) {
return <div className="px-6 py-3 text-gray-400">No details available</div>;
}
const differences = getVersionDifferences(item.versions);
if (differences.length === 0) {
return <div className="px-6 py-3 text-gray-400">No details available</div>;
}
const changedFields = new Set<string>();
differences.forEach((diff) => {
if (JSON.stringify(diff.oldValue) !== JSON.stringify(diff.newValue)) {
changedFields.add(diff.field);
}
});
const handleScroll = (container: "old" | "new") => {
if (isScrollingSynced) return;
setIsScrollingSynced(true);
if (container === "old" && oldContainerRef.current && newContainerRef.current) {
newContainerRef.current.scrollTop = oldContainerRef.current.scrollTop;
} else if (container === "new" && oldContainerRef.current && newContainerRef.current) {
oldContainerRef.current.scrollTop = newContainerRef.current.scrollTop;
}
setTimeout(() => setIsScrollingSynced(false), 50);
};
const sortedVersions = [...item.versions].sort((a, b) => b.version - a.version);
let oldVersion = null;
let newVersion = null;
let oldVersionContent = null;
let newVersionContent = null;
if (item.isUpdated) {
if (item.isRollback) {
[oldVersion, newVersion] = sortedVersions;
} else {
[newVersion, oldVersion] = sortedVersions;
}
oldVersionContent = highlightChangedFields(oldVersion, changedFields, true);
newVersionContent = highlightChangedFields(newVersion, changedFields, false);
} else if (item.isAdded) {
[newVersion] = sortedVersions;
oldVersionContent = <div className="italic text-gray-400" />;
newVersionContent = formatAddedJson(newVersion);
} else if (item.isDeleted) {
[oldVersion] = sortedVersions;
oldVersionContent = formatDeletedJson(oldVersion);
newVersionContent = <div className="italic text-gray-400" />;
}
const renderHeader = () => {
if (customHeader) {
return customHeader;
}
const isSecret = item.type === "secret";
const key = isSecret ? item.secretKey || "" : item.folderName || "";
let textStyle = "text-white";
let changeBadge = null;
if (item.isDeleted) {
textStyle = "line-through text-red-300";
changeBadge = (
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
{isSecret ? "Secret" : "Folder"} Deleted
</span>
);
} else if (item.isAdded) {
changeBadge = (
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
{isSecret ? "Secret" : "Folder"} Added
</span>
);
} else if (item.isUpdated) {
changeBadge = (
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
{isSecret ? "Secret" : "Folder"} Updated
</span>
);
}
return (
<div
className="flex cursor-pointer items-center justify-between p-4 hover:bg-mineshaft-700"
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleToggle();
e.preventDefault();
}
}}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
>
<div className="flex items-center">
<span className={textStyle}>{key}</span>
{changeBadge}
</div>
<FontAwesomeIcon icon={collapsed ? faChevronDown : faChevronUp} className="text-gray-400" />
</div>
);
};
return (
<div className="overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-800">
{showHeader && renderHeader()}
{!collapsed && (
<div className="border-t border-mineshaft-700 bg-mineshaft-900 px-6 py-4">
<div className="grid grid-cols-2 gap-4">
<div
ref={oldContainerRef}
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
onScroll={() => handleScroll("old")}
>
{oldVersionContent}
</div>
<div
ref={newContainerRef}
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
onScroll={() => handleScroll("new")}
>
{newVersionContent}
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1 @@
export { SecretVersionDiffView } from "./SecretVersionDiffView";