From 629f7696a36f11424260eb4a81c057a3e0a77cf9 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 24 Jul 2026 11:26:42 +0200 Subject: [PATCH] Add item duplicate option to browser extension (#2293) --- .../popup/components/Items/ItemCard.tsx | 61 ++++++++- .../components/Items/ItemContextMenu.tsx | 79 +++++++++++ .../popup/pages/items/ItemsList.tsx | 77 +++++++++++ .../src/i18n/locales/en.json | 2 + .../utils/db/repositories/ItemRepository.ts | 125 ++++++++++++++++++ 5 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 apps/browser-extension/src/entrypoints/popup/components/Items/ItemContextMenu.tsx diff --git a/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx index 522109298..76543cc55 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -6,6 +6,7 @@ import type { Item } from '@/utils/dist/core/models/vault'; import { FieldKey } from '@/utils/dist/core/models/vault'; import { truncateFolderPath } from '@/utils/FolderUtils'; +import ItemContextMenu from './ItemContextMenu'; import ItemIcon from './ItemIcon'; type ItemCardProps = { @@ -15,6 +16,9 @@ type ItemCardProps = { currentFolderPath?: string[] | null; isActive?: boolean; optionId?: string; + isHighlighted?: boolean; + onDuplicate?: (itemId: string) => void; + onDelete?: (itemId: string) => void; }; /** @@ -24,9 +28,11 @@ type ItemCardProps = { * It allows the user to navigate to the item details page when clicked. * */ -const ItemCard: React.FC = ({ item, showFolderPath = false, searchTerm = '', currentFolderPath = null, isActive = false, optionId }) => { +const ItemCard: React.FC = ({ item, showFolderPath = false, searchTerm = '', currentFolderPath = null, isActive = false, optionId, isHighlighted = false, onDuplicate, onDelete }) => { const { t } = useTranslation(); const navigate = useNavigate(); + const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null); + const menuEnabled = Boolean(onDuplicate || onDelete); /** * Get the display text for the item (username or email) @@ -102,8 +108,28 @@ const ItemCard: React.FC = ({ item, showFolderPath = false, searc return truncated.join(' > '); }; + /** + * Open the item context menu from a right-click at the cursor position. + */ + const handleContextMenu = (e: React.MouseEvent): void => { + if (!menuEnabled) { + return; + } + e.preventDefault(); + setMenuPosition({ x: e.clientX, y: e.clientY }); + }; + + /** + * Open the item context menu anchored below the ellipsis button. + */ + const handleEllipsisClick = (e: React.MouseEvent): void => { + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + setMenuPosition({ x: rect.right - 160, y: rect.bottom + 4 }); + }; + return ( -
  • +
  • + {menuEnabled && ( + + )} + {menuPosition && ( + setMenuPosition(null)} + onEdit={() => navigate(`/items/${item.Id}/edit`)} + onDuplicate={() => onDuplicate?.(item.Id)} + onDelete={() => onDelete?.(item.Id)} + /> + )}
  • ); }; diff --git a/apps/browser-extension/src/entrypoints/popup/components/Items/ItemContextMenu.tsx b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemContextMenu.tsx new file mode 100644 index 000000000..08ae40e76 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemContextMenu.tsx @@ -0,0 +1,79 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ItemContextMenuProps = { + position: { x: number; y: number }; + onClose: () => void; + onEdit: () => void; + onDuplicate: () => void; + onDelete: () => void; +}; + +const MENU_WIDTH = 160; +const MENU_HEIGHT = 120; + +/** + * Context menu with actions for a single item in the items list. + * Opened via right-click on an item row or via the row's ellipsis button. + */ +const ItemContextMenu: React.FC = ({ position, onClose, onEdit, onDuplicate, onDelete }) => { + const { t } = useTranslation(); + + // Close on Escape while the menu is open. + useEffect(() => { + /** + * Close the menu when Escape is pressed. + */ + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return (): void => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const left = Math.max(8, Math.min(position.x, window.innerWidth - MENU_WIDTH - 8)); + const top = Math.max(8, Math.min(position.y, window.innerHeight - MENU_HEIGHT - 8)); + + const itemClass = 'w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'; + + /** + * Close the menu, then run the given action. + */ + const runAction = (action: () => void) => (): void => { + onClose(); + action(); + }; + + return ( + <> +
    { + e.preventDefault(); + onClose(); + }} + /> +
    + + + +
    + + ); +}; + +export default ItemContextMenu; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index 7dee203f4..bdbf72d23 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import ConfirmDeleteModal from '@/entrypoints/popup/components/Dialogs/ConfirmDeleteModal'; import DeleteFolderModal from '@/entrypoints/popup/components/Folders/DeleteFolderModal'; import FolderBreadcrumb from '@/entrypoints/popup/components/Folders/FolderBreadcrumb'; import FolderModal from '@/entrypoints/popup/components/Folders/FolderModal'; @@ -126,6 +127,8 @@ const ItemsList: React.FC = () => { const [showFolderModal, setShowFolderModal] = useState(false); const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false); const [showEditFolderModal, setShowEditFolderModal] = useState(false); + const [deleteItemId, setDeleteItemId] = useState(null); + const [highlightedItemId, setHighlightedItemId] = useState(null); const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0); const [folderRefreshKey, setFolderRefreshKey] = useState(0); const [sortOrder, setSortOrder] = useState('NewestFirst'); @@ -255,6 +258,67 @@ const ItemsList: React.FC = () => { setItems(results); }, [dbContext, currentFolderId, executeVaultMutationAsync]); + /** + * Duplicate an item via the item context menu. + */ + const handleDuplicateItem = useCallback(async (itemId: string) : Promise => { + if (!dbContext?.sqliteClient) { + return; + } + + let newItemId: string | null = null; + await executeVaultMutationAsync(async () => { + newItemId = await dbContext.sqliteClient!.items.duplicate(itemId); + }); + + // Refresh items to show the new duplicate + const results = dbContext.sqliteClient!.items.getAll(); + setItems(results); + + // Scroll to and briefly highlight the new duplicate so it's clear where it landed + setHighlightedItemId(newItemId); + }, [dbContext, executeVaultMutationAsync]); + + /** + * Scroll the highlighted item (a freshly created duplicate) into view once it's + * rendered, then clear the highlight after a short moment. + */ + useEffect(() => { + if (!highlightedItemId) { + return; + } + + // Wait a frame so the re-rendered list contains the new item before scrolling. + requestAnimationFrame(() => { + document.querySelector(`[data-item-id="${highlightedItemId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + + const timer = setTimeout(() => setHighlightedItemId(null), 2000); + return (): void => clearTimeout(timer); + }, [highlightedItemId]); + + /** + * Move an item to the trash after confirmation via the item context menu. + */ + const handleConfirmDeleteItem = useCallback(async () : Promise => { + if (!dbContext?.sqliteClient || !deleteItemId) { + return; + } + + const itemId = deleteItemId; + setDeleteItemId(null); + + await executeVaultMutationAsync(async () => { + await dbContext.sqliteClient!.items.trash(itemId); + }); + + // Refresh items to reflect the deletion + const results = dbContext.sqliteClient!.items.getAll(); + setItems(results); + const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount(); + setRecentlyDeletedCount(deletedCount); + }, [dbContext, deleteItemId, executeVaultMutationAsync]); + /** * Handle delete folder (keep items, move them to root). */ @@ -1035,6 +1099,9 @@ const ItemsList: React.FC = () => { currentFolderPath={currentFolderPath} isActive={activeKind === 'item' && activeIndex === index} optionId={itemIdFor(index)} + isHighlighted={item.Id === highlightedItemId} + onDuplicate={handleDuplicateItem} + onDelete={setDeleteItemId} /> ))} @@ -1082,6 +1149,16 @@ const ItemsList: React.FC = () => { )} + {/* Delete Item Confirmation Modal */} + setDeleteItemId(null)} + onConfirm={handleConfirmDeleteItem} + title={t('items.deleteItemTitle')} + message={t('items.deleteItemConfirm')} + confirmText={t('common.delete')} + /> + {/* Create Folder Modal */} { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + const newItemId = this.generateId(); + + const sourceRows = this.client.executeQuery<{ Name: string | null }>( + 'SELECT Name FROM Items WHERE Id = ? AND IsDeleted = 0', + [itemId] + ); + if (sourceRows.length === 0) { + throw new Error(`Item not found: ${itemId}`); + } + + const existingNames = this.client.executeQuery<{ Name: string | null }>( + 'SELECT Name FROM Items WHERE IsDeleted = 0 AND DeletedAt IS NULL' + ); + const newName = ItemRepository.generateCopyName( + sourceRows[0].Name, + existingNames.map(row => row.Name) + ); + + // 1. Copy the item row itself (same logo, folder and type). + this.client.executeUpdate(` + INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted) + SELECT ?, ?, ItemType, LogoId, FolderId, ?, ?, 0 FROM Items WHERE Id = ?`, + [newItemId, newName, currentDateTime, currentDateTime, itemId]); + + /* + * 2. Copy custom field definitions so later edits to the duplicate's + * custom fields don't affect the original item. + */ + const definitionRows = this.client.executeQuery<{ Id: string }>( + `SELECT DISTINCT FieldDefinitionId as Id FROM FieldValues + WHERE ItemId = ? AND IsDeleted = 0 AND FieldDefinitionId IS NOT NULL`, + [itemId] + ); + const definitionIdMap = new Map(); + for (const definition of definitionRows) { + const newDefinitionId = this.generateId(); + definitionIdMap.set(definition.Id, newDefinitionId); + this.client.executeUpdate(` + INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted) + SELECT ?, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, ?, ?, 0 FROM FieldDefinitions WHERE Id = ?`, + [newDefinitionId, currentDateTime, currentDateTime, definition.Id]); + } + + // 3. Copy field values, remapping custom fields to the copied definitions. + const fieldValueRows = this.client.executeQuery<{ Id: string; FieldDefinitionId: string | null }>( + 'SELECT Id, FieldDefinitionId FROM FieldValues WHERE ItemId = ? AND IsDeleted = 0', + [itemId] + ); + for (const row of fieldValueRows) { + this.client.executeUpdate(` + INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted) + SELECT ?, ?, ?, FieldKey, Value, Weight, ?, ?, 0 FROM FieldValues WHERE Id = ?`, + [ + this.generateId(), + newItemId, + row.FieldDefinitionId ? definitionIdMap.get(row.FieldDefinitionId) ?? null : null, + currentDateTime, + currentDateTime, + row.Id + ]); + } + + /* + * 4. Copy remaining child rows with fresh IDs. + */ + const childCopies = [ + { + table: 'TotpCodes', + sql: `INSERT INTO TotpCodes (Id, ItemId, Name, SecretKey, CreatedAt, UpdatedAt, IsDeleted) + SELECT ?, ?, Name, SecretKey, ?, ?, 0 FROM TotpCodes WHERE Id = ?`, + }, + { + table: 'Attachments', + sql: `INSERT INTO Attachments (Id, ItemId, Filename, Blob, CreatedAt, UpdatedAt, IsDeleted) + SELECT ?, ?, Filename, Blob, ?, ?, 0 FROM Attachments WHERE Id = ?`, + }, + { + table: 'ItemTags', + sql: `INSERT INTO ItemTags (Id, ItemId, TagId, CreatedAt, UpdatedAt, IsDeleted) + SELECT ?, ?, TagId, ?, ?, 0 FROM ItemTags WHERE Id = ?`, + }, + ]; + + for (const copy of childCopies) { + const rows = this.client.executeQuery<{ Id: string }>( + `SELECT Id FROM ${copy.table} WHERE ItemId = ? AND IsDeleted = 0`, + [itemId] + ); + for (const row of rows) { + this.client.executeUpdate(copy.sql, [this.generateId(), newItemId, currentDateTime, currentDateTime, row.Id]); + } + } + + return newItemId; + }); + } + + /** + * Generate a unique name for a duplicated item: "Name (1)", "Name (2)", etc. + * If the source name already ends with a "(n)" suffix, the counter is incremented + * instead of stacking suffixes. + */ + private static generateCopyName(sourceName: string | null, existingNames: (string | null)[]): string | null { + if (!sourceName) { + return sourceName; + } + + const base = sourceName.replace(/ \(\d+\)$/, ''); + const taken = new Set(existingNames.filter((name): name is string => name !== null)); + + let candidate = `${base} (1)`; + for (let counter = 2; taken.has(candidate); counter++) { + candidate = `${base} (${counter})`; + } + return candidate; + } + /** * Update an existing item with field-based structure. * @param item The item object to update