import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { toast } from '@/components/ui'; import { Icon } from '@/components/icon/Icon'; import { requestFileAccess } from '@/lib/desktop'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { parsePlanMarkdown, resolveProjectContextId, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { cn } from '@/lib/utils'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useUIStore } from '@/stores/useUIStore'; /** * Saved plan markdown for the project. * * Plan mutations touch neither notes nor todos, so this section talks to the * store directly instead of routing writes through the container. */ export const PlansSection: React.FC<{ projectRef: ProjectRef; plans: ProjectPlanLink[]; /** Panel-wide filter, matched against plan titles. */ query: string; /** Hosts without a ContextPanel (mobile) render their own plan viewer. The plan carries its owner so the host viewer never guesses the project. */ onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void; pinnedPlanIds: ReadonlySet; onTogglePinned: (planId: string, pinned: boolean) => Promise; }> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => { const { t } = useI18n(); const fileInputRef = React.useRef(null); const [isImporting, setIsImporting] = React.useState(false); const [deletingPlanId, setDeletingPlanId] = React.useState(null); const createPlan = useProjectContextStore((state) => state.createPlan); const removePlan = useProjectContextStore((state) => state.deletePlan); const movePlan = useProjectContextStore((state) => state.movePlan); const [movingPlanId, setMovingPlanId] = React.useState(null); // A plan changes id when it moves between the two folders; the list reloads // from the returned context, so nothing here tracks the new id. const handleMovePlan = React.useCallback( async (plan: ProjectPlanLink) => { if (movingPlanId) return; const direction = plan.source === 'shared' ? 'unshare' : 'share'; setMovingPlanId(plan.id); try { const ok = await movePlan(projectRef, plan.id, direction); if (!ok) { const detail = useProjectContextStore.getState().getEntry(projectRef).error; toast.error(t('rightSidebar.contextNotesTodo.toast.movePlanFailed'), detail ? { description: detail } : undefined); } } finally { setMovingPlanId(null); } }, [movePlan, movingPlanId, projectRef, t], ); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); const handleDeletePlan = React.useCallback( async (planId: string) => { if (deletingPlanId) { return; } setDeletingPlanId(planId); try { const ok = await removePlan(projectRef, planId); if (!ok) { toast.error(t('rightSidebar.contextNotesTodo.toast.deletePlanFailed')); } } finally { setDeletingPlanId(null); } }, [deletingPlanId, projectRef, removePlan, t] ); // Imported files arrive as a whole markdown document; split it the same way // the server would so the stored plan keeps the author's heading. const importPlanFromText = React.useCallback( async (text: string, fallbackTitle: string) => { if (!text.trim()) { toast.error(t('rightSidebar.contextNotesTodo.toast.planFileEmpty')); return; } const parsed = parsePlanMarkdown(text, fallbackTitle || t('rightSidebar.contextNotesTodo.plan.defaultTitle')); const created = await createPlan(projectRef, { title: parsed.title, body: parsed.body }); if (!created) { toast.error(t('rightSidebar.contextNotesTodo.toast.importPlanFailed')); return; } toast.success(t('rightSidebar.contextNotesTodo.toast.planImported')); }, [createPlan, projectRef, t] ); const handleTriggerImport = React.useCallback(async () => { if (isImporting) { return; } const result = await requestFileAccess({ defaultPath: projectRef.path, filters: [ { name: 'Plan files', extensions: ['md', 'markdown', 'txt'] }, { name: 'All files', extensions: ['*'] }, ], }); if (result.success && result.path) { setIsImporting(true); try { const params = new URLSearchParams({ path: result.path, allowOutsideWorkspace: 'true' }); if (result.outsideFileGrant) { params.set('outsideFileGrant', result.outsideFileGrant); } const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' }); if (!response.ok) { toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed')); return; } const text = await response.text(); const fallbackTitle = result.path.split('/').pop()?.replace(/\.(md|markdown|txt)$/i, '').trim() || ''; await importPlanFromText(text, fallbackTitle); } catch (error) { const description = error instanceof Error ? error.message : undefined; toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); } finally { setIsImporting(false); } return; } if (result.error === 'Native file picker not available') { // Fall back to the HTML file input for web/non-desktop runtimes. fileInputRef.current?.click(); } }, [importPlanFromText, isImporting, projectRef.path, t]); const handleUploadFile = React.useCallback( async (file: File | null) => { if (!file) { return; } setIsImporting(true); try { const text = await file.text(); const fallbackTitle = file.name.replace(/\.(md|markdown|txt)$/i, '').trim(); await importPlanFromText(text, fallbackTitle); } catch (error) { const description = error instanceof Error ? error.message : undefined; toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'), description ? { description } : undefined); } finally { setIsImporting(false); } }, [importPlanFromText, t] ); const handleTogglePinned = React.useCallback( async (planId: string, pinned: boolean) => { const ok = await onTogglePinned(planId, pinned); if (!ok) { const detail = useProjectContextStore.getState().getEntry(projectRef).error; toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined); } }, [onTogglePinned, projectRef, t] ); const visiblePlans = React.useMemo( () => plans.filter((plan) => matchesRankQuery([plan.title], query)), [plans, query], ); const handleOpenPlan = React.useCallback( (plan: ProjectPlanLink) => { if (onOpenPlan) { onOpenPlan({ id: plan.id, title: plan.title, projectRef }); return; } const panelDirectory = currentDirectory?.trim() || projectRef.path.trim(); if (!panelDirectory) { return; } openContextPanelTab(panelDirectory, { mode: 'plan', projectPlanId: plan.id, projectPlanRef: projectRef, // Storage identity is derived from the project path, not the settings // id, so the tab identity uses the same derivation. Two projects // sharing a settings id but not a path must not merge plan tabs. dedupeKey: `plan:${resolveProjectContextId(projectRef)}:${plan.id}`, label: plan.title, }); }, [currentDirectory, onOpenPlan, openContextPanelTab, projectRef] ); return (
{ const file = event.target.files?.[0] ?? null; void handleUploadFile(file); event.currentTarget.value = ''; }} />
{visiblePlans.length === 0 ? (

{query.trim() ? t('rightSidebar.contextNotesTodo.search.noResults', { query: query.trim() }) : t('rightSidebar.contextNotesTodo.plans.empty')}

) : (
    {visiblePlans.map((plan) => (
  • ))}
)}
); };