From bf73f5b6592ee071bb66746e636480eb76723d4b Mon Sep 17 00:00:00 2001 From: jazzson51569 Date: Sat, 6 Jun 2026 18:20:08 +0800 Subject: [PATCH 1/3] feat: implement hierarchical prompt structure with drag-drop and keyboard indentation --- apps/desktop/src/main/ipc/prompt.ipc.ts | 6 + apps/desktop/src/preload/api/prompt.ts | 2 + .../components/layout/MainContent.tsx | 24 ++ .../components/prompt/PromptListView.tsx | 235 ++++++++++++++++-- .../desktop/src/renderer/services/database.ts | 33 +++ .../src/renderer/stores/prompt.store.ts | 7 + packages/db/src/prompt.ts | 102 +++++++- packages/db/src/schema.ts | 7 +- packages/shared/constants/ipc-channels.ts | 1 + packages/shared/types/prompt.ts | 4 + 10 files changed, 391 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/main/ipc/prompt.ipc.ts b/apps/desktop/src/main/ipc/prompt.ipc.ts index 99193119..faabc7f0 100644 --- a/apps/desktop/src/main/ipc/prompt.ipc.ts +++ b/apps/desktop/src/main/ipc/prompt.ipc.ts @@ -249,4 +249,10 @@ export function registerPromptIPC(db: PromptDB, folderDb: FolderDB, rawDb: Datab db.insertVersionDirect(version); return true; }); + + ipcMain.handle(IPC_CHANNELS.PROMPT_MOVE, async (_, promptId: string, newParentId: string | null, newOrder: number) => { + db.movePrompt(promptId, newParentId, newOrder); + syncWorkspace(); + return true; + }); } diff --git a/apps/desktop/src/preload/api/prompt.ts b/apps/desktop/src/preload/api/prompt.ts index 2977db4c..e6cd0cd0 100644 --- a/apps/desktop/src/preload/api/prompt.ts +++ b/apps/desktop/src/preload/api/prompt.ts @@ -40,4 +40,6 @@ export const promptApi = { prompts: Prompt[]; versions: PromptVersion[]; }) => ipcRenderer.invoke(IPC_CHANNELS.PROMPT_MIGRATE_IDB_BATCH, payload), + move: (promptId: string, newParentId: string | null, newOrder: number) => + ipcRenderer.invoke(IPC_CHANNELS.PROMPT_MOVE, promptId, newParentId, newOrder), }; diff --git a/apps/desktop/src/renderer/components/layout/MainContent.tsx b/apps/desktop/src/renderer/components/layout/MainContent.tsx index b4d791ff..1919b451 100644 --- a/apps/desktop/src/renderer/components/layout/MainContent.tsx +++ b/apps/desktop/src/renderer/components/layout/MainContent.tsx @@ -24,6 +24,7 @@ const PromptQuickRewriteDialog = lazy(() => import('../prompt/PromptQuickRewrite const PromptTableView = lazy(() => import('../prompt/PromptTableView').then(m => ({ default: m.PromptTableView }))); const PromptGalleryView = lazy(() => import('../prompt/PromptGalleryView').then(m => ({ default: m.PromptGalleryView }))); const PromptKanbanView = lazy(() => import('../prompt/PromptKanbanView').then(m => ({ default: m.PromptKanbanView }))); +const PromptListView = lazy(() => import('../prompt/PromptListView').then(m => ({ default: m.PromptListView }))); const AiTestModal = lazy(() => import('../prompt/AiTestModal').then(m => ({ default: m.AiTestModal }))); const PromptDetailModal = lazy(() => import('../prompt/PromptDetailModal').then(m => ({ default: m.PromptDetailModal }))); const VariableInputModal = lazy(() => import('../prompt/VariableInputModal').then(m => ({ default: m.VariableInputModal }))); @@ -367,6 +368,7 @@ function PromptSkillMainContent() { const sortOrder = usePromptStore((state) => state.sortOrder); const viewMode = usePromptStore((state) => state.viewMode); const incrementUsageCount = usePromptStore((state) => state.incrementUsageCount); + const movePrompt = usePromptStore((state) => state.movePrompt); // Resizable prompt-list pane width (#119) const promptListPaneWidth = useUIStore((state) => state.promptListPaneWidth); const setPromptListPaneWidth = useUIStore( @@ -2014,6 +2016,28 @@ function PromptSkillMainContent() { )} + {/* List view mode: hierarchical list with drag-and-drop */} + {/* 列表视图模式:分层列表支持拖拽 */} +
+ + {viewMode === 'list' && ( + + selectPrompt(id)} + onToggleFavorite={toggleFavorite} + onCopy={handleCopyPrompt} + onContextMenu={handleContextMenu} + onMovePrompt={movePrompt} + /> + + )} +
+ {/* Card view mode: two-column layout */} {/* 卡片视图模式:左右分栏 */}
void; onToggleFavorite: (id: string) => void; onCopy: (prompt: Prompt) => void; onContextMenu: (e: React.MouseEvent, prompt: Prompt) => void; + onMovePrompt: (promptId: string, newParentId: string | null, newOrder: number) => void; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; } export function PromptListView({ prompts, selectedId, + selectedIds, onSelect, onToggleFavorite, onCopy, onContextMenu, + onMovePrompt, }: PromptListViewProps) { const { t } = useTranslation(); + const [draggingId, setDraggingId] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); + const [dropPosition, setDropPosition] = useState<'before' | 'after' | 'inside' | null>(null); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!selectedId || draggingId) return; + + const selectedPrompt = prompts.find(p => p.id === selectedId); + if (!selectedPrompt) return; + + if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + + if (e.shiftKey) { + // Shift+Tab: outdent (move to parent level) + if (selectedPrompt.parentId) { + const siblings = prompts.filter(p => p.parentId === selectedPrompt.parentId); + const parentPrompt = prompts.find(p => p.id === selectedPrompt.parentId); + const newOrder = parentPrompt ? prompts.filter(p => p.parentId === parentPrompt?.parentId).length : prompts.filter(p => p.parentId === null).length; + onMovePrompt(selectedId, parentPrompt?.parentId || null, newOrder); + } + } else { + // Tab: indent (move to previous sibling's child) + const siblings = prompts.filter(p => p.parentId === selectedPrompt.parentId).sort((a, b) => (a.order || 0) - (b.order || 0)); + const currentIndex = siblings.findIndex(s => s.id === selectedId); + + if (currentIndex > 0) { + const prevSibling = siblings[currentIndex - 1]; + const childCount = prompts.filter(p => p.parentId === prevSibling.id).length; + onMovePrompt(selectedId, prevSibling.id, childCount); + } + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedId, prompts, onMovePrompt, draggingId]); - // Format date - // 格式化日期 const formatDate = (dateStr: string) => { const date = new Date(dateStr); const now = new Date(); @@ -40,29 +85,169 @@ export function PromptListView({ } }; - return ( -
- {prompts.map((prompt) => ( + const toggleExpand = useCallback((promptId: string) => { + setExpandedIds(prev => { + const newSet = new Set(prev); + if (newSet.has(promptId)) { + newSet.delete(promptId); + } else { + newSet.add(promptId); + } + return newSet; + }); + }, []); + + const hasChildren = useCallback((promptId: string) => { + return prompts.some(p => p.parentId === promptId); + }, [prompts]); + + const getChildren = useCallback((parentId: string | null) => { + return prompts + .filter(p => p.parentId === parentId) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + }, [prompts]); + + const handleDragStart = useCallback((e: React.DragEvent, promptId: string) => { + setDraggingId(promptId); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', promptId); + }, []); + + const handleDragEnd = useCallback(() => { + setDraggingId(null); + setDropTargetId(null); + setDropPosition(null); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }, []); + + const handleDragEnter = useCallback((e: React.DragEvent, promptId: string) => { + e.preventDefault(); + if (draggingId !== promptId) { + setDropTargetId(promptId); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const y = e.clientY - rect.top; + const height = rect.height; + + if (y < height / 3) { + setDropPosition('before'); + } else if (y > height * 2 / 3) { + setDropPosition('after'); + } else { + setDropPosition('inside'); + } + } + }, [draggingId]); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDropTargetId(null); + setDropPosition(null); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetPromptId: string) => { + e.preventDefault(); + if (draggingId && draggingId !== targetPromptId) { + const targetPrompt = prompts.find(p => p.id === targetPromptId); + const draggingPrompt = prompts.find(p => p.id === draggingId); + + if (targetPrompt && draggingPrompt) { + if (dropPosition === 'inside') { + const childCount = prompts.filter(p => p.parentId === targetPromptId).length; + onMovePrompt(draggingId, targetPromptId, childCount); + } else { + const newParentId = targetPrompt.parentId; + let targetOrder = targetPrompt.order || 0; + + if (dropPosition === 'after') { + targetOrder += 1; + } + + if (draggingPrompt.parentId === newParentId && draggingPrompt.order && draggingPrompt.order < targetOrder) { + targetOrder -= 1; + } + + onMovePrompt(draggingId, newParentId, targetOrder); + } + } + } + setDraggingId(null); + setDropTargetId(null); + setDropPosition(null); + }, [draggingId, dropPosition, prompts, onMovePrompt]); + + const isSelected = useCallback((promptId: string) => { + return selectedId === promptId || selectedIds.includes(promptId); + }, [selectedId, selectedIds]); + + const isDragging = useCallback((promptId: string) => { + return draggingId === promptId; + }, [draggingId]); + + const isDropTarget = useCallback((promptId: string) => { + return dropTargetId === promptId; + }, [dropTargetId]); + + const renderTreeNode = useCallback((prompt: Prompt, depth: number) => { + const hasKids = hasChildren(prompt.id); + const isExpanded = expandedIds.has(prompt.id); + + return ( +
handleDragStart(e, prompt.id)} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + onDragEnter={(e) => handleDragEnter(e, prompt.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, prompt.id)} onClick={() => onSelect(prompt.id)} onContextMenu={(e) => onContextMenu(e, prompt)} className={` flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer - transition-colors duration-quick - ${selectedId === prompt.id + transition-colors duration-quick relative + ${isSelected(prompt.id) ? 'bg-primary/10 border-l-2 border-l-primary' - : 'hover:bg-accent/50' + : isDropTarget(prompt.id) && dropPosition === 'inside' + ? 'bg-primary/20 border-l-2 border-l-primary' + : 'hover:bg-accent/50' } + ${isDragging(prompt.id) ? 'opacity-50' : ''} + ${isDropTarget(prompt.id) && dropPosition === 'inside' ? 'ring-2 ring-primary/50 ring-inset' : ''} + ${isDropTarget(prompt.id) && dropPosition === 'before' ? 'border-t-2 border-t-primary' : ''} + ${isDropTarget(prompt.id) && dropPosition === 'after' ? 'border-b-2 border-b-primary' : ''} `} + style={{ paddingLeft: `${depth * 16 + 12}px` }} > - {/* Title and description */} - {/* 标题和描述 */} +
+ {hasKids && ( + + )} + {!hasKids && } + +
+

@@ -85,24 +270,18 @@ export function PromptListView({ )}

- {/* Usage count */} - {/* 使用次数 */}
{prompt.usageCount || 0}
- {/* Update time */} - {/* 更新时间 */}
{formatDate(prompt.updatedAt)}
- {/* Action buttons */} - {/* 操作按钮 */}
- ))} + + {hasKids && isExpanded && ( +
+ {getChildren(prompt.id).map((child) => renderTreeNode(child, depth + 1))} +
+ )} +
+ ); + }, [hasChildren, expandedIds, toggleExpand, getChildren, isSelected, isDragging, isDropTarget, dropPosition, onSelect, onContextMenu, handleDragStart, handleDragEnd, handleDragOver, handleDragEnter, handleDragLeave, handleDrop, onCopy, onToggleFavorite, t]); + + const rootNodes = getChildren(null); + + return ( +
+ {rootNodes.map((node) => renderTreeNode(node, 0))}
); -} +} \ No newline at end of file diff --git a/apps/desktop/src/renderer/services/database.ts b/apps/desktop/src/renderer/services/database.ts index 36d35d39..767d0507 100644 --- a/apps/desktop/src/renderer/services/database.ts +++ b/apps/desktop/src/renderer/services/database.ts @@ -356,6 +356,39 @@ export async function movePrompts( } } +export async function movePrompt( + promptId: string, + newParentId: string | null, + newOrder: number, +): Promise { + if (window.api?.prompt?.move) { + await window.api.prompt.move(promptId, newParentId, newOrder); + return; + } + + const database = await getDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(STORES.PROMPTS, "readwrite"); + const store = transaction.objectStore(STORES.PROMPTS); + const getRequest = store.get(promptId); + + getRequest.onsuccess = () => { + const prompt = getRequest.result; + if (prompt) { + prompt.parentId = newParentId; + prompt.order = newOrder; + prompt.updatedAt = new Date().toISOString(); + const putRequest = store.put(prompt); + putRequest.onsuccess = () => resolve(); + putRequest.onerror = () => reject(putRequest.error); + } else { + resolve(); + } + }; + getRequest.onerror = () => reject(getRequest.error); + }); +} + // ==================== Version 操作 ==================== // ==================== Version Operations ==================== diff --git a/apps/desktop/src/renderer/stores/prompt.store.ts b/apps/desktop/src/renderer/stores/prompt.store.ts index 37a36624..19e1c30a 100644 --- a/apps/desktop/src/renderer/stores/prompt.store.ts +++ b/apps/desktop/src/renderer/stores/prompt.store.ts @@ -60,6 +60,7 @@ interface PromptState { setGalleryImageSize: (size: GalleryImageSize) => void; setKanbanColumns: (columns: KanbanColumns) => void; incrementUsageCount: (id: string) => Promise; + movePrompt: (promptId: string, newParentId: string | null, newOrder: number) => Promise; } export const usePromptStore = create()( @@ -228,6 +229,12 @@ export const usePromptStore = create()( })); } }, + + movePrompt: async (promptId, newParentId, newOrder) => { + await db.movePrompt(promptId, newParentId, newOrder); + await get().fetchPrompts(); + scheduleAllSaveSync("prompt:move"); + }, }), { name: "prompt-store", diff --git a/packages/db/src/prompt.ts b/packages/db/src/prompt.ts index 5b253d53..b587331f 100644 --- a/packages/db/src/prompt.ts +++ b/packages/db/src/prompt.ts @@ -24,6 +24,8 @@ interface PromptRow { variables: string | null; tags: string | null; folder_id: string | null; + parent_id: string | null; + sort_order: number; images: string | null; videos: string | null; is_favorite: number; @@ -65,9 +67,9 @@ export class PromptDB { const stmt = this.db.prepare(` INSERT INTO prompts ( id, visibility, title, description, prompt_type, system_prompt, system_prompt_en, user_prompt, - user_prompt_en, variables, tags, folder_id, images, videos, source, notes, + user_prompt_en, variables, tags, folder_id, parent_id, sort_order, images, videos, source, notes, last_ai_response, is_favorite, current_version, usage_count, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( @@ -83,16 +85,18 @@ export class PromptDB { JSON.stringify(data.variables || []), JSON.stringify(data.tags || []), data.folderId || null, + null, + 0, JSON.stringify(data.images || []), JSON.stringify(data.videos || []), data.source || null, data.notes || null, null, 0, - 0, - 0, - now, - now, + 0, + 0, + now, + now, ); // Create initial version @@ -183,6 +187,14 @@ export class PromptDB { updates.push("folder_id = ?"); values.push(data.folderId); } + if (data.parentId !== undefined) { + updates.push("parent_id = ?"); + values.push(data.parentId); + } + if (data.order !== undefined) { + updates.push("sort_order = ?"); + values.push(data.order); + } if (data.images !== undefined) { updates.push("images = ?"); values.push(JSON.stringify(data.images)); @@ -260,6 +272,8 @@ export class PromptDB { ...(data.variables !== undefined && { variables: data.variables }), ...(data.tags !== undefined && { tags: data.tags }), ...(data.folderId !== undefined && { folderId: data.folderId }), + ...(data.parentId !== undefined && { parentId: data.parentId }), + ...(data.order !== undefined && { order: data.order }), ...(data.images !== undefined && { images: data.images }), ...(data.videos !== undefined && { videos: data.videos }), ...(data.isFavorite !== undefined && { isFavorite: data.isFavorite }), @@ -506,11 +520,11 @@ export class PromptDB { .prepare( `INSERT OR REPLACE INTO prompts ( id, visibility, title, description, prompt_type, system_prompt, system_prompt_en, user_prompt, - user_prompt_en, variables, tags, folder_id, images, videos, is_favorite, is_pinned, + user_prompt_en, variables, tags, folder_id, parent_id, sort_order, images, videos, is_favorite, is_pinned, current_version, usage_count, source, notes, last_ai_response, created_at, updated_at ) VALUES ( @id, @visibility, @title, @description, @prompt_type, @system_prompt, @system_prompt_en, @user_prompt, - @user_prompt_en, @variables, @tags, @folder_id, @images, @videos, @is_favorite, @is_pinned, + @user_prompt_en, @variables, @tags, @folder_id, @parent_id, @sort_order, @images, @videos, @is_favorite, @is_pinned, @current_version, @usage_count, @source, @notes, @last_ai_response, @created_at, @updated_at )`, ) @@ -527,6 +541,8 @@ export class PromptDB { "@variables": JSON.stringify(prompt.variables ?? []), "@tags": JSON.stringify(prompt.tags ?? []), "@folder_id": prompt.folderId ?? null, + "@parent_id": prompt.parentId ?? null, + "@sort_order": prompt.order ?? 0, "@images": JSON.stringify(prompt.images ?? []), "@videos": JSON.stringify(prompt.videos ?? []), "@is_favorite": prompt.isFavorite ? 1 : 0, @@ -669,6 +685,74 @@ export class PromptDB { txn(); } + /** + * Move prompt to a new parent or reorder within the same parent + * 移动提示词到新的父节点或在同级中重新排序 + */ + movePrompt(promptId: string, newParentId: string | null, newOrder: number): void { + const txn = this.db.transaction(() => { + // Get current parent and order + const current = this.db + .prepare("SELECT parent_id, sort_order FROM prompts WHERE id = ?") + .get(promptId) as { parent_id: string | null; sort_order: number } | undefined; + + if (!current) return; + + const oldParentId = current.parent_id; + const oldOrder = current.sort_order; + + // Update the moved prompt + this.db + .prepare("UPDATE prompts SET parent_id = ?, sort_order = ?, updated_at = ? WHERE id = ?") + .run(newParentId, newOrder, Date.now(), promptId); + + // If parent changed, adjust orders in old parent + if (oldParentId !== newParentId && oldParentId !== null) { + this.db + .prepare("UPDATE prompts SET sort_order = sort_order - 1 WHERE parent_id = ? AND sort_order > ?") + .run(oldParentId, oldOrder); + } + + // Adjust orders in new parent to make room + if (newParentId !== null) { + this.db + .prepare("UPDATE prompts SET sort_order = sort_order + 1 WHERE parent_id = ? AND sort_order >= ? AND id != ?") + .run(newParentId, newOrder, promptId); + } else { + // No parent (root level) + this.db + .prepare("UPDATE prompts SET sort_order = sort_order + 1 WHERE parent_id IS NULL AND sort_order >= ? AND id != ?") + .run(newOrder, promptId); + } + }); + + txn(); + } + + /** + * Get children of a prompt + * 获取提示词的子节点 + */ + getChildren(parentId: string | null): Prompt[] { + const stmt = this.db.prepare( + parentId === null + ? "SELECT * FROM prompts WHERE parent_id IS NULL ORDER BY sort_order" + : "SELECT * FROM prompts WHERE parent_id = ? ORDER BY sort_order" + ); + const rows = stmt.all(parentId === null ? [] : [parentId]) as PromptRow[]; + return rows.map((row) => this.rowToPrompt(row)); + } + + /** + * Get all prompts with hierarchical structure + * 获取所有提示词(包含层级结构) + */ + getAllWithHierarchy(): Prompt[] { + const stmt = this.db.prepare("SELECT * FROM prompts ORDER BY parent_id NULLS FIRST, sort_order"); + const rows = stmt.all() as PromptRow[]; + return rows.map((row) => this.rowToPrompt(row)); + } + /** * Convert database row to Prompt object @@ -689,6 +773,8 @@ export class PromptDB { variables: JSON.parse(row.variables || "[]"), tags: JSON.parse(row.tags || "[]"), folderId: row.folder_id, + parentId: row.parent_id, + order: row.sort_order, images: JSON.parse(row.images || "[]"), videos: JSON.parse(row.videos || "[]"), isFavorite: row.is_favorite === 1, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 58d08d5c..03837a31 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -22,6 +22,8 @@ CREATE TABLE IF NOT EXISTS prompts ( variables TEXT, tags TEXT, folder_id TEXT, + parent_id TEXT, + sort_order INTEGER DEFAULT 0, images TEXT, videos TEXT, is_favorite INTEGER DEFAULT 0, @@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS prompts ( last_ai_response TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL + FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES prompts(id) ON DELETE CASCADE ); -- 版本表 @@ -226,6 +229,8 @@ CREATE INDEX IF NOT EXISTS idx_folders_sort ON folders(sort_order); CREATE INDEX IF NOT EXISTS idx_prompts_folder_favorite ON prompts(folder_id, is_favorite); CREATE INDEX IF NOT EXISTS idx_prompts_folder_updated ON prompts(folder_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_prompts_parent ON prompts(parent_id); +CREATE INDEX IF NOT EXISTS idx_prompts_sort_order ON prompts(sort_order); -- 全文搜索 (FTS5) CREATE VIRTUAL TABLE IF NOT EXISTS prompts_fts USING fts5( diff --git a/packages/shared/constants/ipc-channels.ts b/packages/shared/constants/ipc-channels.ts index 5dbeb49d..5bd0a80d 100644 --- a/packages/shared/constants/ipc-channels.ts +++ b/packages/shared/constants/ipc-channels.ts @@ -26,6 +26,7 @@ export const IPC_CHANNELS = { * 若 SQLite 已有数据则直接返回 { imported: false }(防止覆盖)。 */ PROMPT_MIGRATE_IDB_BATCH: "prompt:migrateIdbBatch", + PROMPT_MOVE: "prompt:move", // Version VERSION_GET_ALL: "version:getAll", diff --git a/packages/shared/types/prompt.ts b/packages/shared/types/prompt.ts index b2a2b2b6..f4ff302a 100644 --- a/packages/shared/types/prompt.ts +++ b/packages/shared/types/prompt.ts @@ -21,6 +21,8 @@ export interface Prompt { variables: Variable[]; tags: string[]; folderId?: string | null; + parentId?: string | null; // Parent prompt ID for hierarchical structure + order?: number; // Sort order within the same parent images?: string[]; videos?: string[]; // Video file names for preview / 视频预览文件名 isFavorite: boolean; @@ -91,6 +93,8 @@ export interface UpdatePromptDTO { variables?: Variable[]; tags?: string[]; folderId?: string | null; + parentId?: string | null; + order?: number; images?: string[]; videos?: string[]; isFavorite?: boolean; From 7872a171c532a8e7230ab9638a2a4dfad9d5081d Mon Sep 17 00:00:00 2001 From: lingxiaotian Date: Sun, 14 Jun 2026 11:13:12 +0800 Subject: [PATCH 2/3] fix: harden hierarchical prompt tree --- apps/desktop/src/main/ipc/prompt.ipc.ts | 20 + .../components/layout/MainContent.tsx | 37 - .../components/prompt/PromptListView.tsx | 719 ++++++++++++------ .../desktop/src/renderer/services/database.ts | 133 +++- .../main/database-migration-locks.test.ts | 66 ++ .../desktop/tests/unit/main/prompt-db.test.ts | 80 ++ packages/db/src/init.ts | 14 + packages/db/src/prompt.ts | 126 ++- packages/db/src/schema.ts | 2 +- 9 files changed, 885 insertions(+), 312 deletions(-) diff --git a/apps/desktop/src/main/ipc/prompt.ipc.ts b/apps/desktop/src/main/ipc/prompt.ipc.ts index faabc7f0..492063b4 100644 --- a/apps/desktop/src/main/ipc/prompt.ipc.ts +++ b/apps/desktop/src/main/ipc/prompt.ipc.ts @@ -51,6 +51,25 @@ export function registerPromptIPC(db: PromptDB, folderDb: FolderDB, rawDb: Datab return ordered; }; + const assertPromptMoveInput = ( + promptId: string, + newParentId: string | null, + newOrder: number, + ) => { + if (typeof promptId !== 'string' || promptId.trim().length === 0) { + throw new Error('Prompt id is required'); + } + if ( + newParentId !== null && + (typeof newParentId !== 'string' || newParentId.trim().length === 0) + ) { + throw new Error('Parent prompt id must be null or a non-empty string'); + } + if (!Number.isFinite(newOrder) || newOrder < 0) { + throw new Error('Prompt order must be a non-negative number'); + } + }; + // Create Prompt // 创建 Prompt ipcMain.handle(IPC_CHANNELS.PROMPT_CREATE, async (_, data: CreatePromptDTO) => { @@ -251,6 +270,7 @@ export function registerPromptIPC(db: PromptDB, folderDb: FolderDB, rawDb: Datab }); ipcMain.handle(IPC_CHANNELS.PROMPT_MOVE, async (_, promptId: string, newParentId: string | null, newOrder: number) => { + assertPromptMoveInput(promptId, newParentId, newOrder); db.movePrompt(promptId, newParentId, newOrder); syncWorkspace(); return true; diff --git a/apps/desktop/src/renderer/components/layout/MainContent.tsx b/apps/desktop/src/renderer/components/layout/MainContent.tsx index 1919b451..43b6215f 100644 --- a/apps/desktop/src/renderer/components/layout/MainContent.tsx +++ b/apps/desktop/src/renderer/components/layout/MainContent.tsx @@ -21,7 +21,6 @@ const SkillManager = lazy(() => import('../skill/SkillManager').then(m => ({ def const RulesManager = lazy(() => import('../rules/RulesManager').then(m => ({ default: m.RulesManager }))); const EditPromptModal = lazy(() => import('../prompt/EditPromptModal').then(m => ({ default: m.EditPromptModal }))); const PromptQuickRewriteDialog = lazy(() => import('../prompt/PromptQuickRewriteDialog').then(m => ({ default: m.PromptQuickRewriteDialog }))); -const PromptTableView = lazy(() => import('../prompt/PromptTableView').then(m => ({ default: m.PromptTableView }))); const PromptGalleryView = lazy(() => import('../prompt/PromptGalleryView').then(m => ({ default: m.PromptGalleryView }))); const PromptKanbanView = lazy(() => import('../prompt/PromptKanbanView').then(m => ({ default: m.PromptKanbanView }))); const PromptListView = lazy(() => import('../prompt/PromptListView').then(m => ({ default: m.PromptListView }))); @@ -1930,42 +1929,6 @@ function PromptSkillMainContent() { ) : ( <> - {/* List view mode */} - {/* 列表视图模式 */} -
- - - {/* Top: sort + view switch */} - {/* 顶部:排序 + 视图切换 */} - - - {/* Table view */} - {/* 表格视图 */} -
- - selectPrompt(id)} - onToggleFavorite={toggleFavorite} - onCopy={handleCopyPrompt} - onEdit={(prompt) => setEditingPrompt(prompt)} - onDelete={handleDeletePrompt} - onAiTest={handleAiTestFromTable} - onVersionHistory={handleVersionHistory} - onViewDetail={handleViewDetail} - aiResults={aiResponseCache} - onBatchFavorite={handleBatchFavorite} - onBatchMove={handleBatchMove} - onBatchDelete={handleBatchDelete} - onContextMenu={handleContextMenu} - /> - -
-
- {/* Gallery view */} {/* Gallery 视图 */}
void; onToggleFavorite: (id: string) => void; onCopy: (prompt: Prompt) => void; - onContextMenu: (e: React.MouseEvent, prompt: Prompt) => void; - onMovePrompt: (promptId: string, newParentId: string | null, newOrder: number) => void; + onContextMenu: (e: ReactMouseEvent, prompt: Prompt) => void; + onMovePrompt: ( + promptId: string, + newParentId: string | null, + newOrder: number, + ) => void; sortBy?: string; sortOrder?: 'asc' | 'desc'; } +function comparePromptTreeOrder(a: Prompt, b: Prompt): number { + return ( + (a.order ?? 0) - (b.order ?? 0) || + a.title.localeCompare(b.title) || + a.id.localeCompare(b.id) + ); +} + +function getDropPosition(event: ReactDragEvent): DropPosition { + const rect = event.currentTarget.getBoundingClientRect(); + const y = event.clientY - rect.top; + + if (y < rect.height / 3) { + return 'before'; + } + + if (y > (rect.height * 2) / 3) { + return 'after'; + } + + return 'inside'; +} + export function PromptListView({ prompts, selectedId, @@ -29,44 +69,135 @@ export function PromptListView({ const { t } = useTranslation(); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); - const [dropPosition, setDropPosition] = useState<'before' | 'after' | 'inside' | null>(null); + const [dropPosition, setDropPosition] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); + const promptById = useMemo(() => { + return new Map(prompts.map((prompt) => [prompt.id, prompt])); + }, [prompts]); + + const getVisibleParentId = useCallback( + (prompt: Prompt): string | null => { + if (!prompt.parentId || prompt.parentId === prompt.id) { + return null; + } + + return promptById.has(prompt.parentId) ? prompt.parentId : null; + }, + [promptById], + ); + + const childrenByParent = useMemo(() => { + const groups = new Map(); + + for (const prompt of prompts) { + const parentId = getVisibleParentId(prompt); + const siblings = groups.get(parentId) ?? []; + siblings.push(prompt); + groups.set(parentId, siblings); + } + + for (const siblings of groups.values()) { + siblings.sort(comparePromptTreeOrder); + } + + return groups; + }, [getVisibleParentId, prompts]); + + const getChildren = useCallback( + (parentId: string | null) => childrenByParent.get(parentId) ?? [], + [childrenByParent], + ); + + const isDescendantOf = useCallback( + (candidateId: string, ancestorId: string): boolean => { + let current = promptById.get(candidateId); + const visited = new Set(); + + while (current) { + const parentId = getVisibleParentId(current); + if (!parentId) { + return false; + } + if (parentId === ancestorId) { + return true; + } + if (visited.has(parentId)) { + return false; + } + + visited.add(parentId); + current = promptById.get(parentId); + } + + return false; + }, + [getVisibleParentId, promptById], + ); + + const canMoveToParent = useCallback( + (promptId: string, parentId: string | null): boolean => { + return ( + !parentId || + (parentId !== promptId && !isDescendantOf(parentId, promptId)) + ); + }, + [isDescendantOf], + ); + useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { if (!selectedId || draggingId) return; - const selectedPrompt = prompts.find(p => p.id === selectedId); + const selectedPrompt = promptById.get(selectedId); if (!selectedPrompt) return; + if (event.key !== 'Tab' || event.ctrlKey || event.metaKey) return; - if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - - if (e.shiftKey) { - // Shift+Tab: outdent (move to parent level) - if (selectedPrompt.parentId) { - const siblings = prompts.filter(p => p.parentId === selectedPrompt.parentId); - const parentPrompt = prompts.find(p => p.id === selectedPrompt.parentId); - const newOrder = parentPrompt ? prompts.filter(p => p.parentId === parentPrompt?.parentId).length : prompts.filter(p => p.parentId === null).length; - onMovePrompt(selectedId, parentPrompt?.parentId || null, newOrder); - } - } else { - // Tab: indent (move to previous sibling's child) - const siblings = prompts.filter(p => p.parentId === selectedPrompt.parentId).sort((a, b) => (a.order || 0) - (b.order || 0)); - const currentIndex = siblings.findIndex(s => s.id === selectedId); - - if (currentIndex > 0) { - const prevSibling = siblings[currentIndex - 1]; - const childCount = prompts.filter(p => p.parentId === prevSibling.id).length; - onMovePrompt(selectedId, prevSibling.id, childCount); - } - } + event.preventDefault(); + + const currentParentId = getVisibleParentId(selectedPrompt); + + if (event.shiftKey) { + if (!currentParentId) return; + + const parentPrompt = promptById.get(currentParentId); + const grandParentId = parentPrompt + ? getVisibleParentId(parentPrompt) + : null; + const parentSiblings = getChildren(grandParentId); + const parentIndex = parentSiblings.findIndex( + (prompt) => prompt.id === currentParentId, + ); + + onMovePrompt( + selectedId, + grandParentId, + parentIndex >= 0 ? parentIndex + 1 : parentSiblings.length, + ); + return; } + + const siblings = getChildren(currentParentId); + const currentIndex = siblings.findIndex( + (prompt) => prompt.id === selectedId, + ); + if (currentIndex <= 0) return; + + const previousSibling = siblings[currentIndex - 1]; + setExpandedIds((current) => new Set(current).add(previousSibling.id)); + onMovePrompt(selectedId, previousSibling.id, getChildren(previousSibling.id).length); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedId, prompts, onMovePrompt, draggingId]); + }, [ + draggingId, + getChildren, + getVisibleParentId, + onMovePrompt, + promptById, + selectedId, + ]); const formatDate = (dateStr: string) => { const date = new Date(dateStr); @@ -75,43 +206,46 @@ export function PromptListView({ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) { - return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - } else if (diffDays === 1) { + return date.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + } + + if (diffDays === 1) { return t('common.yesterday') || '昨天'; - } else if (diffDays < 7) { + } + + if (diffDays < 7) { return `${diffDays}${t('common.daysAgo') || '天前'}`; - } else { - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } + + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); }; const toggleExpand = useCallback((promptId: string) => { - setExpandedIds(prev => { - const newSet = new Set(prev); - if (newSet.has(promptId)) { - newSet.delete(promptId); + setExpandedIds((current) => { + const next = new Set(current); + if (next.has(promptId)) { + next.delete(promptId); } else { - newSet.add(promptId); + next.add(promptId); } - return newSet; + return next; }); }, []); - const hasChildren = useCallback((promptId: string) => { - return prompts.some(p => p.parentId === promptId); - }, [prompts]); - - const getChildren = useCallback((parentId: string | null) => { - return prompts - .filter(p => p.parentId === parentId) - .sort((a, b) => (a.order || 0) - (b.order || 0)); - }, [prompts]); - - const handleDragStart = useCallback((e: React.DragEvent, promptId: string) => { - setDraggingId(promptId); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', promptId); - }, []); + const handleDragStart = useCallback( + (event: ReactDragEvent, promptId: string) => { + setDraggingId(promptId); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', promptId); + }, + [], + ); const handleDragEnd = useCallback(() => { setDraggingId(null); @@ -119,210 +253,323 @@ export function PromptListView({ setDropPosition(null); }, []); - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - }, []); + const updateDropTarget = useCallback( + (event: ReactDragEvent, targetPrompt: Prompt) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; - const handleDragEnter = useCallback((e: React.DragEvent, promptId: string) => { - e.preventDefault(); - if (draggingId !== promptId) { - setDropTargetId(promptId); - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const y = e.clientY - rect.top; - const height = rect.height; - - if (y < height / 3) { - setDropPosition('before'); - } else if (y > height * 2 / 3) { - setDropPosition('after'); - } else { - setDropPosition('inside'); + if (!draggingId || draggingId === targetPrompt.id) { + setDropTargetId(null); + setDropPosition(null); + return; } + + const nextDropPosition = getDropPosition(event); + const nextParentId = + nextDropPosition === 'inside' + ? targetPrompt.id + : getVisibleParentId(targetPrompt); + + if (!canMoveToParent(draggingId, nextParentId)) { + setDropTargetId(null); + setDropPosition(null); + return; + } + + setDropTargetId(targetPrompt.id); + setDropPosition(nextDropPosition); + }, + [canMoveToParent, draggingId, getVisibleParentId], + ); + + const handleDragLeave = useCallback((event: ReactDragEvent) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; } - }, [draggingId]); - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); setDropTargetId(null); setDropPosition(null); }, []); - const handleDrop = useCallback((e: React.DragEvent, targetPromptId: string) => { - e.preventDefault(); - if (draggingId && draggingId !== targetPromptId) { - const targetPrompt = prompts.find(p => p.id === targetPromptId); - const draggingPrompt = prompts.find(p => p.id === draggingId); - - if (targetPrompt && draggingPrompt) { - if (dropPosition === 'inside') { - const childCount = prompts.filter(p => p.parentId === targetPromptId).length; - onMovePrompt(draggingId, targetPromptId, childCount); - } else { - const newParentId = targetPrompt.parentId; - let targetOrder = targetPrompt.order || 0; - - if (dropPosition === 'after') { - targetOrder += 1; - } - - if (draggingPrompt.parentId === newParentId && draggingPrompt.order && draggingPrompt.order < targetOrder) { - targetOrder -= 1; - } - - onMovePrompt(draggingId, newParentId, targetOrder); + const handleDrop = useCallback( + (event: ReactDragEvent, targetPrompt: Prompt) => { + event.preventDefault(); + + if (!draggingId || draggingId === targetPrompt.id || !dropPosition) { + handleDragEnd(); + return; + } + + if (dropPosition === 'inside') { + if (canMoveToParent(draggingId, targetPrompt.id)) { + setExpandedIds((current) => new Set(current).add(targetPrompt.id)); + onMovePrompt(draggingId, targetPrompt.id, getChildren(targetPrompt.id).length); } + handleDragEnd(); + return; } - } - setDraggingId(null); - setDropTargetId(null); - setDropPosition(null); - }, [draggingId, dropPosition, prompts, onMovePrompt]); - - const isSelected = useCallback((promptId: string) => { - return selectedId === promptId || selectedIds.includes(promptId); - }, [selectedId, selectedIds]); - - const isDragging = useCallback((promptId: string) => { - return draggingId === promptId; - }, [draggingId]); - - const isDropTarget = useCallback((promptId: string) => { - return dropTargetId === promptId; - }, [dropTargetId]); - - const renderTreeNode = useCallback((prompt: Prompt, depth: number) => { - const hasKids = hasChildren(prompt.id); - const isExpanded = expandedIds.has(prompt.id); - - return ( -
-
handleDragStart(e, prompt.id)} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - onDragEnter={(e) => handleDragEnter(e, prompt.id)} - onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, prompt.id)} - onClick={() => onSelect(prompt.id)} - onContextMenu={(e) => onContextMenu(e, prompt)} - className={` - flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer - transition-colors duration-quick relative - ${isSelected(prompt.id) - ? 'bg-primary/10 border-l-2 border-l-primary' - : isDropTarget(prompt.id) && dropPosition === 'inside' - ? 'bg-primary/20 border-l-2 border-l-primary' - : 'hover:bg-accent/50' - } - ${isDragging(prompt.id) ? 'opacity-50' : ''} - ${isDropTarget(prompt.id) && dropPosition === 'inside' ? 'ring-2 ring-primary/50 ring-inset' : ''} - ${isDropTarget(prompt.id) && dropPosition === 'before' ? 'border-t-2 border-t-primary' : ''} - ${isDropTarget(prompt.id) && dropPosition === 'after' ? 'border-b-2 border-b-primary' : ''} - `} - style={{ paddingLeft: `${depth * 16 + 12}px` }} - > -
- {hasKids && ( + + const nextParentId = getVisibleParentId(targetPrompt); + if (!canMoveToParent(draggingId, nextParentId)) { + handleDragEnd(); + return; + } + + const targetSiblings = getChildren(nextParentId).filter( + (prompt) => prompt.id !== draggingId, + ); + const targetIndex = targetSiblings.findIndex( + (prompt) => prompt.id === targetPrompt.id, + ); + const nextOrder = + targetIndex < 0 + ? targetSiblings.length + : targetIndex + (dropPosition === 'after' ? 1 : 0); + + onMovePrompt(draggingId, nextParentId, nextOrder); + handleDragEnd(); + }, + [ + canMoveToParent, + draggingId, + dropPosition, + getChildren, + getVisibleParentId, + handleDragEnd, + onMovePrompt, + ], + ); + + const isSelected = useCallback( + (promptId: string) => selectedId === promptId || selectedIds.includes(promptId), + [selectedId, selectedIds], + ); + + const isDragging = useCallback( + (promptId: string) => draggingId === promptId, + [draggingId], + ); + + const isDropTarget = useCallback( + (promptId: string) => dropTargetId === promptId, + [dropTargetId], + ); + + const renderTreeNode = useCallback( + (prompt: Prompt, depth: number, ancestors: Set) => { + const nextAncestors = new Set(ancestors).add(prompt.id); + const children = getChildren(prompt.id).filter( + (child) => !nextAncestors.has(child.id), + ); + const hasKids = children.length > 0; + const isExpanded = expandedIds.has(prompt.id); + + return ( +
+
handleDragStart(event, prompt.id)} + onDragEnd={handleDragEnd} + onDragOver={(event) => updateDropTarget(event, prompt)} + onDragEnter={(event) => updateDropTarget(event, prompt)} + onDragLeave={handleDragLeave} + onDrop={(event) => handleDrop(event, prompt)} + onClick={() => onSelect(prompt.id)} + onContextMenu={(event) => onContextMenu(event, prompt)} + className={` + flex items-center gap-3 px-3 py-2.5 border-b border-border/50 cursor-pointer + transition-colors duration-quick relative + ${ + isSelected(prompt.id) + ? 'bg-primary/10 border-l-2 border-l-primary' + : isDropTarget(prompt.id) && dropPosition === 'inside' + ? 'bg-primary/20 border-l-2 border-l-primary' + : 'hover:bg-accent/50' + } + ${isDragging(prompt.id) ? 'opacity-50' : ''} + ${ + isDropTarget(prompt.id) && dropPosition === 'inside' + ? 'ring-2 ring-primary/50 ring-inset' + : '' + } + ${ + isDropTarget(prompt.id) && dropPosition === 'before' + ? 'border-t-2 border-t-primary' + : '' + } + ${ + isDropTarget(prompt.id) && dropPosition === 'after' + ? 'border-b-2 border-b-primary' + : '' + } + `} + style={{ paddingLeft: `${depth * 16 + 12}px` }} + > +
+ {hasKids ? ( + + ) : ( + + )} + +
+ +
+
+

+ {prompt.title} +

+ {prompt.isFavorite && ( + + )} +
+ {prompt.description && ( +

+ {prompt.description} +

+ )} + {prompt.images && prompt.images.length > 0 && ( +
+ + + {prompt.images.length} + +
+ )} +
+ +
+ + {prompt.usageCount || 0} + +
+ +
+ + {formatDate(prompt.updatedAt)} + +
+ +
- )} - {!hasKids && } - -
- -
-
-

{ + event.stopPropagation(); + onToggleFavorite(prompt.id); + }} + className={`p-1.5 rounded-md transition-colors ${ + prompt.isFavorite + ? 'text-yellow-500 hover:bg-yellow-500/10' + : 'text-muted-foreground hover:text-foreground hover:bg-accent' }`} - title={prompt.title} + title={ + prompt.isFavorite + ? t('nav.favorites') + : t('prompt.addToFavorites') || '添加收藏' + } > - {prompt.title} -

- {prompt.isFavorite && ( - - )} + +
- {prompt.description && ( -

- {prompt.description} -

- )} - {prompt.images && prompt.images.length > 0 && ( -
- - {prompt.images.length} -
- )}
-
- - {prompt.usageCount || 0} - -
+ {hasKids && isExpanded && ( +
+ {children.map((child) => + renderTreeNode(child, depth + 1, nextAncestors), + )} +
+ )} +
+ ); + }, + [ + dropPosition, + expandedIds, + formatDate, + getChildren, + handleDragEnd, + handleDragLeave, + handleDragStart, + handleDrop, + isDragging, + isDropTarget, + isSelected, + onContextMenu, + onCopy, + onSelect, + onToggleFavorite, + t, + toggleExpand, + updateDropTarget, + ], + ); -
- - {formatDate(prompt.updatedAt)} - -
+ const rootNodes = useMemo(() => { + const attachedIds = new Set(); -
- - -
-
+ const collect = (prompt: Prompt, ancestors: Set) => { + if (ancestors.has(prompt.id)) { + return; + } - {hasKids && isExpanded && ( -
- {getChildren(prompt.id).map((child) => renderTreeNode(child, depth + 1))} -
- )} -
- ); - }, [hasChildren, expandedIds, toggleExpand, getChildren, isSelected, isDragging, isDropTarget, dropPosition, onSelect, onContextMenu, handleDragStart, handleDragEnd, handleDragOver, handleDragEnter, handleDragLeave, handleDrop, onCopy, onToggleFavorite, t]); + attachedIds.add(prompt.id); + const nextAncestors = new Set(ancestors).add(prompt.id); + for (const child of getChildren(prompt.id)) { + collect(child, nextAncestors); + } + }; - const rootNodes = getChildren(null); + const roots = getChildren(null); + for (const root of roots) { + collect(root, new Set()); + } + + const detached = prompts + .filter((prompt) => !attachedIds.has(prompt.id)) + .sort(comparePromptTreeOrder); + + return [...roots, ...detached]; + }, [getChildren, prompts]); return ( -
- {rootNodes.map((node) => renderTreeNode(node, 0))} +
+ {rootNodes.map((node) => renderTreeNode(node, 0, new Set()))}
); -} \ No newline at end of file +} diff --git a/apps/desktop/src/renderer/services/database.ts b/apps/desktop/src/renderer/services/database.ts index 767d0507..64fbe750 100644 --- a/apps/desktop/src/renderer/services/database.ts +++ b/apps/desktop/src/renderer/services/database.ts @@ -361,32 +361,139 @@ export async function movePrompt( newParentId: string | null, newOrder: number, ): Promise { + if (!Number.isFinite(newOrder) || newOrder < 0) { + throw new Error("Prompt order must be a non-negative number"); + } + if (window.api?.prompt?.move) { await window.api.prompt.move(promptId, newParentId, newOrder); return; } const database = await getDatabase(); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = database.transaction(STORES.PROMPTS, "readwrite"); const store = transaction.objectStore(STORES.PROMPTS); - const getRequest = store.get(promptId); + const getAllRequest = store.getAll(); - getRequest.onsuccess = () => { - const prompt = getRequest.result; - if (prompt) { - prompt.parentId = newParentId; - prompt.order = newOrder; - prompt.updatedAt = new Date().toISOString(); - const putRequest = store.put(prompt); - putRequest.onsuccess = () => resolve(); - putRequest.onerror = () => reject(putRequest.error); - } else { + getAllRequest.onsuccess = () => { + const prompts = getAllRequest.result as Prompt[]; + const prompt = prompts.find((item) => item.id === promptId); + if (!prompt) { resolve(); + return; + } + + try { + const targetParentId = newParentId ?? null; + assertPromptMoveAllowed(prompts, promptId, targetParentId); + const reorderedPrompts = reorderPromptTree( + prompts, + promptId, + targetParentId, + newOrder, + ); + const now = new Date().toISOString(); + + for (const item of reorderedPrompts) { + const putRequest = store.put({ + ...item, + updatedAt: item.id === promptId ? now : item.updatedAt, + }); + putRequest.onerror = () => reject(putRequest.error); + } + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + } catch (error) { + reject(error); } }; - getRequest.onerror = () => reject(getRequest.error); + getAllRequest.onerror = () => reject(getAllRequest.error); + }); +} + +function assertPromptMoveAllowed( + prompts: Prompt[], + promptId: string, + parentId: string | null, +): void { + if (!parentId) { + return; + } + + if (parentId === promptId) { + throw new Error("Cannot move a prompt under itself"); + } + + const promptById = new Map(prompts.map((prompt) => [prompt.id, prompt])); + let currentParentId: string | null | undefined = parentId; + const visited = new Set(); + + while (currentParentId) { + if (currentParentId === promptId) { + throw new Error("Cannot move a prompt under its descendant"); + } + if (visited.has(currentParentId)) { + throw new Error("Cannot move prompt into a cyclic hierarchy"); + } + + visited.add(currentParentId); + const parent = promptById.get(currentParentId); + if (!parent) { + throw new Error("Parent prompt does not exist"); + } + currentParentId = parent.parentId; + } +} + +function reorderPromptTree( + prompts: Prompt[], + promptId: string, + parentId: string | null, + order: number, +): Prompt[] { + const prompt = prompts.find((item) => item.id === promptId); + if (!prompt) { + return prompts; + } + + const oldParentId = prompt.parentId ?? null; + const nextPrompts = prompts.map((item) => ({ ...item })); + normalizePromptSiblings(nextPrompts, oldParentId, promptId); + + const targetSiblings = nextPrompts + .filter((item) => (item.parentId ?? null) === parentId && item.id !== promptId) + .sort(comparePromptOrder); + const targetIndex = Math.min(Math.trunc(order), targetSiblings.length); + targetSiblings.splice(targetIndex, 0, prompt); + + targetSiblings.forEach((item, index) => { + const target = nextPrompts.find((candidate) => candidate.id === item.id); + if (target) { + target.parentId = parentId; + target.order = index; + } }); + + return nextPrompts; +} + +function normalizePromptSiblings( + prompts: Prompt[], + parentId: string | null, + excludeId: string, +): void { + prompts + .filter((prompt) => (prompt.parentId ?? null) === parentId && prompt.id !== excludeId) + .sort(comparePromptOrder) + .forEach((prompt, index) => { + prompt.order = index; + }); +} + +function comparePromptOrder(a: Prompt, b: Prompt): number { + return (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id); } // ==================== Version 操作 ==================== diff --git a/apps/desktop/tests/unit/main/database-migration-locks.test.ts b/apps/desktop/tests/unit/main/database-migration-locks.test.ts index c52f74ee..699a5c72 100644 --- a/apps/desktop/tests/unit/main/database-migration-locks.test.ts +++ b/apps/desktop/tests/unit/main/database-migration-locks.test.ts @@ -34,6 +34,29 @@ function createLegacySkillSchema(dbPath: string): DatabaseAdapter.Database { return db; } +function createLegacyPromptSchema(dbPath: string): DatabaseAdapter.Database { + const db = new DatabaseAdapter(dbPath); + db.exec(` + CREATE TABLE IF NOT EXISTS prompts ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + system_prompt TEXT, + user_prompt TEXT NOT NULL, + variables TEXT, + tags TEXT, + folder_id TEXT, + images TEXT, + is_favorite INTEGER DEFAULT 0, + current_version INTEGER DEFAULT 0, + usage_count INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + return db; +} + describe("database migration locking regression", () => { const tempDirs: string[] = []; @@ -118,6 +141,49 @@ describe("database migration locking regression", () => { expect(backupFiles).toEqual([]); }); + it("adds prompt hierarchy columns when migrating an older prompts table", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "prompthub-db-prompt-tree-")); + tempDirs.push(tempDir); + + const dbPath = path.join(tempDir, "prompthub.db"); + const legacyDb = createLegacyPromptSchema(dbPath); + const now = Date.now(); + legacyDb.run( + "INSERT INTO prompts (id, title, user_prompt, variables, tags, images, is_favorite, current_version, usage_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "prompt-1", + "Legacy Prompt", + "content", + "[]", + "[]", + "[]", + 0, + 1, + 0, + now, + now, + ); + legacyDb.close(); + + const migratedDb = initSharedDatabase(dbPath); + + const promptColumns = migratedDb.pragma("table_info(prompts)") as Array<{ + name: string; + }>; + const promptRow = migratedDb.get( + "SELECT id, parent_id, sort_order FROM prompts WHERE id = ?", + "prompt-1", + ); + + expect(promptColumns.map((column) => column.name)).toEqual( + expect.arrayContaining(["parent_id", "sort_order"]), + ); + expect(promptRow).toEqual({ + id: "prompt-1", + parent_id: null, + sort_order: 0, + }); + }); + it("does not report clearing a stale lock when no lock exists", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "prompthub-db-no-lock-")); tempDirs.push(tempDir); diff --git a/apps/desktop/tests/unit/main/prompt-db.test.ts b/apps/desktop/tests/unit/main/prompt-db.test.ts index e4b1a17c..80d87d18 100644 --- a/apps/desktop/tests/unit/main/prompt-db.test.ts +++ b/apps/desktop/tests/unit/main/prompt-db.test.ts @@ -239,6 +239,86 @@ describe("PromptDB (in-memory SQLite)", () => { }); }); + // ───────────────────────────────────────────── + // hierarchical grouping + // ───────────────────────────────────────────── + describe("hierarchical grouping", () => { + it("moves prompts under a logical parent and keeps sibling order contiguous", () => { + const parent = db.create({ title: "Parent", userPrompt: "p" }); + const first = db.create({ title: "First", userPrompt: "p" }); + const second = db.create({ title: "Second", userPrompt: "p" }); + const third = db.create({ title: "Third", userPrompt: "p" }); + + db.movePrompt(first.id, parent.id, 0); + db.movePrompt(second.id, parent.id, 1); + db.movePrompt(third.id, parent.id, 2); + db.movePrompt(third.id, parent.id, 0); + + expect(db.getChildren(parent.id).map((prompt) => prompt.id)).toEqual([ + third.id, + first.id, + second.id, + ]); + expect( + db.getChildren(parent.id).map((prompt) => prompt.order), + ).toEqual([0, 1, 2]); + }); + + it("rejects moving a prompt under itself", () => { + const prompt = db.create({ title: "Self", userPrompt: "p" }); + + expect(() => db.movePrompt(prompt.id, prompt.id, 0)).toThrow( + "Cannot move a prompt under itself", + ); + expect(db.getById(prompt.id)?.parentId).toBeNull(); + }); + + it("rejects invalid parent and order inputs", () => { + const prompt = db.create({ title: "Invalid Move", userPrompt: "p" }); + + expect(() => db.movePrompt(prompt.id, "", 0)).toThrow( + "Parent prompt id must be null or a non-empty string", + ); + expect(() => db.movePrompt(prompt.id, null, -1)).toThrow( + "Prompt order must be a non-negative number", + ); + expect(() => db.movePrompt(prompt.id, "missing-parent", 0)).toThrow( + "Parent prompt does not exist", + ); + expect(db.getById(prompt.id)?.parentId).toBeNull(); + }); + + it("rejects moving a prompt under one of its descendants", () => { + const root = db.create({ title: "Root", userPrompt: "p" }); + const child = db.create({ title: "Child", userPrompt: "p" }); + const grandchild = db.create({ title: "Grandchild", userPrompt: "p" }); + + db.movePrompt(child.id, root.id, 0); + db.movePrompt(grandchild.id, child.id, 0); + + expect(() => db.movePrompt(root.id, grandchild.id, 0)).toThrow( + "Cannot move a prompt under its descendant", + ); + expect(db.getById(root.id)?.parentId).toBeNull(); + expect(db.getById(child.id)?.parentId).toBe(root.id); + expect(db.getById(grandchild.id)?.parentId).toBe(child.id); + }); + + it("clears child parentId instead of deleting children when a parent prompt is deleted", () => { + const parent = db.create({ title: "Parent", userPrompt: "p" }); + const child = db.create({ title: "Child", userPrompt: "p" }); + + db.movePrompt(child.id, parent.id, 0); + expect(db.getById(child.id)?.parentId).toBe(parent.id); + + expect(db.delete(parent.id)).toBe(true); + + const remainingChild = db.getById(child.id); + expect(remainingChild).not.toBeNull(); + expect(remainingChild?.parentId).toBeNull(); + }); + }); + // ───────────────────────────────────────────── // search // ───────────────────────────────────────────── diff --git a/packages/db/src/init.ts b/packages/db/src/init.ts index 1b3b7ff1..38511d31 100644 --- a/packages/db/src/init.ts +++ b/packages/db/src/init.ts @@ -69,6 +69,8 @@ const REQUIRED_COLUMNS: Record = { "last_ai_response", "owner_user_id", "visibility", + "parent_id", + "sort_order", ], folders: ["is_private", "updated_at", "owner_user_id", "visibility"], skills: [ @@ -332,6 +334,18 @@ export function initDatabase( db!.run("ALTER TABLE prompts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } + if (!promptCols.includes("parent_id")) { + console.log("Migrating: Adding parent_id column to prompts table"); + db!.run( + "ALTER TABLE prompts ADD COLUMN parent_id TEXT REFERENCES prompts(id) ON DELETE SET NULL", + ); + } + + if (!promptCols.includes("sort_order")) { + console.log("Migrating: Adding sort_order column to prompts table"); + db!.run("ALTER TABLE prompts ADD COLUMN sort_order INTEGER DEFAULT 0"); + } + // Migrations: folders table (query column list once) const folderCols = ( db!.pragma("table_info(folders)") as PragmaColumnInfo[] diff --git a/packages/db/src/prompt.ts b/packages/db/src/prompt.ts index b587331f..938f4f84 100644 --- a/packages/db/src/prompt.ts +++ b/packages/db/src/prompt.ts @@ -690,45 +690,121 @@ export class PromptDB { * 移动提示词到新的父节点或在同级中重新排序 */ movePrompt(promptId: string, newParentId: string | null, newOrder: number): void { + if (!Number.isFinite(newOrder) || newOrder < 0) { + throw new Error("Prompt order must be a non-negative number"); + } + if (newParentId !== null && newParentId.trim().length === 0) { + throw new Error("Parent prompt id must be null or a non-empty string"); + } + const txn = this.db.transaction(() => { - // Get current parent and order const current = this.db - .prepare("SELECT parent_id, sort_order FROM prompts WHERE id = ?") - .get(promptId) as { parent_id: string | null; sort_order: number } | undefined; + .prepare("SELECT id, parent_id FROM prompts WHERE id = ?") + .get(promptId) as { id: string; parent_id: string | null } | undefined; if (!current) return; + if (newParentId === promptId) { + throw new Error("Cannot move a prompt under itself"); + } + + this.assertValidPromptParent(promptId, newParentId); + const oldParentId = current.parent_id; - const oldOrder = current.sort_order; + const targetParentId = newParentId ?? null; - // Update the moved prompt - this.db - .prepare("UPDATE prompts SET parent_id = ?, sort_order = ?, updated_at = ? WHERE id = ?") - .run(newParentId, newOrder, Date.now(), promptId); - - // If parent changed, adjust orders in old parent - if (oldParentId !== newParentId && oldParentId !== null) { - this.db - .prepare("UPDATE prompts SET sort_order = sort_order - 1 WHERE parent_id = ? AND sort_order > ?") - .run(oldParentId, oldOrder); + if (oldParentId !== targetParentId) { + this.rewritePromptSiblingOrder( + oldParentId, + this.getPromptSiblingIds(oldParentId).filter((id) => id !== promptId), + ); } - // Adjust orders in new parent to make room - if (newParentId !== null) { - this.db - .prepare("UPDATE prompts SET sort_order = sort_order + 1 WHERE parent_id = ? AND sort_order >= ? AND id != ?") - .run(newParentId, newOrder, promptId); - } else { - // No parent (root level) - this.db - .prepare("UPDATE prompts SET sort_order = sort_order + 1 WHERE parent_id IS NULL AND sort_order >= ? AND id != ?") - .run(newOrder, promptId); - } + const targetSiblingIds = this.getPromptSiblingIds(targetParentId).filter( + (id) => id !== promptId, + ); + const targetIndex = Math.min(Math.trunc(newOrder), targetSiblingIds.length); + targetSiblingIds.splice(targetIndex, 0, promptId); + this.rewritePromptSiblingOrder( + targetParentId, + targetSiblingIds, + promptId, + Date.now(), + ); }); txn(); } + private assertValidPromptParent(promptId: string, parentId: string | null): void { + if (parentId === null) { + return; + } + + let currentParentId: string | null = parentId; + const visited = new Set(); + + while (currentParentId) { + if (currentParentId === promptId) { + throw new Error("Cannot move a prompt under its descendant"); + } + if (visited.has(currentParentId)) { + throw new Error("Cannot move prompt into a cyclic hierarchy"); + } + visited.add(currentParentId); + + const parent = this.db + .prepare("SELECT parent_id FROM prompts WHERE id = ?") + .get(currentParentId) as { parent_id: string | null } | undefined; + + if (!parent) { + throw new Error("Parent prompt does not exist"); + } + + currentParentId = parent.parent_id; + } + } + + private getPromptSiblingIds(parentId: string | null): string[] { + const stmt = + parentId === null + ? this.db.prepare( + "SELECT id FROM prompts WHERE parent_id IS NULL ORDER BY sort_order ASC, updated_at DESC, id ASC", + ) + : this.db.prepare( + "SELECT id FROM prompts WHERE parent_id = ? ORDER BY sort_order ASC, updated_at DESC, id ASC", + ); + + const rows = ( + parentId === null ? stmt.all() : stmt.all(parentId) + ) as Array<{ id: string }>; + + return rows.map((row) => row.id); + } + + private rewritePromptSiblingOrder( + parentId: string | null, + orderedIds: string[], + movedPromptId?: string, + movedAt?: number, + ): void { + const siblingStmt = this.db.prepare( + "UPDATE prompts SET parent_id = ?, sort_order = ? WHERE id = ?", + ); + const movedStmt = this.db.prepare( + "UPDATE prompts SET parent_id = ?, sort_order = ?, updated_at = ? WHERE id = ?", + ); + + orderedIds.forEach((id, index) => { + if (id === movedPromptId) { + movedStmt.run(parentId, index, movedAt ?? Date.now(), id); + return; + } + + siblingStmt.run(parentId, index, id); + }); + } + /** * Get children of a prompt * 获取提示词的子节点 diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 03837a31..70f3a36f 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS prompts ( created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL, - FOREIGN KEY (parent_id) REFERENCES prompts(id) ON DELETE CASCADE + FOREIGN KEY (parent_id) REFERENCES prompts(id) ON DELETE SET NULL ); -- 版本表 From 925aed0071b1de66e5892b941f5868bd47fb1fd1 Mon Sep 17 00:00:00 2001 From: lingxiaotian Date: Sun, 14 Jun 2026 11:13:22 +0800 Subject: [PATCH 3/3] docs: record prompt relationship tree design --- .../design.md | 72 +++++++++++++++++++ .../implementation.md | 35 +++++++++ .../proposal.md | 34 +++++++++ .../specs/prompt-relationships/spec.md | 43 +++++++++++ .../desktop-prompt-relationship-tree/tasks.md | 19 +++++ 5 files changed, 203 insertions(+) create mode 100644 spec/changes/active/desktop-prompt-relationship-tree/design.md create mode 100644 spec/changes/active/desktop-prompt-relationship-tree/implementation.md create mode 100644 spec/changes/active/desktop-prompt-relationship-tree/proposal.md create mode 100644 spec/changes/active/desktop-prompt-relationship-tree/specs/prompt-relationships/spec.md create mode 100644 spec/changes/active/desktop-prompt-relationship-tree/tasks.md diff --git a/spec/changes/active/desktop-prompt-relationship-tree/design.md b/spec/changes/active/desktop-prompt-relationship-tree/design.md new file mode 100644 index 00000000..38ade856 --- /dev/null +++ b/spec/changes/active/desktop-prompt-relationship-tree/design.md @@ -0,0 +1,72 @@ +# Design + +## Boundary + +Owner modules: + +- `packages/shared`: Prompt contract adds optional `parentId` and `order`. +- `packages/db`: SQLite schema, migration, prompt row mapping, move validation, sibling order rewrite. +- `apps/desktop/src/main`: IPC bridge for `prompt:move`. +- `apps/desktop/src/preload` and renderer services/store: desktop API exposure and fallback implementation. +- `apps/desktop/src/renderer/components/prompt/PromptListView.tsx`: tree display, drag/drop, Tab / Shift+Tab editing. + +Source of truth: + +- SQLite `prompts.parent_id` and `prompts.sort_order` are V1 durable state for `grouped_under`. +- React state only renders and requests moves. It must not be the durable authority for relationship validity. + +## Relationship Semantics + +The hierarchy is logical grouping, not ownership: + +- Parent Prompt does not own child Prompt content. +- Parent Prompt deletion does not delete child Prompts. +- Child Prompt does not inherit system/user prompt content, variables, tags, model settings, or folder membership. +- Folder hierarchy remains separate from Prompt hierarchy. Folders organize storage/navigation; Prompt hierarchy expresses Prompt-to-Prompt logic. + +The broader relationship vocabulary is intentionally typed: + +- `grouped_under`: tree browsing and topic/task grouping. +- `related_to`: loose graph edge. +- `variant_of`: fork or specialization. +- `depends_on`: prerequisite context. +- `next_step`: workflow order. + +V1 only implements `grouped_under` because it matches the contributor branch and the user's preferred direct drag interaction. + +## Data and Migration + +Fresh schema: + +- Add `prompts.parent_id TEXT REFERENCES prompts(id) ON DELETE SET NULL`. +- Add `prompts.sort_order INTEGER DEFAULT 0`. +- Add indexes for parent and sort order. + +Existing databases: + +- `initDatabase` must add both columns if missing. +- `databaseAppearsCurrent` must include these columns so pre-migration backup behavior still triggers for older user DBs. + +Move behavior: + +- Reject self-parenting. +- Reject missing parent IDs. +- Walk the target parent ancestor chain and reject cycles. +- Rewrite sibling order as contiguous `0..n` values for the old and new parent groups. + +## UI Interaction + +List mode becomes the hierarchy editor: + +- Drag into the center of a row: group under that Prompt. +- Drag above or below a row: reorder at that row's parent level. +- `Tab`: indent under previous sibling. +- `Shift+Tab`: outdent to the parent level. + +The tree list defensively renders missing-parent prompts at root level and avoids infinite recursion if corrupted data already contains a cycle. + +## Tradeoffs + +- Keeping `parentId/order` minimizes churn and preserves contributor credit, but it is only a V1 projection of `grouped_under`. +- A future graph model should likely introduce `prompt_relations` rather than overloading `parentId` for all relation kinds. +- Replacing the old table list means some table-specific batch affordances are not present in list mode. Existing card/gallery/kanban/context menu actions still cover the main single-item workflows. diff --git a/spec/changes/active/desktop-prompt-relationship-tree/implementation.md b/spec/changes/active/desktop-prompt-relationship-tree/implementation.md new file mode 100644 index 00000000..75210d9d --- /dev/null +++ b/spec/changes/active/desktop-prompt-relationship-tree/implementation.md @@ -0,0 +1,35 @@ +# Implementation + +## Shipped + +- Merged contributor branch `jazzson51569/feature/hierarchical-latest` with a merge commit so the original contribution remains visible in history. +- Kept the contributor's direct manipulation model: Prompt list mode supports drag/drop hierarchy editing and Tab / Shift+Tab indentation. +- Fixed `MainContent` list mode so it renders the tree list once instead of rendering both old table and new hierarchy list. +- Changed Prompt hierarchy foreign key semantics to `ON DELETE SET NULL` so deleting a parent Prompt does not delete child Prompts. +- Added existing-user migration for `prompts.parent_id` and `prompts.sort_order`. +- Added `PromptDB.movePrompt` validation for self-parenting, missing parents, invalid order values, and descendant cycles. +- Reworked sibling order updates to rewrite contiguous order values for affected groups. +- Hardened renderer IndexedDB fallback move logic with the same relationship guards. +- Hardened `PromptListView` against invalid drop targets, missing parents, and cyclic data rendering. +- Added IPC validation for `prompt:move`. + +## Verification + +- `pnpm --dir apps/desktop exec vitest run tests/unit/main/prompt-db.test.ts tests/unit/main/database-migration-locks.test.ts` +- `pnpm --filter @prompthub/desktop typecheck` +- `pnpm --filter @prompthub/desktop lint` +- `pnpm --filter @prompthub/desktop build` +- `git diff --check` + +Note: an earlier mistyped `pnpm --filter @prompthub/desktop test:run -- ...` invocation ran the broad desktop test suite and surfaced two unrelated failures in `tests/integration/components/skill-ui.integration.test.tsx`. + +## Synced Docs + +- Added this active change record. +- Added `specs/prompt-relationships/spec.md` to define V1 `grouped_under` behavior and the broader future relationship taxonomy. + +## Follow-ups + +- Decide whether to add a dedicated `prompt_relations` table for `related_to`, `variant_of`, `depends_on`, and `next_step`. +- Revisit list-mode batch actions after the tree list stabilizes. +- Consider an Obsidian-like graph view as a separate read/explore surface, not as the primary relation editing workflow. diff --git a/spec/changes/active/desktop-prompt-relationship-tree/proposal.md b/spec/changes/active/desktop-prompt-relationship-tree/proposal.md new file mode 100644 index 00000000..be9a6a79 --- /dev/null +++ b/spec/changes/active/desktop-prompt-relationship-tree/proposal.md @@ -0,0 +1,34 @@ +# Proposal + +## Why + +社区贡献者 `jazzson51569` 提交了 Prompt 层级列表原型,支持拖拽和 Tab / Shift+Tab 调整提示词层级。这个交互方向符合当前产品目标:用户可以直接在列表里建立提示词之间的逻辑关系,不需要进入单独的关系编辑页。 + +但原始实现把层级关系直接接近“所有权树”处理,存在两个合并前必须修正的问题: + +- 删除父 Prompt 会级联删除子 Prompt,容易误删用户内容。 +- 移动 Prompt 缺少自引用、后代循环和老数据库迁移防护。 + +## Scope + +- In scope: +- 保留贡献者的拖拽树和键盘缩进交互。 +- 将 `parentId/order` 语义收敛为 V1 的 `grouped_under` 逻辑分组。 +- 修复 list 视图重复渲染旧表格和新树列表的问题。 +- 为 SQLite fresh schema、existing-user migration、IPC、IndexedDB fallback 和 DB 层移动逻辑补安全边界。 +- 增加 DB 回归测试和迁移测试。 + +- Out of scope: +- 本轮不实现完整 Obsidian 式图谱视图。 +- 本轮不实现 `variant_of`、`depends_on`、`next_step`、`related_to` 的独立关系表和专门 UI。 +- 本轮不做 Prompt 内容继承、多态覆盖或自动组合执行。 + +## Risks + +- `parentId` 作为 V1 快速落地字段,不能被后续误解为内容所有权、继承关系或删除级联。 +- 树状 list 视图替代原 list 表格后,批量工具条能力需要后续重新接入树列表或提供单独表格模式。 +- 后续若引入图谱关系表,需要明确 `parentId` 是兼容投影还是迁移到 `prompt_relations` 的派生字段。 + +## Rollback Thinking + +如果树状列表在桌面端出现严重交互回归,可以保留 DB 字段和迁移,临时把 list 模式切回旧表格视图;已有 `parentId` 数据只是逻辑分组,不影响 Prompt 内容本体。 diff --git a/spec/changes/active/desktop-prompt-relationship-tree/specs/prompt-relationships/spec.md b/spec/changes/active/desktop-prompt-relationship-tree/specs/prompt-relationships/spec.md new file mode 100644 index 00000000..b4e9aadf --- /dev/null +++ b/spec/changes/active/desktop-prompt-relationship-tree/specs/prompt-relationships/spec.md @@ -0,0 +1,43 @@ +# Delta Spec + +## Added + +- Prompt list mode supports direct tree editing through drag-and-drop. +- Prompt list mode supports keyboard hierarchy editing: +- `Tab` moves the selected Prompt under its previous sibling. +- `Shift+Tab` moves the selected Prompt one level up. +- Prompt hierarchy is represented as logical grouping: +- `parentId` points to the Prompt it is grouped under. +- `order` stores sibling order under the same logical parent. +- Existing SQLite databases must be migrated with `prompts.parent_id` and `prompts.sort_order`. + +## Modified + +- The desktop list view is no longer allowed to render both the old table list and the new tree list at the same time. +- Deleting a parent Prompt must clear children `parentId` values instead of deleting child Prompts. +- Moving a Prompt must reject: +- self-parenting +- moving under one of its descendants +- missing parent Prompt IDs +- negative or non-finite order values + +## Relationship Model + +PromptHub should treat Prompt relationships as typed logical links. V1 ships only the tree projection: + +| Kind | Meaning | V1 status | +| --- | --- | --- | +| `grouped_under` | Logical containment or topic/task grouping | Implemented through `parentId/order` | +| `related_to` | Loose bidirectional association | Design target, not implemented in this PR | +| `variant_of` | Fork, specialization, or adapted version | Design target, not implemented in this PR | +| `depends_on` | Requires another Prompt as prerequisite context | Design target, not implemented in this PR | +| `next_step` | Workflow sequence from one Prompt to another | Design target, not implemented in this PR | + +## Scenarios + +- When a user drags Prompt B onto the middle area of Prompt A, Prompt B becomes grouped under Prompt A. +- When a user drags Prompt B before or after Prompt A, Prompt B moves to Prompt A's parent level and receives the corresponding sibling order. +- When a user presses `Tab` on a selected Prompt with a previous sibling, the selected Prompt becomes the previous sibling's last child. +- When a user presses `Shift+Tab` on a selected child Prompt, it moves to the level above its current parent. +- When a user deletes a parent Prompt, child Prompts remain in the database and become root-level grouped prompts. +- When a user attempts to create a cycle by dragging a parent under its descendant, the move is rejected and existing hierarchy remains unchanged. diff --git a/spec/changes/active/desktop-prompt-relationship-tree/tasks.md b/spec/changes/active/desktop-prompt-relationship-tree/tasks.md new file mode 100644 index 00000000..5f564ed1 --- /dev/null +++ b/spec/changes/active/desktop-prompt-relationship-tree/tasks.md @@ -0,0 +1,19 @@ +# Tasks + +- [x] Fetch and merge `jazzson51569/PromptHub feature/hierarchical-latest` into a clean branch while preserving contributor commit history. +- [x] Review contributor implementation and identify merge blockers. +- [x] Write DB regression tests for prompt hierarchy move safety and delete semantics. +- [x] Add existing-database migration coverage for `parent_id` and `sort_order`. +- [x] Change prompt hierarchy delete semantics from cascade delete to `SET NULL`. +- [x] Add old-database migration for hierarchy columns. +- [x] Harden `PromptDB.movePrompt` against invalid order, missing parent, self-parent, and descendant cycles. +- [x] Remove duplicate list view rendering in `MainContent`. +- [x] Harden `PromptListView` drag/drop and keyboard hierarchy handling. +- [x] Harden renderer IndexedDB fallback move behavior. +- [x] Add IPC input validation for prompt moves. +- [x] Run targeted DB and migration tests. +- [x] Run desktop typecheck. +- [x] Run desktop lint. +- [x] Run desktop build. +- [x] Run diff whitespace check. +- [ ] Commit and push PR branch.