diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c0ab992..476154e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -330,74 +330,6 @@ "organizationSettings": "Organization Settings", "billing": "Billing", "usage": "Usage", - "slackIntegration": "Slack Integration", - "byomKeys": "BYOM Keys", - "mcpConnectionsLabel": "MCP Connections", - "mcpConnections": { - "title": "External MCP connections", - "description": "Connect approved GitHub, Linear, and Jira MCP servers, validate their tool catalog, and request manual review for anything outside the allowlist.", - "loadError": "Could not load MCP connection settings.", - "connectError": "Could not validate the MCP connection.", - "connectSuccess": "{server} connection saved.", - "disconnectError": "Could not remove the MCP connection.", - "disconnectSuccess": "The MCP connection was removed and live deployments were suspended.", - "requestError": "Could not submit the MCP review request.", - "requestSuccess": "Review request submitted. Custom MCP servers stay pending until manual approval.", - "githubOAuthSuccess": "GitHub MCP connection completed successfully.", - "githubOAuthError": "GitHub MCP connection failed. Retry the OAuth flow.", - "connectGitHub": "Connect GitHub", - "connectManual": "Validate & save", - "labelPlaceholder": "Connection label (optional)", - "apiKeyPlaceholder": "Paste the provider API key", - "apiTokenPlaceholder": "Paste the provider API token", - "customRequestTitle": "Request a custom MCP server", - "customRequestDescription": "Servers outside the approved allowlist require manual review before they can be connected.", - "requestNamePlaceholder": "Server name", - "requestUrlPlaceholder": "https://mcp.example.com/rpc", - "requestNotesPlaceholder": "Why this MCP server is needed", - "requestReview": "Request review", - "labelValue": "Label · {label}", - "maskedSecretValue": "Saved secret · {secret}", - "toolCountValue": "Cached tools · {count}", - "validatedAtValue": "Last validated · {date}", - "status": { - "active": "Connected", - "error": "Connection error", - "pending_oauth": "OAuth pending", - "disconnected": "Not connected" - }, - "auth": { - "oauth": "OAuth", - "api_key": "API key", - "api_token": "API token" - } - }, - "byomKeys": { - "title": "BYOM Key Management", - "description": "Bring your own model key. Validate and save a provider API key for this project.", - "providerLabel": "Provider", - "apiKeyLabel": "API Key", - "apiKeyPlaceholder": "sk-... or provider token", - "apiKeyUpdatePlaceholder": "Enter new key (leave blank to keep existing)", - "savedKeyLabel": "Saved key", - "baseUrlLabel": "Base URL", - "baseUrlPlaceholder": "https://your-endpoint.example.com/v1", - "baseUrlHint": "Required for OpenAI-compatible providers and must include /v1.", - "baseUrlSavedLabel": "Saved endpoint", - "validateCta": "Validate", - "validating": "Validating...", - "saveCta": "Save Key", - "validationSuccess": "API key is valid and working.", - "validationError": "API key validation failed. Check the key and try again.", - "saveSuccess": "API key saved successfully.", - "deleteSuccess": "Saved BYOM key deleted successfully.", - "deleteSuccessWithRotation": "Saved BYOM key deleted. KMS rotation request recorded.", - "deleteConfirmTitle": "Delete API key?", - "deleteConfirmDesc": "This will remove the saved API key from this project. AI features that depend on it will stop working.", - "confirmDelete": "Delete Key", - "showKey": "Show", - "hideKey": "Hide" - }, "slackIntegration": { "title": "Slack integration & channel mapping", "description": "Connect Slack once, review workspace health, and map channels to the right Sprintable project without leaving Settings.", @@ -460,6 +392,72 @@ "remapSuccessTitle": "Slack channel remapped", "remapSuccessBody": "{channel} now points to {project}.", "unknownProject": "Unknown project" + }, + "byomKeys": { + "title": "BYOM Key Management", + "description": "Bring your own model key. Validate and save a provider API key for this project.", + "providerLabel": "Provider", + "apiKeyLabel": "API Key", + "apiKeyPlaceholder": "sk-... or provider token", + "apiKeyUpdatePlaceholder": "Enter new key (leave blank to keep existing)", + "savedKeyLabel": "Saved key", + "baseUrlLabel": "Base URL", + "baseUrlPlaceholder": "https://your-endpoint.example.com/v1", + "baseUrlHint": "Required for OpenAI-compatible providers and must include /v1.", + "baseUrlSavedLabel": "Saved endpoint", + "validateCta": "Validate", + "validating": "Validating...", + "saveCta": "Save Key", + "validationSuccess": "API key is valid and working.", + "validationError": "API key validation failed. Check the key and try again.", + "saveSuccess": "API key saved successfully.", + "deleteSuccess": "Saved BYOM key deleted successfully.", + "deleteSuccessWithRotation": "Saved BYOM key deleted. KMS rotation request recorded.", + "deleteConfirmTitle": "Delete API key?", + "deleteConfirmDesc": "This will remove the saved API key from this project. AI features that depend on it will stop working.", + "confirmDelete": "Delete Key", + "showKey": "Show", + "hideKey": "Hide" + }, + "mcpConnectionsLabel": "MCP Connections", + "mcpConnections": { + "title": "External MCP connections", + "description": "Connect approved GitHub, Linear, and Jira MCP servers, validate their tool catalog, and request manual review for anything outside the allowlist.", + "loadError": "Could not load MCP connection settings.", + "connectError": "Could not validate the MCP connection.", + "connectSuccess": "{server} connection saved.", + "disconnectError": "Could not remove the MCP connection.", + "disconnectSuccess": "The MCP connection was removed and live deployments were suspended.", + "requestError": "Could not submit the MCP review request.", + "requestSuccess": "Review request submitted. Custom MCP servers stay pending until manual approval.", + "githubOAuthSuccess": "GitHub MCP connection completed successfully.", + "githubOAuthError": "GitHub MCP connection failed. Retry the OAuth flow.", + "connectGitHub": "Connect GitHub", + "connectManual": "Validate & save", + "labelPlaceholder": "Connection label (optional)", + "apiKeyPlaceholder": "Paste the provider API key", + "apiTokenPlaceholder": "Paste the provider API token", + "customRequestTitle": "Request a custom MCP server", + "customRequestDescription": "Servers outside the approved allowlist require manual review before they can be connected.", + "requestNamePlaceholder": "Server name", + "requestUrlPlaceholder": "https://mcp.example.com/rpc", + "requestNotesPlaceholder": "Why this MCP server is needed", + "requestReview": "Request review", + "labelValue": "Label · {label}", + "maskedSecretValue": "Saved secret · {secret}", + "toolCountValue": "Cached tools · {count}", + "validatedAtValue": "Last validated · {date}", + "status": { + "active": "Connected", + "error": "Connection error", + "pending_oauth": "OAuth pending", + "disconnected": "Not connected" + }, + "auth": { + "oauth": "OAuth", + "api_key": "API key", + "api_token": "API token" + } } }, "landing": { @@ -839,7 +837,9 @@ "docTagsPlaceholder": "For example, docs, mobile, parity", "docTagsHint": "Separate tags with commas to show them as badges.", "codeCopy": "Copy code", - "codeCopied": "Copied" + "codeCopied": "Copied", + "moveCircularError": "Cannot move a document into its own subtree", + "movePermissionError": "You do not have permission to move documents here" }, "rewards": { "title": "Rewards", @@ -1723,4 +1723,4 @@ "policySummaryTitle": "Runtime prompt summary", "policySummaryBody": "This exact summary is injected into the agent system prompt." } -} +} \ No newline at end of file diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index bad84a4..e42a2bc 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -330,74 +330,6 @@ "organizationSettings": "조직 설정", "billing": "결제", "usage": "사용량", - "slackIntegration": "Slack 연동", - "byomKeys": "BYOM 키 관리", - "mcpConnectionsLabel": "MCP 연결", - "mcpConnections": { - "title": "외부 MCP 연결", - "description": "승인된 GitHub, Linear, Jira MCP 서버를 연결하고 tool catalog를 검증하며, allowlist 밖의 서버는 수동 심사 요청으로 올리는.", - "loadError": "MCP 연결 설정을 불러오지 못한.", - "connectError": "MCP 연결 검증에 실패한.", - "connectSuccess": "{server} 연결을 저장한.", - "disconnectError": "MCP 연결을 제거하지 못한.", - "disconnectSuccess": "MCP 연결을 제거했고 연결된 live deployment도 suspend 처리한.", - "requestError": "MCP 심사 요청을 제출하지 못한.", - "requestSuccess": "심사 요청을 등록한. allowlist 밖 커스텀 MCP는 수동 승인 전까지 pending 상태입니다.", - "githubOAuthSuccess": "GitHub MCP 연결을 완료한.", - "githubOAuthError": "GitHub MCP 연결에 실패한. OAuth를 다시 진행하기 바라는.", - "connectGitHub": "GitHub 연결", - "connectManual": "검증 후 저장", - "labelPlaceholder": "연결 라벨 (선택)", - "apiKeyPlaceholder": "제공자 API 키 입력", - "apiTokenPlaceholder": "제공자 API 토큰 입력", - "customRequestTitle": "커스텀 MCP 서버 심사 요청", - "customRequestDescription": "승인된 allowlist 밖의 MCP 서버는 연결 전에 반드시 수동 심사를 거치는.", - "requestNamePlaceholder": "서버 이름", - "requestUrlPlaceholder": "https://mcp.example.com/rpc", - "requestNotesPlaceholder": "이 MCP 서버가 필요한 이유", - "requestReview": "심사 요청", - "labelValue": "라벨 · {label}", - "maskedSecretValue": "저장된 secret · {secret}", - "toolCountValue": "캐시된 tool 수 · {count}", - "validatedAtValue": "마지막 검증 · {date}", - "status": { - "active": "연결됨", - "error": "연결 오류", - "pending_oauth": "OAuth 대기", - "disconnected": "미연결" - }, - "auth": { - "oauth": "OAuth", - "api_key": "API 키", - "api_token": "API 토큰" - } - }, - "byomKeys": { - "title": "BYOM 키 관리", - "description": "프로젝트 전용 AI 제공자 API 키를 검증하고 저장합니다.", - "providerLabel": "제공자", - "apiKeyLabel": "API 키", - "apiKeyPlaceholder": "sk-... 또는 제공자 토큰", - "apiKeyUpdatePlaceholder": "새 키 입력 (비워두면 기존 키 유지)", - "savedKeyLabel": "저장된 키", - "baseUrlLabel": "Base URL", - "baseUrlPlaceholder": "https://your-endpoint.example.com/v1", - "baseUrlHint": "OpenAI-compatible 제공자는 /v1 포함 endpoint가 필요합니다.", - "baseUrlSavedLabel": "저장된 endpoint", - "validateCta": "검증", - "validating": "검증 중...", - "saveCta": "키 저장", - "validationSuccess": "API 키가 유효합니다.", - "validationError": "API 키 검증에 실패했습니다. 키를 확인하고 다시 시도하세요.", - "saveSuccess": "API 키가 저장되었습니다.", - "deleteSuccess": "저장된 BYOM 키를 삭제한.", - "deleteSuccessWithRotation": "저장된 BYOM 키를 삭제했고 KMS rotation 요청도 기록한.", - "deleteConfirmTitle": "API 키를 삭제하시겠습니까?", - "deleteConfirmDesc": "이 프로젝트에서 저장된 API 키가 제거됩니다. 해당 키에 의존하는 AI 기능이 중단됩니다.", - "confirmDelete": "키 삭제", - "showKey": "보기", - "hideKey": "숨기기" - }, "slackIntegration": { "title": "Slack 연동 및 채널 매핑", "description": "Slack을 한 번 연결한 뒤 워크스페이스 상태를 확인하고, 채널을 올바른 Sprintable 프로젝트에 바로 매핑합니다.", @@ -460,6 +392,72 @@ "remapSuccessTitle": "Slack 채널 remap 완료", "remapSuccessBody": "{channel} 채널이 이제 {project}를 가리키는.", "unknownProject": "알 수 없는 프로젝트" + }, + "byomKeys": { + "title": "BYOM 키 관리", + "description": "프로젝트 전용 AI 제공자 API 키를 검증하고 저장합니다.", + "providerLabel": "제공자", + "apiKeyLabel": "API 키", + "apiKeyPlaceholder": "sk-... 또는 제공자 토큰", + "apiKeyUpdatePlaceholder": "새 키 입력 (비워두면 기존 키 유지)", + "savedKeyLabel": "저장된 키", + "baseUrlLabel": "Base URL", + "baseUrlPlaceholder": "https://your-endpoint.example.com/v1", + "baseUrlHint": "OpenAI-compatible 제공자는 /v1 포함 endpoint가 필요합니다.", + "baseUrlSavedLabel": "저장된 endpoint", + "validateCta": "검증", + "validating": "검증 중...", + "saveCta": "키 저장", + "validationSuccess": "API 키가 유효합니다.", + "validationError": "API 키 검증에 실패했습니다. 키를 확인하고 다시 시도하세요.", + "saveSuccess": "API 키가 저장되었습니다.", + "deleteSuccess": "저장된 BYOM 키를 삭제한.", + "deleteSuccessWithRotation": "저장된 BYOM 키를 삭제했고 KMS rotation 요청도 기록한.", + "deleteConfirmTitle": "API 키를 삭제하시겠습니까?", + "deleteConfirmDesc": "이 프로젝트에서 저장된 API 키가 제거됩니다. 해당 키에 의존하는 AI 기능이 중단됩니다.", + "confirmDelete": "키 삭제", + "showKey": "보기", + "hideKey": "숨기기" + }, + "mcpConnectionsLabel": "MCP 연결", + "mcpConnections": { + "title": "외부 MCP 연결", + "description": "승인된 GitHub, Linear, Jira MCP 서버를 연결하고 tool catalog를 검증하며, allowlist 밖의 서버는 수동 심사 요청으로 올리는.", + "loadError": "MCP 연결 설정을 불러오지 못한.", + "connectError": "MCP 연결 검증에 실패한.", + "connectSuccess": "{server} 연결을 저장한.", + "disconnectError": "MCP 연결을 제거하지 못한.", + "disconnectSuccess": "MCP 연결을 제거했고 연결된 live deployment도 suspend 처리한.", + "requestError": "MCP 심사 요청을 제출하지 못한.", + "requestSuccess": "심사 요청을 등록한. allowlist 밖 커스텀 MCP는 수동 승인 전까지 pending 상태입니다.", + "githubOAuthSuccess": "GitHub MCP 연결을 완료한.", + "githubOAuthError": "GitHub MCP 연결에 실패한. OAuth를 다시 진행하기 바라는.", + "connectGitHub": "GitHub 연결", + "connectManual": "검증 후 저장", + "labelPlaceholder": "연결 라벨 (선택)", + "apiKeyPlaceholder": "제공자 API 키 입력", + "apiTokenPlaceholder": "제공자 API 토큰 입력", + "customRequestTitle": "커스텀 MCP 서버 심사 요청", + "customRequestDescription": "승인된 allowlist 밖의 MCP 서버는 연결 전에 반드시 수동 심사를 거치는.", + "requestNamePlaceholder": "서버 이름", + "requestUrlPlaceholder": "https://mcp.example.com/rpc", + "requestNotesPlaceholder": "이 MCP 서버가 필요한 이유", + "requestReview": "심사 요청", + "labelValue": "라벨 · {label}", + "maskedSecretValue": "저장된 secret · {secret}", + "toolCountValue": "캐시된 tool 수 · {count}", + "validatedAtValue": "마지막 검증 · {date}", + "status": { + "active": "연결됨", + "error": "연결 오류", + "pending_oauth": "OAuth 대기", + "disconnected": "미연결" + }, + "auth": { + "oauth": "OAuth", + "api_key": "API 키", + "api_token": "API 토큰" + } } }, "landing": { @@ -839,7 +837,9 @@ "docTagsPlaceholder": "예: docs, mobile, parity", "docTagsHint": "쉼표로 구분하면 배지로 표시되어 주세요", "codeCopy": "코드 복사", - "codeCopied": "복사됨" + "codeCopied": "복사됨", + "moveCircularError": "문서를 자신의 하위로 이동할 수 없습니다", + "movePermissionError": "이 위치로 문서를 이동할 권한이 없습니다" }, "rewards": { "title": "리워드", @@ -1723,4 +1723,4 @@ "policySummaryTitle": "런타임 주입 요약", "policySummaryBody": "에이전트 시스템 프롬프트에 그대로 들어가는 정책 요약인." } -} +} \ No newline at end of file diff --git a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx index 3f936b9..cd17c35 100644 --- a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx +++ b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx @@ -8,6 +8,7 @@ import { DocEditor } from '@/components/docs/doc-editor'; import { useDocSync, type SaveStatus } from '@/components/docs/use-doc-sync'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { ToastContainer, useToast } from '@/components/ui/toast'; import { Plus, X, Trash2 } from 'lucide-react'; interface Doc { @@ -74,6 +75,7 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { const searchParams = useSearchParams(); const t = useTranslations('docs'); const tc = useTranslations('common'); + const { toasts, addToast, dismissToast } = useToast(); const [tree, setTree] = useState([]); const [selectedDoc, setSelectedDoc] = useState(null); @@ -167,6 +169,38 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { } }, [fetchTree]); + const handleMove = useCallback(async (docId: string, newParentId: string | null, newSortOrder: number) => { + // Optimistic update + setTree((prev) => + prev.map((doc) => + doc.id === docId ? { ...doc, parent_id: newParentId, sort_order: newSortOrder } : doc + ) + ); + + try { + const res = await fetch(`/api/docs/${docId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent_id: newParentId, sort_order: newSortOrder }), + }); + + if (!res.ok) { + await fetchTree(); + } + } catch (error) { + console.error('Failed to move doc:', error); + await fetchTree(); + } + }, [fetchTree]); + + const handleMoveDenied = useCallback((reason: 'circular' | 'no-permission') => { + if (reason === 'circular') { + addToast({ title: t('moveCircularError'), type: 'error' }); + } else { + addToast({ title: t('movePermissionError'), type: 'warning' }); + } + }, [addToast, t]); + const handleRename = useCallback(async (docId: string, newName: string) => { setTree((prev) => prev.map((doc) => (doc.id === docId ? { ...doc, title: newName } : doc)) @@ -352,6 +386,8 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { selectedSlug={selectedDoc?.slug || null} onSelect={handleSelectDoc} onReorder={handleReorder} + onMove={handleMove} + onMoveDenied={handleMoveDenied} onRename={handleRename} onDelete={handleDeleteDoc} onAddChild={handleAddChild} @@ -470,6 +506,7 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { )} + ); } diff --git a/apps/web/src/components/docs/doc-tree.test.ts b/apps/web/src/components/docs/doc-tree.test.ts new file mode 100644 index 0000000..2f568aa --- /dev/null +++ b/apps/web/src/components/docs/doc-tree.test.ts @@ -0,0 +1,124 @@ +/** + * Tests for S-DOCS2 cross-parent D&D helpers + * + * isDescendant: pure function — safe to unit-test directly. + * Drag guard logic mirrored by pure helpers below. + */ +import { describe, expect, it } from 'vitest'; +import { isDescendant } from './doc-tree'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function doc(id: string, parent_id: string | null, sort_order = 0) { + return { id, parent_id, title: id, slug: id, icon: null, sort_order }; +} + +// Tree: +// root-a +// child-a1 +// grandchild-a1a +// child-a2 +// root-b +const DOCS = [ + doc('root-a', null, 0), + doc('child-a1', 'root-a', 0), + doc('grandchild-a1a', 'child-a1', 0), + doc('child-a2', 'root-a', 1), + doc('root-b', null, 1), +]; + +// --------------------------------------------------------------------------- +// isDescendant tests +// --------------------------------------------------------------------------- + +describe('isDescendant', () => { + it('returns true for direct child', () => { + expect(isDescendant(DOCS, 'root-a', 'child-a1')).toBe(true); + }); + + it('returns true for grandchild (multi-level)', () => { + expect(isDescendant(DOCS, 'root-a', 'grandchild-a1a')).toBe(true); + }); + + it('returns false for sibling', () => { + expect(isDescendant(DOCS, 'child-a1', 'child-a2')).toBe(false); + }); + + it('returns false for ancestor (reverse direction)', () => { + // child-a1 is NOT a descendant of grandchild-a1a + expect(isDescendant(DOCS, 'child-a1', 'root-a')).toBe(false); + }); + + it('returns false for completely unrelated node', () => { + expect(isDescendant(DOCS, 'root-a', 'root-b')).toBe(false); + }); + + it('returns false when nodeId does not exist', () => { + expect(isDescendant(DOCS, 'root-a', 'nonexistent')).toBe(false); + }); + + it('returns false when ancestorId does not exist', () => { + expect(isDescendant(DOCS, 'nonexistent', 'child-a1')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Drag guard helpers — mirror handleDragEnd logic in DocTree +// --------------------------------------------------------------------------- + +type DragResult = + | { action: 'same-parent-reorder' } + | { action: 'cross-parent-move'; newParentId: string | null } + | { action: 'blocked-circular' } + | { action: 'blocked-no-permission' }; + +function simulateDrag( + docs: ReturnType[], + activeId: string, + overId: string, + hasOnMove: boolean, +): DragResult { + if (activeId === overId) throw new Error('same id — caller should skip'); + + const activeDoc = docs.find((d) => d.id === activeId)!; + const overDoc = docs.find((d) => d.id === overId)!; + + if (activeDoc.parent_id !== overDoc.parent_id) { + if (isDescendant(docs, activeDoc.id, overDoc.id)) return { action: 'blocked-circular' }; + if (!hasOnMove) return { action: 'blocked-no-permission' }; + return { action: 'cross-parent-move', newParentId: overDoc.id }; + } + + return { action: 'same-parent-reorder' }; +} + +describe('drag guard logic', () => { + it('same-parent drag → reorder', () => { + const result = simulateDrag(DOCS, 'child-a1', 'child-a2', true); + expect(result.action).toBe('same-parent-reorder'); + }); + + it('cross-parent drag with onMove → move to new parent', () => { + const result = simulateDrag(DOCS, 'child-a1', 'root-b', true); + expect(result).toEqual({ action: 'cross-parent-move', newParentId: 'root-b' }); + }); + + it('cross-parent drag without onMove → blocked (no-permission)', () => { + const result = simulateDrag(DOCS, 'child-a1', 'root-b', false); + expect(result.action).toBe('blocked-no-permission'); + }); + + it('circular drag (drop into own subtree) → blocked', () => { + // Dragging root-a into grandchild-a1a (a descendant) + const result = simulateDrag(DOCS, 'root-a', 'grandchild-a1a', true); + expect(result.action).toBe('blocked-circular'); + }); + + it('dragging leaf into sibling subtree root → cross-parent move', () => { + // child-a2 into root-b (different parent); root-b becomes the new parent + const result = simulateDrag(DOCS, 'child-a2', 'root-b', true); + expect(result).toEqual({ action: 'cross-parent-move', newParentId: 'root-b' }); + }); +}); diff --git a/apps/web/src/components/docs/doc-tree.tsx b/apps/web/src/components/docs/doc-tree.tsx index cc6cf90..5d1d6d2 100644 --- a/apps/web/src/components/docs/doc-tree.tsx +++ b/apps/web/src/components/docs/doc-tree.tsx @@ -17,11 +17,31 @@ interface Doc { is_folder?: boolean; } +/** + * Returns true if `nodeId` is a descendant of `ancestorId` in the doc tree. + * Used to prevent circular moves (dropping a node into its own subtree). + */ +export function isDescendant(docs: Doc[], ancestorId: string, nodeId: string): boolean { + const visited = new Set(); + let currentId: string | null = nodeId; + while (currentId !== null) { + if (visited.has(currentId)) break; // cycle safety guard + visited.add(currentId); + const node = docs.find((d) => d.id === currentId); + if (!node) break; + if (node.parent_id === ancestorId) return true; + currentId = node.parent_id; + } + return false; +} + interface DocTreeProps { docs: Doc[]; selectedSlug: string | null; onSelect: (slug: string) => void; onReorder?: (docId: string, newSortOrder: number, siblings: Doc[]) => Promise; + onMove?: (docId: string, newParentId: string | null, newSortOrder: number) => Promise; + onMoveDenied?: (reason: 'circular' | 'no-permission') => void; onRename?: (docId: string, newTitle: string) => Promise; onDelete?: (docId: string) => Promise; onAddChild?: (parentId: string) => Promise; @@ -197,32 +217,48 @@ function TreeNode({ ); } -export function DocTree({ docs, selectedSlug, onSelect, onReorder, onRename, onDelete, onAddChild, emptyFolderLabel }: DocTreeProps) { +export function DocTree({ docs, selectedSlug, onSelect, onReorder, onMove, onMoveDenied, onRename, onDelete, onAddChild, emptyFolderLabel }: DocTreeProps) { const rootDocs = docs.filter((entry) => !entry.parent_id).sort((a, b) => a.sort_order - b.sort_order); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const handleDragEnd = useCallback(async (event: DragEndEvent) => { const { active, over } = event; - if (!over || active.id === over.id || !onReorder) return; + if (!over || active.id === over.id) return; const activeDoc = docs.find((d) => d.id === active.id); const overDoc = docs.find((d) => d.id === over.id); if (!activeDoc || !overDoc) return; - // Only allow reordering within the same parent - if (activeDoc.parent_id !== overDoc.parent_id) return; + if (activeDoc.parent_id !== overDoc.parent_id) { + // Cross-parent move: prevent circular (dragging a node into its own subtree) + if (isDescendant(docs, activeDoc.id, overDoc.id)) { + onMoveDenied?.('circular'); + return; + } + + // Require onMove to be wired for cross-parent moves + if (!onMove) { + onMoveDenied?.('no-permission'); + return; + } + + // Place activeDoc as a child of overDoc (overDoc becomes the new parent) + const newParentId = overDoc.id; + await onMove(activeDoc.id, newParentId, overDoc.sort_order); + return; + } + // Same-parent reorder (existing behaviour) + if (!onReorder) return; const siblings = docs.filter((d) => d.parent_id === activeDoc.parent_id).sort((a, b) => a.sort_order - b.sort_order); const oldIndex = siblings.findIndex((d) => d.id === active.id); const newIndex = siblings.findIndex((d) => d.id === over.id); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; - // Calculate new sort_order const newSortOrder = siblings[newIndex]!.sort_order; - await onReorder(activeDoc.id, newSortOrder, siblings); - }, [docs, onReorder]); + }, [docs, onReorder, onMove, onMoveDenied]); return (