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, 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. */ onOpenPlan?: (plan: { id: string; title: string }) => 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 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(() => { const needle = query.trim().toLowerCase(); if (!needle) return plans; return plans.filter((plan) => plan.title.toLowerCase().includes(needle)); }, [plans, query]); const handleOpenPlan = React.useCallback( (plan: ProjectPlanLink) => { if (onOpenPlan) { onOpenPlan({ id: plan.id, title: plan.title }); return; } const panelDirectory = currentDirectory?.trim() || projectRef.path.trim(); if (!panelDirectory) { return; } openContextPanelTab(panelDirectory, { mode: 'plan', projectPlanId: plan.id, dedupeKey: `plan:${plan.id}`, label: plan.title, }); }, [currentDirectory, onOpenPlan, openContextPanelTab, projectRef.path] ); 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) => (
  • ))}
)}
); };