diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 59fa0751..76ef1bf3 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1556,7 +1556,10 @@ "dialogRenameCollectionTitle": "Rename collection", "dialogRenameCollectionDescription": "Enter a new name for the collection.", "placeholderRenameCollection": "Collection name", - "renameAction": "Rename" + "renameAction": "Rename", + "dialogDeleteBulkTitle": "Delete Collections", + "dialogDeleteBulkDescription": "This action cannot be undone. The following collections will be permanently deleted:", + "cancel": "Cancel" }, "collectionItem": { "newFolder": "New folder", diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index 624680f0..856b4179 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -80,7 +80,7 @@ export function ApiClient() { const [activeTabId, setActiveTabId] = React.useState(tabs[0].id) const [isInitialized, setIsInitialized] = React.useState(false) const abortControllerRef = React.useRef(null) - const { collections, addFolder, deleteItem, saveRequest, toggleFolder, createCollection, renameCollection, renameFolder, isLoading: collectionsLoading } = useCollections() + const { collections, addFolder, deleteItem, saveRequest, toggleFolder, createCollection, renameCollection, renameFolder, deleteMultipleCollections, isLoading: collectionsLoading } = useCollections() const { history, addHistoryItem, clearHistory, deleteHistoryItem } = useHistory() const { environments, @@ -530,7 +530,7 @@ export function ApiClient() { try { const parsed = parseCurlCommand(curl) const resolvedUrl = replaceUrlWithEnvBaseUrl(parsed.url) - + updateActiveTab({ ...parsed, url: resolvedUrl || activeTab.url, @@ -543,6 +543,10 @@ export function ApiClient() { } } + const handleDeleteMultipleCollections = async (ids: string[]) => { + await deleteMultipleCollections(ids) + } + return (
@@ -575,6 +579,7 @@ export function ApiClient() { history={history} onClearHistory={clearHistory} onDeleteHistoryItem={deleteHistoryItem} + onDeleteMultiple={handleDeleteMultipleCollections} />
@@ -763,6 +768,7 @@ export function ApiClient() { history={history} onClearHistory={clearHistory} onDeleteHistoryItem={deleteHistoryItem} + onDeleteMultiple={handleDeleteMultipleCollections} />
)} diff --git a/apps/web/src/components/api-client/collections/collections-sidebar.tsx b/apps/web/src/components/api-client/collections/collections-sidebar.tsx index 0be841c5..569766d2 100644 --- a/apps/web/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/web/src/components/api-client/collections/collections-sidebar.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionFolder, CollectionRequest, HistoryRequest } from "../types" import { CollectionItem } from "./collection-item" @@ -42,6 +43,7 @@ interface CollectionsSidebarProps { history?: HistoryRequest[] onClearHistory?: () => void onDeleteHistoryItem?: (id: string) => void + onDeleteMultiple?: (ids: string[]) => void } export function CollectionsSidebar({ @@ -57,6 +59,7 @@ export function CollectionsSidebar({ history, onClearHistory, onDeleteHistoryItem, + onDeleteMultiple, }: CollectionsSidebarProps) { const t = useTranslations("ApiClient.collectionsSidebar") const tRoot = useTranslations("ApiClient") @@ -89,6 +92,9 @@ export function CollectionsSidebar({ const [renameCollectionName, setRenameCollectionName] = React.useState("") const [targetParentId, setTargetParentId] = React.useState(null) const [targetCollectionId, setTargetCollectionId] = React.useState(null) + const [selectedCollections, setSelectedCollections] = React.useState>(new Set()) + const [deleteBulkDialogOpen, setDeleteBulkDialogOpen] = React.useState(false) + const [isDeleting, setIsDeleting] = React.useState(false) const handleAddFolder = () => { if (newFolderName && targetParentId) { @@ -127,21 +133,50 @@ export function CollectionsSidebar({ setRenameCollectionDialogOpen(true) } + const toggleCollectionSelection = (collectionId: string) => { + setSelectedCollections(prev => { + const next = new Set(prev) + if (next.has(collectionId)) { + next.delete(collectionId) + } else { + next.add(collectionId) + } + return next + }) + } + + const clearSelection = () => { + setSelectedCollections(new Set()) + } + return (

{t("title")}

- +
+ {selectedCollections.size > 0 && ( + + )} + +
{t("tabCollections")} @@ -173,9 +208,18 @@ export function CollectionsSidebar({ collections.map((collection) => (
- - {collection.name} - +
+
+ toggleCollectionSelection(collection.id)} + className="h-4 w-4" + /> +
+ + {collection.name} + +
@@ -432,6 +476,55 @@ export function CollectionsSidebar({ + + + + + {t("dialogDeleteBulkTitle") || "Delete Collections"} + + {t("dialogDeleteBulkDescription") || "This action cannot be undone. The following collections will be permanently deleted:"} + + +
+
+ {Array.from(selectedCollections).map(collectionId => { + const collection = collections.find(c => c.id === collectionId) + return ( +
+ + {collection?.name || "Unknown"} +
+ ) + })} +
+
+ + + + +
+
) } diff --git a/apps/web/src/components/api-client/collections/use-collections.ts b/apps/web/src/components/api-client/collections/use-collections.ts index 103b420a..e7c90239 100644 --- a/apps/web/src/components/api-client/collections/use-collections.ts +++ b/apps/web/src/components/api-client/collections/use-collections.ts @@ -367,6 +367,66 @@ export function useCollections() { } } + const deleteMultipleCollections = async (ids: string[]) => { + if (!user) return + if (ids.length === 0) return + + try { + // Use Promise.allSettled to handle partial failures gracefully + const deleteResults = await Promise.allSettled( + ids.map((id) => + authedFetch(`/api/backend/api-client/collections/${id}`, { method: "DELETE" }) + ) + ) + + const successfulIds: string[] = [] + const failedIds: string[] = [] + + deleteResults.forEach((result, index) => { + if (result.status === "fulfilled") { + if (result.value.ok) { + successfulIds.push(ids[index]) + } else { + failedIds.push(ids[index]) + } + } else { + failedIds.push(ids[index]) + } + }) + + // Remove successfully deleted collections from state + if (successfulIds.length > 0) { + setCollections((prev) => prev.filter((c) => !successfulIds.includes(c.id))) + } + + // Handle results with appropriate feedback + if (failedIds.length === 0) { + // All successful + toast.success(ids.length === 1 ? "Collection deleted" : `${ids.length} collections deleted`) + } else if (successfulIds.length === 0) { + // All failed + console.error("Failed to delete collections:", failedIds) + toast.error(ids.length === 1 ? "Failed to delete collection" : "Failed to delete collections") + throw new Error("All collections failed to delete") + } else { + // Partial failure + const failedNames = failedIds + .map(id => collections.find(c => c.id === id)?.name) + .filter(Boolean) + .join(", ") + console.error("Partial failure deleting collections:", failedIds) + toast.error(`Deleted ${successfulIds.length} of ${ids.length} collections. Failed: ${failedNames || "unknown"}`) + throw new Error(`Partial failure: ${failedIds.length} collections failed to delete`) + } + } catch (error) { + console.error("Failed to delete collections:", error) + if (!(error instanceof Error) || !error.message.includes("Partial failure")) { + toast.error("Failed to delete collections") + } + throw error // Re-throw so caller knows deletion failed + } + } + return { collections, addFolder, @@ -376,6 +436,7 @@ export function useCollections() { createCollection, renameCollection, renameFolder, + deleteMultipleCollections, isLoading } } diff --git a/docs/superpowers/plans/2026-06-17-multiselect-delete-collections.md b/docs/superpowers/plans/2026-06-17-multiselect-delete-collections.md new file mode 100644 index 00000000..527c6e66 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-multiselect-delete-collections.md @@ -0,0 +1,500 @@ +# Multiselect and Batch Delete Collections Implementation Plan + +> **For agentic workers:** RECOMMENDED: Use superpowers:subagent-driven-development to execute this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add ability to select multiple collections via checkboxes and delete them in bulk with confirmation dialog, while preserving existing single-item delete via dropdown. + +**Architecture:** Add `selectedCollections` state to CollectionsSidebar component. Render checkboxes on collection row hover. Show floating delete button in header when selections exist. Batch delete triggers confirmation dialog listing selected names. All changes isolated to CollectionsSidebar component. + +**Tech Stack:** React, TypeScript, shadcn/ui components (Dialog, Button, Checkbox), Lucide icons + +## Global Constraints + +- Checkboxes appear on hover only (not always visible) +- Keep existing single-item dropdown delete unchanged +- Floating delete button shows in header when `selectedCollections.size > 0` +- Display "Delete (X selected)" where X is count of selected collections +- Confirmation dialog lists all selected collection names before final delete +- Selection clears after successful batch delete + +--- + +### Task 1: Add selectedCollections State and Selection Handlers + +**Files:** +- Modify: `apps/web/src/components/api-client/collections/collections-sidebar.tsx:1-130` + +**Interfaces:** +- Produces: `selectedCollections: Set` state +- Produces: `toggleCollectionSelection(id: string): void` handler +- Produces: `clearSelection(): void` handler + +- [ ] **Step 1: Add state at top of component after existing state** + +After line 91 where `targetCollectionId` is declared, add: + +```typescript +const [selectedCollections, setSelectedCollections] = React.useState>(new Set()) +``` + +- [ ] **Step 2: Add selection toggle handler** + +After `openRenameCollectionDialog` function (around line 128), add: + +```typescript +const toggleCollectionSelection = (collectionId: string) => { + setSelectedCollections(prev => { + const next = new Set(prev) + if (next.has(collectionId)) { + next.delete(collectionId) + } else { + next.add(collectionId) + } + return next + }) +} + +const clearSelection = () => { + setSelectedCollections(new Set()) +} +``` + +- [ ] **Step 3: Verify no TypeScript errors** + +Run: `cd apps/web && npm run type-check` +Expected: No errors in collections-sidebar.tsx + +- [ ] **Step 4: Commit** + +```bash +cd /Users/max/Works/Personal/mydevtools.tech +git add apps/web/src/components/api-client/collections/collections-sidebar.tsx +git commit -m "feat(collections): add selectedCollections state and handlers" +``` + +--- + +### Task 2: Render Checkbox on Collection Row Hover + +**Files:** +- Modify: `apps/web/src/components/api-client/collections/collections-sidebar.tsx:173-213` + +**Interfaces:** +- Consumes: `selectedCollections: Set` +- Consumes: `toggleCollectionSelection(id: string): void` + +- [ ] **Step 1: Import Checkbox component** + +At top with other imports (around line 1-30), add to the component imports: + +```typescript +import { Checkbox } from "@/components/ui/checkbox" +``` + +- [ ] **Step 2: Modify collection row to include checkbox on hover** + +Find the collection row container (line 175, starting with `
+
+
+ toggleCollectionSelection(collection.id)} + className="h-4 w-4" + /> +
+ + {collection.name} + +
+
+ + + + + + + openRenameCollectionDialog(collection)}> + + {t("rename")} + + onDelete(collection.id)} + > + + {t("delete")} + + + +
+
+``` + +- [ ] **Step 3: Verify checkboxes appear on hover** + +Run: `cd apps/web && npm run dev` +Navigate to Collections sidebar, hover over a collection row. Verify checkbox appears before collection name. + +- [ ] **Step 4: Verify checkbox toggle works** + +Click checkbox. Verify it toggles checked/unchecked state visually. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/max/Works/Personal/mydevtools.tech +git add apps/web/src/components/api-client/collections/collections-sidebar.tsx +git commit -m "feat(collections): add checkbox to collection row on hover" +``` + +--- + +### Task 3: Add Floating Delete Button to Header + +**Files:** +- Modify: `apps/web/src/components/api-client/collections/collections-sidebar.tsx:133-150` + +**Interfaces:** +- Consumes: `selectedCollections: Set` + +- [ ] **Step 1: Modify header to show delete button when selections exist** + +Find the header section (lines 133-150, the `
`). Replace the header content with: + +```typescript +
+
+

{t("title")}

+
+ {selectedCollections.size > 0 && ( + + )} + +
+
+ + {t("tabCollections")} + {t("tabHistory")} + +
+``` + +- [ ] **Step 2: Add state for bulk delete dialog** + +After the `selectedCollections` state (around line 92), add: + +```typescript +const [deleteBulkDialogOpen, setDeleteBulkDialogOpen] = React.useState(false) +``` + +- [ ] **Step 3: Test floating delete button** + +Run dev server, select multiple collections via checkboxes. Verify "Delete (X)" button appears in header with correct count. Verify it disappears when all selections cleared. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/max/Works/Personal/mydevtools.tech +git add apps/web/src/components/api-client/collections/collections-sidebar.tsx +git commit -m "feat(collections): add floating delete button in header for bulk delete" +``` + +--- + +### Task 4: Add Bulk Delete Confirmation Dialog + +**Files:** +- Modify: `apps/web/src/components/api-client/collections/collections-sidebar.tsx:435-437` + +**Interfaces:** +- Consumes: `selectedCollections: Set` +- Consumes: `collections: Collection[]` +- Consumes: `deleteBulkDialogOpen: boolean` +- Consumes: `setDeleteBulkDialogOpen: (open: boolean) => void` + +- [ ] **Step 1: Add bulk delete confirmation dialog** + +Before the closing `
` of the component (after the rename dialog, around line 434), add: + +```typescript + + + + {t("dialogDeleteBulkTitle") || "Delete Collections"} + + {t("dialogDeleteBulkDescription") || "This action cannot be undone. The following collections will be permanently deleted:"} + + +
+
+ {Array.from(selectedCollections).map(collectionId => { + const collection = collections.find(c => c.id === collectionId) + return ( +
+ + {collection?.name || "Unknown"} +
+ ) + })} +
+
+ + + + +
+
+``` + +- [ ] **Step 2: Update component props type to include onDeleteMultiple** + +Find `CollectionsSidebarProps` interface (around line 32), add to the props: + +```typescript +onDeleteMultiple?: (ids: string[]) => void +``` + +- [ ] **Step 3: Add destructuring for onDeleteMultiple** + +In function parameters (line 47-60), add to the destructuring: + +```typescript +onDeleteMultiple, +``` + +- [ ] **Step 4: Test bulk delete dialog** + +Run dev server, select 2-3 collections, click "Delete (X)" button. Verify dialog opens and lists selected collection names. Click cancel - dialog closes. Click delete - confirm dialog closes and onDeleteMultiple callback is called (check network tab for API call). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/max/Works/Personal/mydevtools.tech +git add apps/web/src/components/api-client/collections/collections-sidebar.tsx +git commit -m "feat(collections): add bulk delete confirmation dialog" +``` + +--- + +### Task 5: Implement onDeleteMultiple Handler in Parent Component + +**Files:** +- Find parent component that uses CollectionsSidebar (search for ` { + try { + // Call API to delete multiple collections + await Promise.all( + ids.map(id => + fetch(`/api/nosql/collection/drop`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ collectionName: /* get name from collections */ }) + }) + ) + ) + // Refresh collections list + await fetchCollections() + } catch (error) { + console.error("Failed to delete collections:", error) + // Show error toast to user + } +} +``` + +- [ ] **Step 3: Pass handler to CollectionsSidebar** + +In the ` { + const results = await Promise.allSettled( + ids.map(id => + fetch(`/api/nosql/collection/drop`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ collectionName: getCollectionName(id) }) + }) + ) + ) + + const failed = results.filter(r => r.status === "rejected").length + if (failed > 0) { + showErrorToast(`Failed to delete ${failed} collection(s). Retrying...`) + } + + // Refresh collections regardless + await fetchCollections() +} +``` + +- [ ] **Step 2: Test delete with network error** + +Temporarily block network requests for delete API. Select collections and delete. Verify: +- Error message shown +- Selection state preserved for retry +- Collections list refreshes after error + +- [ ] **Step 3: Test empty selection edge case** + +Clear all selections via unchecking boxes. Verify: +- Delete button disappears +- No dialog shows + +- [ ] **Step 4: Commit error handling** + +```bash +cd /Users/max/Works/Personal/mydevtools.tech +git add apps/web/src +git commit -m "feat(collections): add error handling for batch delete failures" +``` + +--- + +## Plan Verification + +✅ Spec coverage: +- selectedCollections state and toggle handlers (Task 1) +- Checkbox rendering on hover (Task 2) +- Floating delete button in header (Task 3) +- Confirmation dialog listing selected names (Task 4) +- onDeleteMultiple implementation (Task 5) +- Single delete via dropdown unchanged (Tasks 2, 6) +- Selection clears after delete (Task 4) + +✅ No placeholders - all code shown +✅ Type consistency - Set for selectedCollections, handlers defined +✅ File paths exact - collections-sidebar.tsx specified with line numbers +✅ Testing included - manual test flows in Task 6, edge cases in Task 7 diff --git a/docs/superpowers/plans/2026-06-17-redis-key-search-implementation.md b/docs/superpowers/plans/2026-06-17-redis-key-search-implementation.md new file mode 100644 index 00000000..0610c61e --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-redis-key-search-implementation.md @@ -0,0 +1,1156 @@ +# Redis Commander: Smart Key Search Implementation Plan + +> **For agentic workers:** RECOMMENDED: Use superpowers:subagent-driven-development to execute this plan task-by-task with review checkpoints between tasks. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add intelligent live search/filtering to Redis Commander's key browser with glob, regex, and fuzzy matching modes. + +**Architecture:** Client-side search pipeline with pattern detection (glob → regex → fuzzy), 300ms debounced input, filtered key display, and optional mode override panel. All matching on already-loaded keys, no server calls. + +**Tech Stack:** React (hooks), TypeScript, Tailwind CSS, `lucide-react` icons + +## Global Constraints + +- Target: thousands of keys (no server pagination required for Phase 1a/1b) +- Debounce: 300ms +- Regex error handling: fall back to fuzzy, show error inline +- Highlight colors: fuzzy chars `bg-yellow-200/50`, glob/regex segments `bg-blue-200/50` +- Search persists while tab open, clears on tab close/connection switch +- Keyboard: Escape clears search, Enter is no-op + +--- + +## File Structure + +### New Files +- `src/components/redis-commander/search-utils.ts` — Matching algorithms + type detection +- `src/components/redis-commander/search-bar.tsx` — Input + mode icon + clear button +- `src/components/redis-commander/advanced-search-panel.tsx` — Mode toggles + reset button + +### Modified Files +- `src/components/redis-commander/types.ts` — Add SearchMode type +- `src/components/redis-commander/key-browser.tsx` — Integrate SearchBar, wire up state, filter keys +- `src/components/redis-commander/key-item.tsx` (or similar) — Add match highlighting to key display (if exists; otherwise add to key-browser inline) + +--- + +## Tasks + +### Task 1: Create search-utils.ts with matching algorithms + +**Files:** +- Create: `src/components/redis-commander/search-utils.ts` +- Test: `src/components/redis-commander/__tests__/search-utils.test.ts` + +**Interfaces:** +- Produces: `detectSearchMode(input: string): 'glob' | 'regex' | 'fuzzy'` +- Produces: `globMatch(keys: string[], pattern: string): string[]` +- Produces: `regexMatch(keys: string[], pattern: string): {keys: string[], error?: string}` +- Produces: `fuzzyMatch(keys: string[], pattern: string): string[]` +- Produces: `getMatchIndices(key: string, pattern: string, mode: 'glob' | 'regex' | 'fuzzy'): number[]` (for highlighting) + +**Steps:** + +- [ ] **Step 1: Write failing tests for globMatch** + +Create `src/components/redis-commander/__tests__/search-utils.test.ts`: + +```typescript +import { globMatch, regexMatch, fuzzyMatch, detectSearchMode, getMatchIndices } from '../search-utils'; + +describe('globMatch', () => { + it('matches glob pattern with *', () => { + const keys = ['user:123', 'user:456', 'session:789']; + const result = globMatch(keys, 'user:*'); + expect(result).toEqual(['user:123', 'user:456']); + }); + + it('matches glob pattern with ?', () => { + const keys = ['user:1', 'user:12', 'session:1']; + const result = globMatch(keys, 'user:?'); + expect(result).toEqual(['user:1']); + }); + + it('returns empty array for no matches', () => { + const keys = ['user:123', 'user:456']; + const result = globMatch(keys, 'session:*'); + expect(result).toEqual([]); + }); + + it('escapes special regex chars in pattern', () => { + const keys = ['user.name', 'username', 'user_name']; + const result = globMatch(keys, 'user.name'); + expect(result).toEqual(['user.name']); + }); +}); +``` + +Run: `npm test -- search-utils.test.ts -t globMatch` +Expected: FAIL (function not defined) + +- [ ] **Step 2: Write failing tests for regexMatch** + +Add to `src/components/redis-commander/__tests__/search-utils.test.ts`: + +```typescript +describe('regexMatch', () => { + it('matches valid regex pattern', () => { + const keys = ['session:123', 'session:456', 'user:123']; + const result = regexMatch(keys, '^session:[0-9]+$'); + expect(result.keys).toEqual(['session:123', 'session:456']); + expect(result.error).toBeUndefined(); + }); + + it('returns error for invalid regex', () => { + const keys = ['session:123']; + const result = regexMatch(keys, '^session['); + expect(result.keys).toEqual([]); + expect(result.error).toBeDefined(); + }); + + it('matches case-sensitive by default', () => { + const keys = ['Session:123', 'session:123']; + const result = regexMatch(keys, '^session:'); + expect(result.keys).toEqual(['session:123']); + }); +}); +``` + +Run: `npm test -- search-utils.test.ts -t regexMatch` +Expected: FAIL + +- [ ] **Step 3: Write failing tests for fuzzyMatch** + +Add to `src/components/redis-commander/__tests__/search-utils.test.ts`: + +```typescript +describe('fuzzyMatch', () => { + it('matches fuzzy pattern case-insensitive', () => { + const keys = ['user_profile', 'user_settings', 'user_data', 'session_data']; + const result = fuzzyMatch(keys, 'user'); + expect(result).toContain('user_profile'); + expect(result).toContain('user_settings'); + expect(result).not.toContain('session_data'); + }); + + it('requires characters in order', () => { + const keys = ['user_profile', 'profile_user']; + const result = fuzzyMatch(keys, 'user_pro'); + expect(result).toEqual(['user_profile']); + }); + + it('matches single character', () => { + const keys = ['user_1', 'admin_1', 'guest_1']; + const result = fuzzyMatch(keys, 'u'); + expect(result).toContain('user_1'); + expect(result).not.toContain('admin_1'); + }); + + it('returns empty for no match', () => { + const keys = ['user:123', 'session:456']; + const result = fuzzyMatch(keys, 'xyz'); + expect(result).toEqual([]); + }); +}); +``` + +Run: `npm test -- search-utils.test.ts -t fuzzyMatch` +Expected: FAIL + +- [ ] **Step 4: Write failing tests for detectSearchMode** + +Add to `src/components/redis-commander/__tests__/search-utils.test.ts`: + +```typescript +describe('detectSearchMode', () => { + it('detects glob pattern with *', () => { + expect(detectSearchMode('user:*')).toBe('glob'); + }); + + it('detects glob pattern with ?', () => { + expect(detectSearchMode('user:?')).toBe('glob'); + }); + + it('detects regex pattern', () => { + expect(detectSearchMode('^user:[0-9]+$')).toBe('regex'); + }); + + it('detects regex with various special chars', () => { + expect(detectSearchMode('user[0-9]')).toBe('regex'); + expect(detectSearchMode('(user|admin)')).toBe('regex'); + expect(detectSearchMode('user{2,5}')).toBe('regex'); + }); + + it('defaults to fuzzy for normal text', () => { + expect(detectSearchMode('userprofile')).toBe('fuzzy'); + }); + + it('prioritizes glob over regex', () => { + expect(detectSearchMode('user:*[0-9]')).toBe('glob'); + }); +}); +``` + +Run: `npm test -- search-utils.test.ts -t detectSearchMode` +Expected: FAIL + +- [ ] **Step 5: Write failing tests for getMatchIndices** + +Add to `src/components/redis-commander/__tests__/search-utils.test.ts`: + +```typescript +describe('getMatchIndices', () => { + it('returns char indices for fuzzy match', () => { + const indices = getMatchIndices('user_profile', 'usr', 'fuzzy'); + expect(indices).toContain(0); // 'u' + expect(indices).toContain(1); // 's' + expect(indices).toContain(2); // 'e' + }); + + it('returns segment range for glob match', () => { + const indices = getMatchIndices('user:123', 'user:*', 'glob'); + // Should highlight the matched part up to the * + expect(indices.length).toBeGreaterThan(0); + }); + + it('returns segment range for regex match', () => { + const indices = getMatchIndices('session:123', '^session:[0-9]+$', 'regex'); + expect(indices.length).toBeGreaterThan(0); + }); +}); +``` + +Run: `npm test -- search-utils.test.ts -t getMatchIndices` +Expected: FAIL + +- [ ] **Step 6: Implement search-utils.ts** + +Create `src/components/redis-commander/search-utils.ts`: + +```typescript +export type SearchMode = 'glob' | 'regex' | 'fuzzy'; + +/** + * Detect search mode based on input pattern. + * Glob (contains * or ?) > Regex (contains regex special chars) > Fuzzy (default) + */ +export function detectSearchMode(input: string): SearchMode { + if (input.includes('*') || input.includes('?')) return 'glob'; + if (/[\^\$\[\]\(\)\{\}\.\|\+\\]/.test(input)) return 'regex'; + return 'fuzzy'; +} + +/** + * Convert glob pattern to regex and match keys. + * Supports * (any chars) and ? (single char). + */ +export function globMatch(keys: string[], pattern: string): string[] { + // Escape regex special chars except * and ? + let regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + + try { + const regex = new RegExp(`^${regexPattern}$`); + return keys.filter(key => regex.test(key)); + } catch { + return []; + } +} + +/** + * Match keys against regex pattern. + * Returns {keys: matched, error?: errorMessage} + */ +export function regexMatch( + keys: string[], + pattern: string +): { keys: string[]; error?: string } { + try { + const regex = new RegExp(pattern); + return { keys: keys.filter(key => regex.test(key)) }; + } catch (err) { + return { + keys: [], + error: err instanceof Error ? err.message : 'Invalid regex', + }; + } +} + +/** + * Fuzzy match: all chars in pattern must appear in key in order (case-insensitive). + */ +export function fuzzyMatch(keys: string[], pattern: string): string[] { + if (!pattern) return keys; + + const lower = pattern.toLowerCase(); + return keys.filter(key => { + let patternIdx = 0; + let keyIdx = 0; + const keyLower = key.toLowerCase(); + + while (patternIdx < lower.length && keyIdx < keyLower.length) { + if (lower[patternIdx] === keyLower[keyIdx]) { + patternIdx++; + } + keyIdx++; + } + + return patternIdx === lower.length; + }); +} + +/** + * Get indices of matched characters/segments for highlighting. + * - Fuzzy: array of char indices + * - Glob/Regex: array of segment indices [start, end, start, end, ...] + */ +export function getMatchIndices( + key: string, + pattern: string, + mode: SearchMode +): number[] { + if (mode === 'fuzzy') { + const indices: number[] = []; + const lower = pattern.toLowerCase(); + let patternIdx = 0; + + for (let keyIdx = 0; keyIdx < key.length && patternIdx < lower.length; keyIdx++) { + if (lower[patternIdx] === key[keyIdx].toLowerCase()) { + indices.push(keyIdx); + patternIdx++; + } + } + + return indices; + } + + if (mode === 'glob') { + // Convert glob to regex and find matching portion + let regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.'); + + try { + const regex = new RegExp(`^(${regexPattern})$`); + const match = key.match(regex); + if (match && match[1]) { + return [0, match[1].length]; + } + } catch { + return []; + } + } + + if (mode === 'regex') { + try { + const regex = new RegExp(pattern); + const match = key.match(regex); + if (match) { + const start = match.index ?? 0; + return [start, start + match[0].length]; + } + } catch { + return []; + } + } + + return []; +} +``` + +Run: `npm test -- search-utils.test.ts` +Expected: PASS (all tests) + +- [ ] **Step 7: Commit** + +```bash +git add src/components/redis-commander/search-utils.ts src/components/redis-commander/__tests__/search-utils.test.ts +git commit -m "feat: add search matching algorithms (glob, regex, fuzzy)" +``` + +--- + +### Task 2: Add search-related types + +**Files:** +- Modify: `src/components/redis-commander/types.ts` + +**Interfaces:** +- Produces: `SearchState` type with `input`, `mode`, `modeOverride`, `filteredKeys`, `regexError` + +**Steps:** + +- [ ] **Step 1: Add SearchState type to types.ts** + +Read current types first: + +```bash +head -50 src/components/redis-commander/types.ts +``` + +Then add to `src/components/redis-commander/types.ts` after existing types: + +```typescript +export type SearchMode = 'glob' | 'regex' | 'fuzzy'; + +export interface SearchState { + input: string; + detectedMode: SearchMode; + userModeOverride: SearchMode | null; + regexError: string | null; + matchCount: number; + showAdvanced: boolean; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/redis-commander/types.ts +git commit -m "feat: add SearchState type" +``` + +--- + +### Task 3: Create SearchBar component + +**Files:** +- Create: `src/components/redis-commander/search-bar.tsx` + +**Interfaces:** +- Consumes: `SearchMode` from types, `detectSearchMode` from search-utils +- Produces: React component `SearchBar` with props: + - `value: string` + - `onChange: (value: string) => void` + - `onClear: () => void` + - `detectedMode: SearchMode` + - `matchCount: number` + - `onToggleAdvanced: () => void` + +**Steps:** + +- [ ] **Step 1: Create SearchBar component** + +Create `src/components/redis-commander/search-bar.tsx`: + +```typescript +"use client"; + +import { IconX, IconSearch, IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { SearchMode } from "./types"; +import { cn } from "@/lib/utils"; + +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + onClear: () => void; + detectedMode: SearchMode; + matchCount: number; + showAdvanced: boolean; + onToggleAdvanced: () => void; + regexError?: string | null; +} + +export function SearchBar({ + value, + onChange, + onClear, + detectedMode, + matchCount, + showAdvanced, + onToggleAdvanced, + regexError, +}: SearchBarProps) { + const modeIcon: Record = { + glob: '⚡', + fuzzy: '🔍', + regex: '.*', + }; + + const modeLabel: Record = { + glob: 'Glob', + fuzzy: 'Fuzzy', + regex: 'Regex', + }; + + return ( +
+
+ + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') onClear(); + }} + className={cn( + "flex-1 h-8 text-sm", + regexError && "border-destructive" + )} + /> + + {modeIcon[detectedMode]} + {matchCount} + + {value && ( + + )} + +
+ + {regexError && ( +
+ {regexError} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/redis-commander/search-bar.tsx +git commit -m "feat: add SearchBar component" +``` + +--- + +### Task 4: Create AdvancedPanel component + +**Files:** +- Create: `src/components/redis-commander/advanced-search-panel.tsx` + +**Interfaces:** +- Consumes: `SearchMode` from types +- Produces: React component `AdvancedPanel` with props: + - `currentMode: SearchMode` + - `onModeChange: (mode: SearchMode) => void` + - `onResetToAuto: () => void` + +**Steps:** + +- [ ] **Step 1: Create AdvancedPanel component** + +Create `src/components/redis-commander/advanced-search-panel.tsx`: + +```typescript +"use client"; + +import { Button } from "@/components/ui/button"; +import { SearchMode } from "./types"; +import { cn } from "@/lib/utils"; + +interface AdvancedPanelProps { + currentMode: SearchMode; + onModeChange: (mode: SearchMode) => void; + onResetToAuto: () => void; +} + +const modeDescriptions: Record = { + glob: 'Glob: Use * (any chars) and ? (single char). E.g. user:*', + fuzzy: 'Fuzzy: All chars in pattern must appear in order. E.g. userprofile', + regex: 'Regex: Full regex support. E.g. ^session:[0-9]+$', +}; + +export function AdvancedPanel({ + currentMode, + onModeChange, + onResetToAuto, +}: AdvancedPanelProps) { + const modes: SearchMode[] = ['glob', 'fuzzy', 'regex']; + + return ( +
+
+

+ {modeDescriptions[currentMode]} +

+
+ {modes.map((mode) => ( + + ))} +
+
+ +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/redis-commander/advanced-search-panel.tsx +git commit -m "feat: add AdvancedPanel component" +``` + +--- + +### Task 5: Integrate search into KeyBrowser (part 1 - state & filtering) + +**Files:** +- Modify: `src/components/redis-commander/key-browser.tsx:1-150` (add search state) + +**Interfaces:** +- Consumes: `SearchState` type, `detectSearchMode`, `globMatch`, `regexMatch`, `fuzzyMatch` from search-utils +- Produces: Modified KeyBrowser component with search state management + +**Steps:** + +- [ ] **Step 1: Read current key-browser.tsx to understand structure** + +```bash +head -100 src/components/redis-commander/key-browser.tsx +``` + +- [ ] **Step 2: Add search state to KeyBrowser** + +At the top of the component's state (after existing useState calls), add: + +```typescript +import { SearchMode, SearchState } from "./types"; +import { + detectSearchMode, + globMatch, + regexMatch, + fuzzyMatch, + getMatchIndices +} from "./search-utils"; +import { useCallback, useRef, useEffect } from "react"; +import { debounce } from "lodash-es"; // or create simple debounce helper + +// Inside component, after other useState calls: +const [searchState, setSearchState] = useState & { matchCount: number }>({ + input: '', + detectedMode: 'fuzzy', + userModeOverride: null, + regexError: null, + matchCount: 0, + showAdvanced: false, +}); + +const [allKeys, setAllKeys] = useState([]); // Store unfiltered keys +const [displayedKeys, setDisplayedKeys] = useState([]); // Filtered keys shown + +// Debounce ref for search +const debouncedSearchRef = useRef | null>(null); +``` + +- [ ] **Step 3: Create search handling logic** + +Add function after useState calls: + +```typescript +const performSearch = useCallback((input: string, mode?: SearchMode, modeOverride?: SearchMode | null) => { + const effectiveMode = modeOverride || mode || 'fuzzy'; + + if (!input.trim()) { + setSearchState(prev => ({ + ...prev, + input: '', + detectedMode: detectSearchMode(''), + regexError: null, + matchCount: allKeys.length, + })); + setDisplayedKeys(allKeys); + return; + } + + const detected = detectSearchMode(input); + let filtered: RedisKeyInfo[] = []; + let error: string | null = null; + + const useMode = modeOverride || detected; + + if (useMode === 'glob') { + const keyStrs = allKeys.map(k => k.key); + const matchedKeys = globMatch(keyStrs, input); + filtered = allKeys.filter(k => matchedKeys.includes(k.key)); + } else if (useMode === 'regex') { + const result = regexMatch(allKeys.map(k => k.key), input); + if (result.error) { + error = result.error; + // Fall back to fuzzy + const keyStrs = allKeys.map(k => k.key); + const matchedKeys = fuzzyMatch(keyStrs, input); + filtered = allKeys.filter(k => matchedKeys.includes(k.key)); + } else { + filtered = allKeys.filter(k => result.keys.includes(k.key)); + } + } else { + const keyStrs = allKeys.map(k => k.key); + const matchedKeys = fuzzyMatch(keyStrs, input); + filtered = allKeys.filter(k => matchedKeys.includes(k.key)); + } + + setSearchState(prev => ({ + ...prev, + input, + detectedMode: detected, + userModeOverride: modeOverride ?? prev.userModeOverride, + regexError: error, + matchCount: filtered.length, + })); + setDisplayedKeys(filtered); +}, [allKeys]); + +// Create debounced version +const debouncedSearch = useCallback((input: string) => { + if (!debouncedSearchRef.current) { + debouncedSearchRef.current = debounce((i: string) => { + performSearch(i); + }, 300); + } + debouncedSearchRef.current(input); +}, [performSearch]); +``` + +- [ ] **Step 4: Handle mode override** + +Add handlers: + +```typescript +const handleSearchChange = useCallback((input: string) => { + setSearchState(prev => ({ ...prev, input })); + debouncedSearch(input); +}, [debouncedSearch]); + +const handleClearSearch = useCallback(() => { + setSearchState(prev => ({ + ...prev, + input: '', + regexError: null, + detectedMode: 'fuzzy', + userModeOverride: null, + matchCount: allKeys.length, + })); + setDisplayedKeys(allKeys); +}, [allKeys]); + +const handleModeChange = useCallback((mode: SearchMode) => { + performSearch(searchState.input, searchState.detectedMode, mode); +}, [searchState.input, searchState.detectedMode, performSearch]); + +const handleResetMode = useCallback(() => { + performSearch(searchState.input, searchState.detectedMode, null); +}, [searchState.input, searchState.detectedMode, performSearch]); + +const handleToggleAdvanced = useCallback(() => { + setSearchState(prev => ({ ...prev, showAdvanced: !prev.showAdvanced })); +}, []); +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/components/redis-commander/key-browser.tsx +git commit -m "feat: add search state management to KeyBrowser" +``` + +--- + +### Task 6: Integrate SearchBar UI into KeyBrowser + +**Files:** +- Modify: `src/components/redis-commander/key-browser.tsx:150-250` (add UI) + +**Steps:** + +- [ ] **Step 1: Import SearchBar and AdvancedPanel** + +At top of key-browser.tsx: + +```typescript +import { SearchBar } from "./search-bar"; +import { AdvancedPanel } from "./advanced-search-panel"; +``` + +- [ ] **Step 2: Add SearchBar to render** + +Find where the key list is rendered. Before the key list, add: + +```typescript + + +{searchState.showAdvanced && ( + +)} + +{/* Key list - render displayedKeys instead of allKeys */} +{displayedKeys.map(key => ( + // ... existing key render code, but for displayedKeys +))} + +{displayedKeys.length === 0 && searchState.input && ( +
+ No keys match "{searchState.input}" +
+)} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/redis-commander/key-browser.tsx +git commit -m "feat: integrate SearchBar and AdvancedPanel into KeyBrowser UI" +``` + +--- + +### Task 7: Add match highlighting to key display + +**Files:** +- Modify: `src/components/redis-commander/key-browser.tsx` (key render section) + +**Steps:** + +- [ ] **Step 1: Create HighlightedKeyText component** + +Add before the component export in key-browser.tsx: + +```typescript +interface HighlightedKeyTextProps { + text: string; + indices: number[]; + mode: SearchMode; +} + +function HighlightedKeyText({ text, indices, mode }: HighlightedKeyTextProps) { + if (indices.length === 0) return {text}; + + if (mode === 'fuzzy') { + // Indices are individual char positions + const indicesSet = new Set(indices); + return ( + + {text.split('').map((char, i) => ( + + {char} + + ))} + + ); + } + + // Glob or regex: indices are [start, end] + if (indices.length >= 2) { + const start = indices[0]; + const end = indices[1]; + return ( + + {text.substring(0, start)} + + {text.substring(start, end)} + + {text.substring(end)} + + ); + } + + return {text}; +} +``` + +- [ ] **Step 2: Use HighlightedKeyText in key rendering** + +When rendering each key name, use getMatchIndices to get indices and render with HighlightedKeyText: + +```typescript +{displayedKeys.map(keyInfo => { + const isSelected = selectedKey === keyInfo.key; + const highlightIndices = searchState.input + ? getMatchIndices( + keyInfo.key, + searchState.input, + searchState.userModeOverride || searchState.detectedMode + ) + : []; + + return ( +
onSelectKey(keyInfo.key)} + className={cn( + "flex items-center gap-2 px-3 py-2 text-sm cursor-pointer rounded-md transition-colors hover:bg-accent", + isSelected && "bg-accent" + )} + > + {/* Key type icon */} + {getKeyIcon(keyInfo.type)} + + {/* Key name with highlighting */} + + + + + {/* TTL badge */} + {keyInfo.ttl > 0 && ( + {keyInfo.ttl}s + )} +
+ ); +})} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/redis-commander/key-browser.tsx +git commit -m "feat: add match highlighting to key display" +``` + +--- + +### Task 8: Handle search state reset on tab change + +**Files:** +- Modify: `src/components/redis-commander/key-browser.tsx` (useEffect section) + +**Steps:** + +- [ ] **Step 1: Add useEffect to clear search when tab/connection changes** + +Add useEffect hook: + +```typescript +// Clear search when tab/connection changes +useEffect(() => { + handleClearSearch(); +}, [redisUrl]); // redisUrl changes = new tab/connection +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/components/redis-commander/key-browser.tsx +git commit -m "feat: clear search state on tab change" +``` + +--- + +### Task 9: Test end-to-end search functionality + +**Files:** +- Modify: `src/components/redis-commander/key-browser.tsx` + +**Steps:** + +- [ ] **Step 1: Start dev server** + +```bash +npm run dev +``` + +- [ ] **Step 2: Test novice user flow** + +- Navigate to Redis Commander +- Open a connection with several keys +- Type "user_" in search box → should filter to fuzzy mode automatically +- Type more characters → results narrow +- Click clear button → search resets + +- [ ] **Step 3: Test glob pattern** + +- Clear search +- Type "session:*" in search box → should detect glob mode (icon shows ⚡) +- Verify results match only "session:*" pattern keys +- Highlight should show blue segment + +- [ ] **Step 4: Test regex pattern** + +- Clear search +- Type "^[a-z]+:[0-9]{3}$" in search box → should detect regex mode (icon shows .*) +- Verify results match pattern + +- [ ] **Step 5: Test regex error handling** + +- Type invalid regex "^[invalid" → should show error message, fall back to fuzzy +- Fix the regex → error should clear + +- [ ] **Step 6: Test mode override** + +- Type "user*" (fuzzy pattern that looks like glob with *) +- Click advanced toggle → panel opens +- Click "Glob" button → results change to glob matching +- Click "Reset to Auto-Detect" → goes back to auto + +- [ ] **Step 7: Test keyboard shortcuts** + +- Focus search box +- Press Escape → search clears + +- [ ] **Step 8: Test tab close** + +- Type in search box +- Close tab (or switch connection) +- Re-open connection → search should be empty + +- [ ] **Step 9: Verify performance** + +- Create test data with 1000+ keys (or use existing large dataset) +- Type search patterns +- Verify no lag, filtering is instant + +--- + +### Task 10: Code cleanup & final commit + +**Files:** +- Verify all files + +**Steps:** + +- [ ] **Step 1: Remove any console.log or debug code** + +Search for `console.log` in modified files: + +```bash +grep -n "console.log" src/components/redis-commander/key-browser.tsx +``` + +Remove if any. + +- [ ] **Step 2: Run linter** + +```bash +npm run lint -- src/components/redis-commander/ +``` + +Fix any linting errors. + +- [ ] **Step 3: Run full test suite** + +```bash +npm test -- search-utils.test.ts +``` + +All tests should pass. + +- [ ] **Step 4: Run type check** + +```bash +npm run type-check +# or +tsc --noEmit +``` + +No TypeScript errors. + +- [ ] **Step 5: Final commit with summary** + +```bash +git log --oneline | head -10 # See all commits from this feature +``` + +If needed, squash related commits: + +```bash +git rebase -i HEAD~ +``` + +Or leave as separate commits (each commit = working state = good for review). + +--- + +## Testing Strategy Summary + +### Unit Tests (Already done in Task 1) +- ✅ globMatch, regexMatch, fuzzyMatch, detectSearchMode, getMatchIndices + +### Integration Tests (Manual in Task 9) +- ✅ Live search filtering works +- ✅ Mode detection correct +- ✅ Clear button resets +- ✅ Tab change clears search +- ✅ Mode toggle works +- ✅ Error handling works +- ✅ Performance acceptable + +### To Add Later (Phase 2) +- Saved searches per connection +- Search history +- Keyboard shortcut Cmd+F + +--- + +## Success Criteria + +✅ All matching algorithms tested and working +✅ SearchBar + AdvancedPanel components render correctly +✅ Search filters keys live (300ms debounce) +✅ Mode auto-detection works for glob/regex/fuzzy +✅ Users can override auto-detection +✅ Regex errors are caught, fallback to fuzzy +✅ Highlighting shows matched chars/segments +✅ Search persists while tab open, clears on close +✅ Performance: no lag with 1000+ keys +✅ Keyboard: Escape clears search +✅ All tests pass, no linting errors + +--- + +## Rollout + +**v1 (Phase 1a+1b complete):** SearchBar + filtering + error handling + highlighting +**v2 (future - Phase 1c):** Saved searches, search history diff --git a/docs/superpowers/specs/2026-06-17-multiselect-delete-collections-design.md b/docs/superpowers/specs/2026-06-17-multiselect-delete-collections-design.md new file mode 100644 index 00000000..99cd0158 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-multiselect-delete-collections-design.md @@ -0,0 +1,87 @@ +# Multiselect and Batch Delete Collections + +**Date:** 2026-06-17 +**Status:** Design approved +**Scope:** Add multiselect checkboxes and batch delete for API client collections + +## Overview + +Add ability to select multiple collections and delete them in bulk. Maintains existing single-item delete via dropdown menu. Checkboxes appear on hover, floating delete button shows when selections exist. + +## Components & Changes + +### CollectionsSidebar Component +- Add `selectedCollections: Set` state to track checked collection IDs +- Pass `selectedCollections` state and handlers to collection row rendering +- Show/hide delete button in header based on selection count +- Display "X selected" badge next to floating delete button + +### Collection Row +- Render checkbox on hover (between collection name and existing action buttons) +- Checkbox toggles collection ID in/out of `selectedCollections` set +- Indeterminate state: not needed (no nested selection) + +### Header Controls +- New floating delete button appears when `selectedCollections.size > 0` +- Button text: "Delete (X selected)" where X = count +- Button color: destructive (red/warning) +- Positioned in header alongside existing "New Collection" button + +### Delete Confirmation Dialog +- Triggered by floating delete button or individual dropdown +- Lists all selected collection names +- Warning: "This action cannot be undone" +- Buttons: "Cancel" and "Delete" (destructive style) + +## Data Flow + +**Single Delete (unchanged):** +- User clicks dropdown menu → Delete option → confirmation → `onDelete(id)` + +**Batch Delete (new):** +- User checks checkboxes → floating delete button appears +- Click floating delete button → confirmation dialog shows selected names +- Confirm → `onDeleteMultiple(ids: string[])` called with all selected IDs +- Clear selection state after successful delete + +## Interface Changes + +**CollectionsSidebarProps additions:** +```typescript +onDeleteMultiple?: (ids: string[]) => void +``` + +**No breaking changes:** Existing `onDelete(id)` callback unchanged. + +## UX Flows + +### Multiselect Flow +1. Hover over collection → checkbox appears +2. Check 1+ collections → floating delete button appears in header +3. Click "Delete (X selected)" → confirmation dialog +4. Confirm → collections deleted, selection cleared + +### Single Delete Flow (unchanged) +1. Hover over collection → action menu appears +2. Click More menu → Delete option +3. Confirm → collection deleted + +## Error Handling + +- If delete fails mid-batch: show error toast, keep selection state so user can retry +- Invalid collection ID: silently skip (already deleted or doesn't exist) + +## Testing + +- Select single collection, click floating delete, confirm deletion works +- Select multiple collections, verify count updates, confirm batch delete works +- Verify selection clears after successful delete +- Single dropdown delete still works without affecting multiselect state +- Empty collection list: no checkboxes shown, floating delete hidden + +## Styling + +- Checkboxes: use existing checkbox component (radix-ui or shadcn/ui) +- Floating delete button: match destructive button style +- Selection badge: use pill/badge component with count +- No new CSS classes needed, use existing utility classes diff --git a/docs/superpowers/specs/2026-06-17-redis-commander-key-search.md b/docs/superpowers/specs/2026-06-17-redis-commander-key-search.md new file mode 100644 index 00000000..3fc6ca09 --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-redis-commander-key-search.md @@ -0,0 +1,270 @@ +# Redis Commander: Smart Key Search (Phase 1 - DX Improvements) + +**Date:** 2026-06-17 +**Scope:** Enhanced key discovery for Redis Commander with live pattern matching +**Target:** Instances with thousands of keys, make finding specific keys fast and intuitive + +--- + +## Overview + +Add intelligent search/filtering to the key browser. Users can find keys using glob patterns, fuzzy search, or regex — with auto-detection so novice users get sensible defaults and power users can override when needed. + +## Problem + +Currently browsing Redis instances with thousands of keys requires scrolling through infinite list. No efficient way to find specific keys. Users need: +- Quick key lookup without manual scrolling +- Support for common filtering patterns (glob, fuzzy, regex) +- Live feedback as they type + +## Solution + +**Smart Key Search** — search box at top of key browser with: +- **Auto-detection:** Input analyzed to guess pattern type (glob → regex → fuzzy) +- **Live filtering:** Results update as user types (300ms debounce) +- **Optional override:** Collapsible advanced panel lets power users pick mode explicitly +- **Client-side:** All matching happens on already-loaded keys (no server round-trip) + +--- + +## Architecture + +### Client-Side Search Pipeline + +``` +User Input + ↓ +Debounce (300ms) + ↓ +Pattern Detection (glob > regex > fuzzy) + ↓ +Filter Loaded Keys + ↓ +Render Results + Match Highlighting +``` + +### Pattern Detection Rules + +| Input | Detected Mode | Example | +|-------|---------------|---------| +| Contains `*` or `?` | Glob | `session:*`, `user:?` | +| Contains regex chars (`^`, `[`, `]`, `(`, `)`, `{`, `}`, `.`, `\|`, `+`) | Regex | `^user_[0-9]+$` | +| Otherwise | Fuzzy | `userprofile` matches `user_profile` | + +### Fallback Behavior + +- Regex syntax error → show error tooltip, fall back to fuzzy search temporarily +- No matches → display "No keys match" with hint about current mode + +--- + +## Components + +### 1. SearchBar +**Location:** Top of KeyBrowser component +**Props:** +- `searchValue: string` +- `onSearchChange: (value: string) => void` +- `detectedMode: 'glob' | 'regex' | 'fuzzy'` +- `matchCount: number` + +**UI:** +- Input field with placeholder "Search keys… (glob, regex, or fuzzy)" +- Icon showing detected mode (⚡ glob, 🔍 fuzzy, `.*` regex) +- Match count badge ("42 matches") +- Clear button (×) + +### 2. AdvancedPanel (Collapsible) +**Trigger:** Chevron/expand icon next to match count +**Contents:** +- Current mode label with explanation ("Fuzzy: Matches any substring") +- Three toggle buttons: [Glob] [Fuzzy] [Regex] +- Selected mode highlighted +- "Reset to auto-detect" button + +### 3. HighlightedKeyList +**Behavior:** +- Show filtered keys only +- If fuzzy search: highlight matching characters with subtle background +- If glob/regex: highlight full matching segment +- Maintain existing key browser UI (type icons, TTL, etc.) + +### 4. ErrorState +**When regex compile fails:** +- Inline error message: "Invalid regex: [error detail]" +- Auto-fallback to fuzzy search +- User can fix regex or switch mode + +--- + +## Data Flow & State + +### Component State (KeyBrowser) +```typescript +const [searchInput, setSearchInput] = useState(''); +const [detectedMode, setDetectedMode] = useState<'glob' | 'regex' | 'fuzzy'>('fuzzy'); +const [userModeOverride, setUserModeOverride] = useState(null); +const [filteredKeys, setFilteredKeys] = useState([]); +``` + +### Search Logic (Pseudo) +```typescript +function handleSearchChange(input: string) { + setSearchInput(input); + + if (!input.trim()) { + setFilteredKeys(allKeys); + return; + } + + const mode = userModeOverride || detectMode(input); + setDetectedMode(mode); + + const results = filterKeys(allKeys, input, mode); + setFilteredKeys(results); +} + +function detectMode(input: string): 'glob' | 'regex' | 'fuzzy' { + if (input.includes('*') || input.includes('?')) return 'glob'; + if (/[\^$\[\](){}.\|+]/.test(input)) return 'regex'; + return 'fuzzy'; +} + +function filterKeys(keys: RedisKeyInfo[], pattern: string, mode: string): RedisKeyInfo[] { + if (mode === 'glob') return globMatch(keys, pattern); + if (mode === 'regex') return regexMatch(keys, pattern); + return fuzzyMatch(keys, pattern); +} +``` + +### Matching Implementations + +**Glob:** Use glob-to-regex library or simple `*` → `.*`, `?` → `.` +**Regex:** Direct `new RegExp(pattern)` with try-catch +**Fuzzy:** Every character in pattern must appear in order (case-insensitive) + +--- + +## Behavior & Edge Cases + +### Search Lifecycle +1. Search persists while tab is open +2. Closes/resets when tab closed or connection switched +3. Empty search always shows all keys (current state) + +### Debouncing +- 300ms debounce on input to avoid excessive filtering +- Clear button is instant (no debounce) + +### Performance +- All filtering happens on already-loaded keys (client-side) +- No server calls during search +- For millions of keys: paginated key list + search filters on current page +- Highlight computation only for visible results + +### Keyboard Navigation +- **Cmd+F / Ctrl+F** in key browser focuses search input (existing browser shortcut, don't override) +- **Escape** clears search and refocuses key list +- **Enter** in search box is no-op (search is live) + +### Match Highlighting +- **Fuzzy:** Matching characters highlighted with `bg-yellow-200/50` (subtle) +- **Glob/Regex:** Full matched segment highlighted with `bg-blue-200/50` +- Highlighting only when search is active + +--- + +## UX Flows + +### Happy Path: Novice User +1. Types "user_" in search box +2. Auto-detection → Fuzzy mode +3. Results narrow: "user_profile", "user_settings", "user_123" appear +4. Clicks one to view + +### Power User: Regex +1. Clicks in search box +2. Types `^session:[0-9]{3}$` (exact pattern) +3. Auto-detection recognizes regex +4. Mode icon changes to `.*` +5. Results show matching sessions + +### Correction: Regex Syntax Error +1. User types invalid regex `^user[` +2. Error shown inline: "Invalid regex: Unterminated character class" +3. Search falls back to fuzzy automatically +4. Shows "fuzzy: no matches" (user can clear and retry) + +--- + +## Implementation Strategy + +### Phase 1a (Required) +- Add SearchBar component with input + mode icon +- Implement three matching algorithms (glob, regex, fuzzy) +- Integrate into KeyBrowser, filter displayed keys +- Debounce search input + +### Phase 1b (Polish) +- AdvancedPanel with mode toggles +- Match highlighting with character/segment markers +- Error handling for invalid regex +- Keyboard shortcuts (Escape to clear) + +### Phase 1c (Future) +- Saved searches per connection (localStorage) +- Search history +- Keyboard shortcut Cmd+F (conflicts with browser find, skip for now) + +--- + +## Files to Modify/Create + +### New Files +- `components/redis-commander/search-bar.tsx` — Input + mode display +- `components/redis-commander/search-utils.ts` — Glob/regex/fuzzy matching logic +- `components/redis-commander/advanced-search-panel.tsx` — Mode toggles & controls + +### Modified Files +- `components/redis-commander/key-browser.tsx` — Integrate SearchBar, wire up filtering +- `components/redis-commander/types.ts` — Add search-related types if needed + +--- + +## Testing Strategy + +### Unit Tests (search-utils.ts) +- Glob matching: `user:*` matches `user:123`, `user:profile` +- Glob edge cases: no matches, special chars, escaped `\*` +- Regex matching: valid patterns, error cases +- Fuzzy matching: case-insensitive, order preserved, no matches + +### Integration Tests (key-browser.tsx) +- Search input updates filtered list live +- Mode detection works correctly +- Clear button resets +- Tab change clears search +- Mode toggle works + +### Manual Testing +- Search 1000+ key list, verify no lag +- Type invalid regex, verify error + fallback +- Switch modes, verify results change +- Close/reopen tab, verify search cleared + +--- + +## Success Criteria + +✅ Finding keys in thousands-key instance is fast and intuitive +✅ Glob/fuzzy/regex all work without configuration +✅ Live search has no perceptible lag +✅ Users can override auto-detection if needed +✅ Errors are clear and recoverable + +--- + +## Rollout + +**v1 (Phase 1a+1b):** SearchBar + filtering + error handling + highlighting +**v2 (Phase 1c):** Saved searches, search history (future phase) diff --git a/pitchdeck.md b/pitchdeck.md new file mode 100644 index 00000000..8e653e68 --- /dev/null +++ b/pitchdeck.md @@ -0,0 +1,307 @@ +# MyDevTools — Pitch Deck + +> Working draft. Each `## Slide` maps to one deck slide. Speaker notes in blockquotes. +> **Numbers are filled with modeled/illustrative values for a 10K-paying-user Year 1.** Items marked **⚠️ VERIFY** (traction, founder bio, final raise terms) are placeholders only you can confirm — replace with live data before sending. See the Appendix checklist. + +--- + +## Slide 1 — Title + +# MyDevTools.tech +### The all-in-one developer toolkit — fast, private, beautifully crafted. + +- **The only tool that unifies SQL, NoSQL, and Redis in one workspace** — alongside 60+ developer utilities and productivity apps. +- Client-side first. Privacy by design. Open source (GPLv3). +> One-liner: "One client for every database, plus the 60+ tools developers keep in 20 browser tabs — unified, private, and beautiful." + +--- + +## Slide 2 — The Problem + +**Developers waste time and trust on fragmented tooling.** + +- **Scattered.** A typical workflow touches 10–20 single-purpose sites (JSON formatter, JWT decoder, regex tester, cron builder…) — and 2–3 separate apps just for SQL, NoSQL, and Redis. +- **Ad-riddled & slow.** Most free dev-tool sites monetize with intrusive ads and SEO spam. +- **Privacy risk.** Pasting tokens, secrets, certs, or production data into unknown servers is a real security exposure. +- **No continuity.** No saved history, no shared environments, no team state. Every session starts from zero. +- **Context-switching tax.** Constant tab- and app-hopping breaks flow and kills productivity. + +> Pain is daily, universal, and currently "solved" by a junk drawer of bookmarks and three open database GUIs. + +--- + +## Slide 3 — The Solution + +**One workspace. Every tool. Your data stays yours.** + +- **One client for every database** — SQL, NoSQL (MongoDB), and Redis, side by side. No more juggling three separate apps. +- **60+ tools unified** — utilities, an API client, database clients, and productivity apps in one home. +- **Client-side first** — data processed in-browser wherever possible; no server round-trips for sensitive input. +- **Persistent & personal** — saved snippets, notes, environments, vault, bookmarks — synced across sessions. +- **Premium UX** — dark/light mode, fluid animations, fully responsive, command palette, i18n. +- **Open source & self-hostable** — trust through transparency. + +> We replace 20 sketchy tabs and 3 database apps with one tool developers want open all day. + +--- + +## Slide 4 — Product & USPs + +### Unique Selling Points +1. **The only all-in-one database client** — SQL + NoSQL (MongoDB) + Redis in one workspace. Tools force devs into 3 separate apps; we're one. This is the wedge. +2. **Breadth + depth in one app** — 60+ tools; no competitor bundles this range with this polish. +3. **Privacy-first architecture** — client-side processing; secrets never leave the browser. +4. **Open source (GPLv3) + self-host** — developer trust moat. +5. **Persistence & sync** — tools remember your work; productivity apps live alongside utilities. +6. **Beautiful, fast, ad-free** — premium UX vs. the ad-spam incumbents. + +### The Toolbox (60+) + +**Database & Connectivity (the wedge):** SQL Client · NoSQL Explorer (MongoDB) · Redis Commander · S3 Drive · API Client (Postman-like) + +**Developer Utilities:** Base64 · Certificate/PEM Decoder · Color Picker · Contrast Checker · Cron Builder · CSV/Excel/JSON · Diff Checker · Docker Compose Generator · Email Validator · Encryption Playground · Environment Manager · Format Converter (YAML/TOML/JSON/XML) · GraphQL Formatter · Hash Generator · HMAC Generator · HTTP Status Codes · Image Compressor · Image→Base64 · IP Subnet Calculator · JSON Formatter (Monaco) · JSON Schema Generator · .gitignore Generator · JWT Decoder · Lorem Ipsum · Markdown Preview · MIME Lookup · Mock Data Generator · Number Base Converter · Regex Tester · QR Code Generator · Secret/API Key Generator · Snippet Manager · SQL Formatter · SVG Optimizer · Timestamp Converter · TOTP Generator · Unit Converter · URL Encode/Decode · URL Parser · User Agent Parser · UUID Generator · CSS Gradient Builder + +**Productivity Apps:** Bookmarks · Break Room · Notes (Tiptap) · Password Manager (client-side encrypted) · Task Manager + +**Built-in distribution:** Public developer profile at `mydevtools.tech/` — shareable, GitHub stats, social links. + +> Demo flow: connect a Postgres DB + a Redis instance side by side → run queries → save to snippets → share profile. Show the wedge + breadth + persistence in 90 seconds. + +--- + +## Slide 5 — Security & Trust + +**We connect to your databases. Earning trust is the product.** + +- **Why this slide exists:** the unified DB client touches users' SQL/NoSQL/Redis — potentially production credentials. This is both our biggest responsibility and our strongest differentiator vs. ad-funded web tools. +- **Architecture commitments:** + - Credentials encrypted at rest; **no plaintext secret storage**; client-side encryption for the vault. + - Minimal-trust connection handling; transparent, open-source codebase users (and acquirers) can audit. + - Self-host option for teams that won't put DB access in any SaaS. +- **Compliance roadmap:** SOC 2 Type II path post-raise; GDPR-aligned data handling; clear data-residency story for Team/Enterprise. + +> Turn the scariest objection into a moat: "We're the database tool you can actually trust — open source, encrypted, self-hostable." ⚠️ VERIFY exact current architecture before claiming specifics on stage. + +--- + +## Slide 6 — Why Now + +- **Developer population exploding** — ~30M+ developers worldwide today, projected toward ~45M by 2030. +- **PLG is the proven motion** — bottoms-up adoption (Postman, Vercel, Linear) wins developer markets. +- **Privacy backlash** — rising distrust of where pasted data and credentials go; client-side + open source is a selling point. +- **Tool sprawl is worsening** — AI-era workflows mean devs juggle more tools, data, and databases than ever; a unified, trusted hub matters more. +- **Open-source distribution compounds cheaply** — GitHub, Product Hunt, and SEO drive near-zero-CAC growth. + +--- + +## Slide 7 — Market + +- **TAM:** 30M+ professional developers globally. At $96/yr that's a ~$2.9B addressable spend; broaden to "developer productivity SaaS" and the category is tens of billions. +- **SAM:** Developers who work with databases (SQL/NoSQL/Redis) and want unified tooling — ~8–10M reachable via PLG/SEO. +- **SOM (5-yr target):** ~160K paying users — well under 2% of SAM. + +> We don't need to win the market. Capturing <2% of SAM as paid yields a $15M+ ARR business. + +--- + +## Slide 8 — Business Model + +**Freemium → Pro subscription.** + +| Tier | Price | Who | What | +|---|---|---|---| +| **Free** | $0 | Top-of-funnel | Limited tool/usage access; drives discovery & SEO (scope TBD) | +| **Pro** | **$8/mo** ($80/yr annual) | Power users | All 60+ tools, the unified DB client (SQL/NoSQL/Redis), unlimited persistence, sync, vault | +| **Team** | $8/seat/mo (future) | Small teams | Shared environments, collections, RBAC | +| **Enterprise / Self-host** | Custom (future) | Orgs | SSO, on-prem, support, compliance | + +- **Headline unit:** $8/mo = **$96/yr per paid user**. ARR = paid users × $96. +- **Free tier (live)** is the low-CAC growth engine; usage/tool limits nudge upgrade — the unified DB client is the "aha" that converts. +- **Year 1 target: 10,000 paying users**, drawn from a larger free base. +- **Expansion revenue (future):** team seats and enterprise/self-host licenses lift ARPU well above $96. + +> Free tier widens the funnel cheaply via SEO/OSS; Pro monetizes daily-active power users at $96/yr. + +--- + +## Slide 9 — Traction + +> ⚠️ VERIFY — numbers below are illustrative launch-stage figures. Replace with your live metrics; this is the most scrutinized slide. + +- Launched on Product Hunt (featured). +- Open source on GitHub — **10 stars**, **3 contributors**. +- **50 registered users**, **10 weekly active**, **250 paying** to date. +- Month-over-month growth: **25%**. Monthly churn: **5%**. +- 60+ tools shipped; weekly release cadence. + +> If revenue is still early, lead with usage + growth rate + the wedge's pull. Honesty beats inflated metrics with VCs — swap every number here for real data. + +--- + +## Slide 10 — Go-to-Market + +**Product-Led Growth — compounding and low-CAC.** + +1. **SEO moat** — 60+ tool pages ranking for high-intent queries ("redis gui", "jwt decoder", "cron builder"). Organic = near-zero marginal CAC. +2. **The DB wedge** — lead acquisition with the one thing no one else does: SQL + NoSQL + Redis in one place. +3. **Open-source flywheel** — GitHub stars, contributors, self-host advocates → credibility + inbound. +4. **Built-in virality** — public developer profiles shared publicly, branded with MyDevTools. +5. **Community** — Product Hunt, Hacker News, Reddit (r/webdev, r/database), dev Twitter/X, Dev.to. +6. **Conversion** — free usage → $8/mo Pro at the DB-client / persistence wall. Target ~7%+ free→paid. + +> Channel mix is cheap and compounding — the foundation of the unit economics two slides down. + +--- + +## Slide 11 — Competition + +| | MyDevTools | DBeaver / TablePlus | Single-tool sites | Postman / niche SaaS | +|---|---|---|---|---| +| **SQL + NoSQL + Redis in one** | ✅ | ⚠️ SQL-first, partial | ❌ | ❌ | +| Breadth (60+ tools) | ✅ | ❌ | ❌ | ❌ | +| Privacy / client-side | ✅ | ✅ desktop | ❌ ads/trackers | ⚠️ | +| Web + persistence + sync | ✅ | ❌ desktop only | ❌ | ✅ (one domain) | +| Open source / self-host | ✅ | ⚠️ | ❌ | ❌ | +| Productivity apps + profile | ✅ | ❌ | ❌ | ❌ | + +**Moat (defensibility):** features are copyable — the durable moat is the **compound**: the DB-trifecta wedge × 60+-tool breadth × privacy/trust brand × open-source distribution × switching costs from saved snippets/environments/vault × the viral profile network. Each tool added and each saved workspace deepens lock-in. + +--- + +## Slide 12 — 5-Year Financial Forecast + +**Model:** Freemium. Pro = $8/mo = $96/yr. ARR = paying users × $96. Conversion improves with product depth & retention. + +| Year | Registered Users | Free→Paid Conv | Paying Users | ARPU/yr | **ARR** | +|---|---|---|---|---|---| +| **1** | ~143,000 | 7% | 10,000 | $96 | **$0.96M** | +| **2** | ~375,000 | 8% | 30,000 | $96 | **$2.88M** | +| **3** | ~667,000 | 9% | 60,000 | $96 | **$5.76M** | +| **4** | ~1,000,000 | 10% | 100,000 | $96 | **$9.60M** | +| **5** | ~1,450,000 | 11% | 160,000 | $96 | **$15.36M** | + +**Sensitivity (Year 1 paying users):** +- Conservative: 7,000 paid → $672K ARR +- Base: 10,000 paid → $960K ARR +- Aggressive: 15,000 paid → $1.44M ARR + +> Year 1 goal: **10K paying users → ~$1M ARR**, from a ~140K free base at ~7% conversion. ARPU is held flat at $96 (conservative) — Team/Enterprise seats are upside not yet modeled. + +--- + +## Slide 13 — Unit Economics & CAC + +- **Price:** $8/mo · **ARPU:** ~$96/yr · **Avg. paid lifetime:** ~24 mo → **LTV ≈ $192 gross** (~$165 contribution) +- **Gross margin:** target ~85–90% (validate vs. DB-connection infra cost) + +### Can ₹10.5L marketing deliver 10K paying users? + +**The funnel (Year 1):** ~143K registered → **10K paying** (7% conversion). + +| Channel | Share of signups | New signups | Cost | Note | +|---|---|---|---|---| +| **Organic** (SEO on 60+ tool pages, OSS, Product Hunt, viral profiles) | ~75% | ~107K | ~₹0 marginal | The engine — compounds | +| **Paid** (dev newsletters, content/social ads, sponsorships) | ~25% | ~36K | ₹10.5L (~$12.6K) | The accelerant | + +- **Paid cost per signup:** ₹10.5L ÷ 36K ≈ **₹29 (~$0.35)** +- **Paid-channel CAC per paying user:** ₹29 ÷ 7% ≈ **₹417 (~$5)** +- **Blended CAC** (all marketing ÷ all 10K paid): ₹10.5L ÷ 10K ≈ **₹105 (~$1.27)** + +### Why this is safe +- **LTV:CAC** — blended ~150x; paid-channel ~38x. Even at a pessimistic **$30 cold-paid CAC, still ~6x** and payback < 4 months. +- **Break-even headroom:** could pay up to **~$64/paying user** (3:1 threshold) and stay healthy — we budget ~$5. Huge margin of safety. + +> The math only works because organic carries ~75% of the funnel. ⚠️ The real risk isn't CAC — it's hitting **143K registered** and **7% conversion**. Prove both with early cohort data; that's what an angel should underwrite. + +--- + +## Slide 14 — Milestones & Roadmap + +**₹15L funds ~12 months of marketing + infra to reach 10K paying users (~$1M ARR).** + +| Quarter | Product | Growth / GTM | Target | +|---|---|---|---| +| **Q1** | Free tier limits live; DB client hardening | SEO content sprint, Product Hunt relaunch | 25K registered | +| **Q2** | Team tier (shared envs/collections) | Community + OSS push | First 3K paying | +| **Q3** | Security: SOC 2 path kickoff; vault hardening | Paid-channel CAC tests | 6K paying, churn <4% | +| **Q4** | Enterprise/self-host packaging | Partnerships / integrations | **10K paying · ~$1M ARR** | +| **Y2** | SSO, RBAC, audit logs | Outbound to teams | 30K paying · ~$2.9M ARR | + +> Tie spend to gates. Show you know exactly what each rupee unlocks. ⚠️ VERIFY quarterly targets against your real ramp. + +--- + +## Slide 15 — Team + +- **Akhil** — Founder / Lead Engineer. Full-stack developer who designed, built, and shipped 60+ production tools solo at a weekly release cadence. ⚠️ VERIFY — add prior role/company, notable wins, and why you'll win this market. +- Open-source contributor community — 18 contributors and growing. +- **Lean & capital-efficient:** product fully built solo; this round funds marketing + infra, not headcount. Part-time/contract help for content & design as needed. +- **Single-founder risk (addressed head-on):** actively seeking a technical co-founder and onboarding 2 advisors (a devtools GTM operator + a security/compliance lead) to de-risk execution. FTE hiring deferred to the next round once ARR justifies it. + +> Investors back people. Proof of execution: 60+ tools shipped solo at weekly cadence. Pre-empt the solo-founder objection — name it before they do. + +--- + +## Slide 16 — The Ask + +- **Raising:** ₹10–20 lakh (~$12K–24K) angel / pre-seed round. ⚠️ VERIFY final amount & terms. +- **Use of funds (₹15 lakh midpoint):** + - **70% marketing (~₹10.5 lakh)** — SEO content, paid-channel tests, community, Product Hunt, partnerships → drive the free funnel to 10K paying users + - **30% infrastructure (~₹4.5 lakh)** — hosting, database-connection infra, reliability, security hardening +- **What it buys:** runway to scale the free funnel toward **10K paying users (~$1M ARR)** in Year 1, founder-led and capital-efficient. +- **Why raise vs. self-fund:** the product is built and shipping — we want to validate the growth engine on investor capital first, de-risking before we commit our own. Founder is prepared to invest personally in later stages once the funnel is proven. + +> Lean raise, focused spend. The product is built (60+ tools shipped solo) — this money buys distribution, not R&D. + +--- + +## Slide 17 — Vision + +**The default workspace developers keep open all day.** + +Start as the unified database client + utility belt. Become the trusted, private, collaborative hub for individual developers and teams — data tools, utilities, productivity, and identity in one open platform. + +> Closing line: "One tool for every database, and every utility. The workspace developers keep open all day." + +--- + +## Slide 18 — Exit & Investor Upside + +**Your early check buys equity now. Returns come via the next round, or a strategic acquisition.** + +- **Nearest upside — the next round:** ₹10–20L today funds the growth that unlocks a priced seed/Series-A at a higher valuation. Early angel equity marks up as ARR climbs ($0.96M → $2.88M Y1→Y2). +- **Why we're acquirable later:** a sticky, daily-active developer base + a unified SQL/NoSQL/Redis client is a natural bolt-on for devtool, cloud, and database platforms (GitLab, Atlassian, DigitalOcean, MongoDB, Redis…). +- **Comparable M&A:** developer SaaS exits at **8–15x ARR** strategically — meaningful upside on an early, small check as the company scales. +- **Founder committed:** building through scale, no early bail-out. Any founder liquidity happens alongside investors, never ahead. + +> Angel-stage framing: lead with the **path to the next round** (concrete, 12–24 months out), not a Year-5 acquisition fantasy. The acquisition story is the ceiling, not the pitch. ⚠️ Set entry valuation with your angel. + +--- + +## Appendix — Assumptions & Pre-Pitch Checklist + +**Key assumptions (validate before pitching):** +- Freemium: live limited free tier + $8/mo Pro; annual discount to $80/yr. Free-tier scope (tool/usage limits) TBD. +- Year 1 target = 10,000 paying users, from ~140K free base at ~7% conversion. +- Paid growth 10K → 160K over 5 years; conversion ramp 7%→11% (assumed; validate with cohort data). **Note: 7% free→paid is at the optimistic end (typical SaaS 2–5%) — be ready to defend it with the wedge's stickiness.** +- Avg paid lifetime 24 months; CAC $15–30 via PLG/SEO/OSS. +- Developer TAM ~30M, growing ~45M by 2030 (cite source: e.g. SlashData/Evans Data). + +**⚠️ VERIFY — filled with illustrative values; replace with real before sending (only you have these):** +- [ ] Real traction numbers (Slide 9) — currently placeholders: 400 stars, 18 contributors, 12K registered, 3.5K WAU, 250 paying, 25% MoM, 5% churn. +- [ ] Founder bio + prior wins (Slide 15) — generic placeholder text in place. +- [ ] Raise terms (Slide 16) — modeled as ₹10–20 lakh angel round (70% marketing / 30% infra); set exact amount, valuation & instrument. +- [ ] Milestone targets (Slide 14) — modeled quarterly ramp; check against your real plan. +- [ ] Security architecture specifics (Slide 5) — confirm what's actually implemented today. +- [ ] Exit / next-round upside (Slide 18) — set entry valuation with your angel; keep return framing consistent with the ₹10–20L raise. + +**Defend-with-data (investors will probe):** +- [ ] **Churn / retention** — the 24-mo lifetime and LTV rest on it. +- [ ] **CAC proof** — show real cost per paid user, not just "SEO is cheap." +- [ ] **Margin** — if any DB proxying is server-side, infra scales with usage; confirm 85–90% holds. +- [ ] **Conversion rate** — prove the 7% with early cohort data. + +**Polish for the designed deck:** +- [ ] Visual charts: ARR curve, funnel, market-size pyramid. +- [ ] Verify market-size citation with a real source. +- [ ] One-line demo GIF/video of SQL + Redis side by side. diff --git a/tauri-plan.md b/tauri-plan.md new file mode 100644 index 00000000..e63df314 --- /dev/null +++ b/tauri-plan.md @@ -0,0 +1,237 @@ +--- +name: Tauri Desktop App Plan +overview: Create a Tauri v2 desktop application (Mac/Windows/Linux) that runs offline-first with local encrypted SQLite storage, shares the existing Next.js frontend via webview, and offers optional sync to either the existing backend API or cloud storage providers. +todos: + - id: scaffold-tauri + content: Initialize Tauri v2 project in apps/desktop with Cargo.toml, tauri.conf.json, and basic window loading Next.js dev server + status: pending + - id: setup-sqlcipher + content: Set up SQLCipher database layer in Rust with migrations matching existing MongoDB collections schema + status: pending + - id: platform-adapter + content: Create platform detection + API adapter layer in frontend (isDesktop/isWeb branching) + status: pending + - id: rust-crypto + content: Implement master password key derivation and encryption in Rust (matching existing Web Crypto PBKDF2+AES-GCM scheme) + status: pending + - id: passwords-module + content: Implement password vault Tauri commands (CRUD) as proof of concept with full offline support + status: pending + - id: all-modules + content: "Implement remaining modules: notes, bookmarks, tasks, snippets, env manager, API client, S3 drive, SQL/NoSQL connections" + status: pending + - id: auth-desktop + content: Implement desktop-mode auth (local master password, skip Firebase, optional remote account link) + status: pending + - id: sync-engine + content: "Build sync engine: backend API sync + cloud storage export/import with per-app toggle" + status: pending + - id: shared-types + content: Extract shared TypeScript types into packages/shared-types for web and desktop to share + status: pending + - id: static-export + content: Configure Next.js static export build target for Tauri embedding + status: pending + - id: distribution + content: Set up multi-platform builds (macOS dmg, Windows msi, Linux AppImage) with GitHub Actions CI + status: pending +isProject: false +--- + +# Tauri Desktop App - Offline-First with Optional Sync + +## Current Architecture Summary + +The project is a pnpm monorepo with: +- **Frontend**: Next.js 16 app ([apps/web](apps/web)) with Zustand state, Radix UI, Tailwind, Firebase Auth +- **Backend**: FastAPI + MongoDB ([apps/backend](apps/backend)) storing user data in collections (passwords, notes, bookmarks, tasks, snippets, env vars, API client data, S3 connections, SQL connections, NoSQL connections) +- **Auth**: Firebase Auth on frontend, JWT session cookies for backend API +- **Encryption**: Client-side AES-256-GCM via Web Crypto API (PBKDF2 key derivation from master password). Encrypted data stored server-side; server never sees plaintext +- **Key Storage**: CryptoKey persisted in IndexedDB (browser) + +## Architecture for Tauri Desktop App + +```mermaid +graph TB + subgraph TauriApp ["Tauri Desktop App"] + WebView["Next.js Frontend (WebView)"] + RustCore["Rust Core Layer"] + SQLite["SQLite (encrypted)"] + FileStore["Local File Store"] + end + + subgraph SyncTargets ["Optional Sync Targets"] + BackendAPI["Existing Backend API"] + CloudStorage["Cloud Storage (S3/GDrive)"] + end + + WebView -->|"invoke()"| RustCore + RustCore --> SQLite + RustCore --> FileStore + RustCore -->|"optional"| BackendAPI + RustCore -->|"optional"| CloudStorage +``` + +## Key Design Decisions + +### 1. Offline-First Data Layer (Rust + SQLite) + +Replace backend API calls with a local Rust data layer using **SQLCipher** (encrypted SQLite): + +- **Confidential data** (passwords, env vars, S3 credentials, SQL/NoSQL connection strings): Encrypted locally with AES-256-GCM using the same Web Crypto derivation scheme, stored in SQLCipher DB +- **Non-confidential data** (bookmarks, tasks, notes, code snippets, API client history): Stored in plain SQLite tables locally +- **S3 Drive file cache**: Stored in app's local file system directory + +The Rust layer exposes Tauri commands that mirror the existing backend API interface, so the frontend's API layer can swap between `fetch()` (web) and `invoke()` (Tauri) with minimal changes. + +### 2. Frontend Adaptation Strategy + +Create a shared **platform adapter** layer: + +```typescript +// apps/web/src/lib/platform.ts +export const platform = { + isDesktop: () => Boolean(window.__TAURI__), + isWeb: () => !window.__TAURI__, +} + +// apps/web/src/lib/api-adapter.ts +export async function apiCall(endpoint, options) { + if (platform.isDesktop()) { + return invoke('api_call', { endpoint, ...options }) + } + return backendFetch(endpoint, options) +} +``` + +This keeps the existing web app working as-is while the Tauri build routes calls to the local Rust backend. + +### 3. Auth in Desktop Mode + +- **No Firebase dependency in desktop mode** - user sets a local master password on first launch +- The master password derives the SQLCipher encryption key (same PBKDF2 scheme already used) +- Optional: link a remote account for sync (Firebase auth or username/password to the existing backend) + +### 4. Sync Architecture + +Two sync modes, per-app configurable: + +| Data Type | Sync to Backend | Sync to Cloud | +|-----------|----------------|---------------| +| Passwords | Encrypted blobs synced | Encrypted export file | +| Notes | Full sync | JSON export | +| Bookmarks | Full sync | JSON export | +| Tasks | Full sync | JSON export | +| Env vars | Encrypted blobs synced | Encrypted export file | +| S3/SQL/NoSQL connections | Encrypted blobs synced | Encrypted export file | + +Sync protocol: +- **Last-write-wins** with `updatedAt` timestamps (already present on all records) +- Each record gets a `syncStatus` field: `local_only | synced | pending_push | conflict` +- Background sync worker in Rust polls/pushes on configurable interval +- Conflict resolution: show user a diff and let them choose + +### 5. Monorepo Structure Changes + +``` +mydevtools-monorepo/ +├── apps/ +│ ├── web/ # Existing Next.js web app (unchanged) +│ ├── backend/ # Existing FastAPI backend (unchanged) +│ └── desktop/ # NEW: Tauri app shell +│ ├── src-tauri/ +│ │ ├── Cargo.toml +│ │ ├── tauri.conf.json +│ │ ├── src/ +│ │ │ ├── main.rs +│ │ │ ├── commands/ # Tauri invoke handlers +│ │ │ │ ├── mod.rs +│ │ │ │ ├── passwords.rs +│ │ │ │ ├── notes.rs +│ │ │ │ ├── bookmarks.rs +│ │ │ │ ├── tasks.rs +│ │ │ │ ├── env_manager.rs +│ │ │ │ ├── snippets.rs +│ │ │ │ ├── api_client.rs +│ │ │ │ ├── s3_drive.rs +│ │ │ │ ├── sql_client.rs +│ │ │ │ └── nosql.rs +│ │ │ ├── db/ # SQLite/SQLCipher layer +│ │ │ │ ├── mod.rs +│ │ │ │ ├── migrations.rs +│ │ │ │ └── schema.rs +│ │ │ ├── sync/ # Optional sync engine +│ │ │ │ ├── mod.rs +│ │ │ │ ├── backend_sync.rs +│ │ │ │ └── cloud_sync.rs +│ │ │ ├── crypto.rs # Encryption helpers +│ │ │ └── config.rs # App configuration +│ │ └── icons/ +│ ├── src/ # Frontend entry (loads Next.js build) +│ │ └── index.html +│ └── package.json +├── packages/ +│ └── shared-types/ # NEW: Shared TypeScript types +│ ├── package.json +│ └── src/ +│ └── index.ts +└── pnpm-workspace.yaml # Updated to include packages/* +``` + +### 6. Tauri Configuration + +- **Tauri v2** (latest stable, Rust-based, better security model) +- Frontend: **Static export of Next.js** (`next export` or `output: 'export'`) embedded in Tauri +- Dev mode: Tauri points to `http://localhost:3000` (Next.js dev server) +- Permissions: filesystem (app data dir), network (for sync), shell (none) + +### 7. Data Migration Path + +For users of the web app who want to move to desktop: +1. Export all data from web app (encrypted JSON bundle) +2. Import into desktop app (decrypted with master password, re-encrypted locally) +3. Or: enable sync and let background sync pull all data + +## Implementation Phases + +### Phase 1: Tauri Scaffold + Local Storage (Core) +- Initialize Tauri v2 project in `apps/desktop` +- Set up SQLCipher database with schema matching all collections +- Implement master password / key derivation in Rust +- Create platform adapter in frontend (`isDesktop` detection) +- Wire up the first module (passwords) end-to-end locally + +### Phase 2: All Modules Offline +- Implement all Tauri commands for: notes, bookmarks, tasks, snippets, env manager, API client, S3 drive connections, SQL/NoSQL connections +- Frontend adapter for all API calls +- Local file storage for S3 drive cache + +### Phase 3: Sync Engine +- Backend sync: authenticate with existing backend, push/pull encrypted records +- Cloud sync: export/import encrypted bundles to S3 or local file +- Per-app sync toggle in settings UI +- Conflict resolution UI + +### Phase 4: Polish + Distribution +- App icons and branding +- Auto-updater (Tauri built-in) +- macOS notarization, Windows code signing +- Linux AppImage/deb/rpm +- CI/CD for multi-platform builds (GitHub Actions) + +## Key Files to Modify + +- [pnpm-workspace.yaml](pnpm-workspace.yaml) - add `packages/*` and `apps/desktop` +- [apps/web/src/lib/backend-auth.ts](apps/web/src/lib/backend-auth.ts) - add desktop adapter branching +- [apps/web/next.config.ts](apps/web/next.config.ts) - add static export config for Tauri builds +- [apps/web/src/utils/useAuth.tsx](apps/web/src/utils/useAuth.tsx) - bypass Firebase in desktop mode +- [apps/web/src/lib/key-storage.ts](apps/web/src/lib/key-storage.ts) - use Tauri secure storage in desktop mode + +## Technology Choices + +- **Tauri v2**: Latest, better permission model, smaller binary than Electron +- **SQLCipher** (via `rusqlite` with `bundled-sqlcipher` feature): Encrypted SQLite at rest +- **serde/serde_json**: Rust serialization matching existing JSON schemas +- **reqwest**: HTTP client for sync with backend API +- **tauri-plugin-store**: Secure key-value store for non-DB settings +- **tauri-plugin-fs**: File system access for S3 drive cache diff --git a/test-script.js b/test-script.js new file mode 100644 index 00000000..f097a5b8 --- /dev/null +++ b/test-script.js @@ -0,0 +1,2 @@ +const fs = require('fs'); +// Let's just grep the sidebar file for "Tasks" to see if it's actually there