diff --git a/apps/desktop/src/main/ipc/prompt.ipc.ts b/apps/desktop/src/main/ipc/prompt.ipc.ts
index 99193119..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) => {
@@ -249,4 +268,11 @@ 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) => {
+ assertPromptMoveInput(promptId, newParentId, newOrder);
+ 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..43b6215f 100644
--- a/apps/desktop/src/renderer/components/layout/MainContent.tsx
+++ b/apps/desktop/src/renderer/components/layout/MainContent.tsx
@@ -21,9 +21,9 @@ 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 })));
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 +367,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(
@@ -1928,23 +1929,16 @@ function PromptSkillMainContent() {
) : (
<>
- {/* List view mode */}
- {/* 列表视图模式 */}
+ {/* Gallery view */}
+ {/* Gallery 视图 */}
-
-
- {/* Top: sort + view switch */}
- {/* 顶部:排序 + 视图切换 */}
-
- {/* Table view */}
- {/* 表格视图 */}
-
+ {viewMode === 'gallery' && (
- selectPrompt(id)}
onToggleFavorite={toggleFavorite}
@@ -1954,25 +1948,21 @@ function PromptSkillMainContent() {
onAiTest={handleAiTestFromTable}
onVersionHistory={handleVersionHistory}
onViewDetail={handleViewDetail}
- aiResults={aiResponseCache}
- onBatchFavorite={handleBatchFavorite}
- onBatchMove={handleBatchMove}
- onBatchDelete={handleBatchDelete}
onContextMenu={handleContextMenu}
/>
-
+ )}
- {/* Gallery view */}
- {/* Gallery 视图 */}
+ {/* Kanban view */}
+ {/* 看板视图 */}
- {viewMode === 'gallery' && (
+ {viewMode === 'kanban' && (
- selectPrompt(id)}
@@ -1989,26 +1979,23 @@ function PromptSkillMainContent() {
)}
- {/* Kanban view */}
- {/* 看板视图 */}
+ {/* List view mode: hierarchical list with drag-and-drop */}
+ {/* 列表视图模式:分层列表支持拖拽 */}
- {viewMode === 'kanban' && (
+ {viewMode === 'list' && (
- selectPrompt(id)}
onToggleFavorite={toggleFavorite}
onCopy={handleCopyPrompt}
- onEdit={(prompt) => setEditingPrompt(prompt)}
- onDelete={handleDeletePrompt}
- onAiTest={handleAiTestFromTable}
- onVersionHistory={handleVersionHistory}
- onViewDetail={handleViewDetail}
onContextMenu={handleContextMenu}
+ onMovePrompt={movePrompt}
/>
)}
diff --git a/apps/desktop/src/renderer/components/prompt/PromptListView.tsx b/apps/desktop/src/renderer/components/prompt/PromptListView.tsx
index 72142d22..cdb151e3 100644
--- a/apps/desktop/src/renderer/components/prompt/PromptListView.tsx
+++ b/apps/desktop/src/renderer/components/prompt/PromptListView.tsx
@@ -1,28 +1,204 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type {
+ DragEvent as ReactDragEvent,
+ MouseEvent as ReactMouseEvent,
+} from 'react';
import { useTranslation } from 'react-i18next';
-import { StarIcon, CopyIcon, ImageIcon } from 'lucide-react';
+import {
+ ChevronDownIcon,
+ ChevronRightIcon,
+ CopyIcon,
+ GripVerticalIcon,
+ ImageIcon,
+ StarIcon,
+} from 'lucide-react';
import type { Prompt } from '@prompthub/shared/types';
+type DropPosition = 'before' | 'after' | 'inside';
+
interface PromptListViewProps {
prompts: Prompt[];
selectedId: string | null;
+ selectedIds: string[];
onSelect: (id: string) => void;
onToggleFavorite: (id: string) => void;
onCopy: (prompt: Prompt) => void;
- onContextMenu: (e: React.MouseEvent, prompt: Prompt) => 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,
+ selectedIds,
onSelect,
onToggleFavorite,
onCopy,
onContextMenu,
+ onMovePrompt,
}: PromptListViewProps) {
const { t } = useTranslation();
+ const [draggingId, setDraggingId] = useState(null);
+ const [dropTargetId, setDropTargetId] = useState(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 = (event: KeyboardEvent) => {
+ if (!selectedId || draggingId) return;
+
+ const selectedPrompt = promptById.get(selectedId);
+ if (!selectedPrompt) return;
+ if (event.key !== 'Tab' || event.ctrlKey || event.metaKey) return;
+
+ 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);
+ }, [
+ draggingId,
+ getChildren,
+ getVisibleParentId,
+ onMovePrompt,
+ promptById,
+ selectedId,
+ ]);
- // Format date
- // 格式化日期
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
@@ -30,106 +206,370 @@ 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',
+ });
};
- return (
-
- {prompts.map((prompt) => (
-
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
- ? 'bg-primary/10 border-l-2 border-l-primary'
- : 'hover:bg-accent/50'
- }
- `}
- >
- {/* Title and description */}
- {/* 标题和描述 */}
-
-
-
- {prompt.title}
-
- {prompt.isFavorite && (
-
+ const toggleExpand = useCallback((promptId: string) => {
+ setExpandedIds((current) => {
+ const next = new Set(current);
+ if (next.has(promptId)) {
+ next.delete(promptId);
+ } else {
+ next.add(promptId);
+ }
+ return next;
+ });
+ }, []);
+
+ const handleDragStart = useCallback(
+ (event: ReactDragEvent, promptId: string) => {
+ setDraggingId(promptId);
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', promptId);
+ },
+ [],
+ );
+
+ const handleDragEnd = useCallback(() => {
+ setDraggingId(null);
+ setDropTargetId(null);
+ setDropPosition(null);
+ }, []);
+
+ const updateDropTarget = useCallback(
+ (event: ReactDragEvent
, targetPrompt: Prompt) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+
+ 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;
+ }
+
+ setDropTargetId(null);
+ setDropPosition(null);
+ }, []);
+
+ 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;
+ }
+
+ 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.description && (
-
- {prompt.description}
-
- )}
- {prompt.images && prompt.images.length > 0 && (
-
-
-
{prompt.images.length}
+
+
+
+
+ {prompt.title}
+
+ {prompt.isFavorite && (
+
+ )}
- )}
-
+ {prompt.description && (
+
+ {prompt.description}
+
+ )}
+ {prompt.images && prompt.images.length > 0 && (
+
+
+
+ {prompt.images.length}
+
+
+ )}
+
- {/* Usage count */}
- {/* 使用次数 */}
-
-
- {prompt.usageCount || 0}
-
-
+
+
+ {prompt.usageCount || 0}
+
+
- {/* Update time */}
- {/* 更新时间 */}
-
-
- {formatDate(prompt.updatedAt)}
-
-
+
+
+ {formatDate(prompt.updatedAt)}
+
+
- {/* Action buttons */}
- {/* 操作按钮 */}
-
-
-
+
+ {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,
+ ],
+ );
+
+ const rootNodes = useMemo(() => {
+ const attachedIds = new Set
();
+
+ const collect = (prompt: Prompt, ancestors: Set) => {
+ if (ancestors.has(prompt.id)) {
+ return;
+ }
+
+ attachedIds.add(prompt.id);
+ const nextAncestors = new Set(ancestors).add(prompt.id);
+ for (const child of getChildren(prompt.id)) {
+ collect(child, nextAncestors);
+ }
+ };
+
+ 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, new Set()))}
);
}
diff --git a/apps/desktop/src/renderer/services/database.ts b/apps/desktop/src/renderer/services/database.ts
index 36d35d39..64fbe750 100644
--- a/apps/desktop/src/renderer/services/database.ts
+++ b/apps/desktop/src/renderer/services/database.ts
@@ -356,6 +356,146 @@ export async function movePrompts(
}
}
+export async function movePrompt(
+ promptId: string,
+ 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) => {
+ const transaction = database.transaction(STORES.PROMPTS, "readwrite");
+ const store = transaction.objectStore(STORES.PROMPTS);
+ const getAllRequest = store.getAll();
+
+ 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);
+ }
+ };
+ 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 操作 ====================
// ==================== 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/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 5b253d53..938f4f84 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,150 @@ 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 {
+ 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(() => {
+ const current = this.db
+ .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 targetParentId = newParentId ?? null;
+
+ if (oldParentId !== targetParentId) {
+ this.rewritePromptSiblingOrder(
+ oldParentId,
+ this.getPromptSiblingIds(oldParentId).filter((id) => id !== 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
+ * 获取提示词的子节点
+ */
+ 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 +849,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..70f3a36f 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 SET NULL
);
-- 版本表
@@ -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;
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.