Files
openchamber/packages/ui/src/components/views/PlanView.tsx
T

827 lines
31 KiB
TypeScript
Raw Normal View History

import React from 'react';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments';
2026-02-08 10:42:50 -03:00
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
import { RiCheckLine, RiClipboardLine, RiCodeAiLine, RiLoopRightAiLine } from '@remixicon/react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessions } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { EditorView } from '@codemirror/view';
import { copyTextToClipboard } from '@/lib/clipboard';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { useI18n } from '@/lib/i18n';
type PlanViewProps = {
targetPath?: string | null;
};
type PlanSendAction = 'improve' | 'implement';
type PlanSendTarget = 'session' | 'worktree';
type PendingPlanSend = {
action: PlanSendAction;
target: PlanSendTarget;
};
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
const joinPath = (base: string, segment: string): string => {
const normalizedBase = normalize(base);
const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
if (!normalizedBase || normalizedBase === '/') {
return `/${cleanSegment}`;
}
return `${normalizedBase}/${cleanSegment}`;
};
const buildRepoPlanPath = (directory: string, created: number, slug: string): string => {
return joinPath(joinPath(joinPath(directory, '.opencode'), 'plans'), `${created}-${slug}.md`);
};
const buildHomePlanPath = (created: number, slug: string): string => {
return `~/.opencode/plans/${created}-${slug}.md`;
};
const resolveTilde = (path: string, homeDir: string | null): string => {
const trimmed = path.trim();
if (!trimmed.startsWith('~')) return trimmed;
if (trimmed === '~') return homeDir || trimmed;
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
return homeDir ? `${homeDir}${trimmed.slice(1)}` : trimmed;
}
return trimmed;
};
const toDisplayPath = (resolvedPath: string, options: { currentDirectory: string; homeDirectory: string }): string => {
const current = normalize(options.currentDirectory);
const home = normalize(options.homeDirectory);
const normalized = normalize(resolvedPath);
if (current && normalized.startsWith(current + '/')) {
return normalized.slice(current.length + 1);
}
if (home && normalized === home) {
return '~';
}
if (home && normalized.startsWith(home + '/')) {
return `~${normalized.slice(home.length)}`;
}
return normalized;
};
const resolveProjectRefForDirectory = (
directory: string,
projects: Array<{ id: string; path: string }>,
activeProjectId: string | null,
): { id: string; path: string } | null => {
const normalized = normalize(directory.trim());
if (!normalized) {
return null;
}
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
if (activeProject?.path) {
const activePath = normalize(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
}
}
const match = projects
.filter((project) => {
const projectPath = normalize(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
})
.sort((left, right) => normalize(right.path).length - normalize(left.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
type SelectedLineRange = {
start: number;
end: number;
};
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
const sendMessage = useSessionUIStore((state) => state.sendMessage);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const sessions = useSessions();
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const gitDirectories = useGitStore((state) => state.directories);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const runtimeApis = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const { currentTheme } = useThemeSystem();
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const session = React.useMemo(() => {
if (!currentSessionId) return null;
return sessions.find((s) => s.id === currentSessionId) ?? null;
}, [currentSessionId, sessions]);
const sessionDirectory = React.useMemo(() => {
const raw = typeof session?.directory === 'string' ? session.directory : '';
return normalize(raw || '');
}, [session?.directory]);
const projectDirectory = React.useMemo(
() => normalize(effectiveDirectory || sessionDirectory),
[effectiveDirectory, sessionDirectory],
);
const currentProjectRef = React.useMemo(
() => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId),
[activeProjectId, projectDirectory, projects],
);
const canCreateWorktree = React.useMemo(
() => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false),
[currentProjectRef, gitDirectories],
);
const [pendingPlanSend, setPendingPlanSend] = React.useState<PendingPlanSend | null>(null);
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
const [resolvedPath, setResolvedPath] = React.useState<string | null>(null);
const displayPath = React.useMemo(() => {
if (!resolvedPath || !sessionDirectory || !homeDirectory) {
return resolvedPath;
}
return toDisplayPath(resolvedPath, { currentDirectory: sessionDirectory, homeDirectory });
}, [resolvedPath, sessionDirectory, homeDirectory]);
const [content, setContent] = React.useState<string>('');
const [saveError, setSaveError] = React.useState<string | null>(null);
const planFileLabel = React.useMemo(() => {
return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName');
}, [displayPath, t]);
const parsedTitle = React.useMemo(() => {
if (!content.trim()) {
return t('planView.title.default');
}
return parseProjectPlanMarkdown(content).title || t('planView.title.default');
}, [content, t]);
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || t('planView.title.default'), [parsedTitle, t]);
const [loading, setLoading] = React.useState(false);
const [copiedContent, setCopiedContent] = React.useState(false);
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
const copiedContentTimeoutRef = React.useRef<number | null>(null);
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const editorViewRef = React.useRef<EditorView | null>(null);
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
React.useEffect(() => {
try {
const stored = localStorage.getItem(MD_VIEWER_MODE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored) as unknown;
if (parsed === 'preview' || parsed === 'edit') {
setMdViewMode(parsed);
}
} catch {
// ignore
}
}, []);
const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
setMdViewMode(mode);
try {
localStorage.setItem(MD_VIEWER_MODE_KEY, JSON.stringify(mode));
} catch {
// ignore
}
}, []);
const isSelectingRef = React.useRef(false);
const selectionStartRef = React.useRef<number | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
React.useEffect(() => {
const handleGlobalMouseUp = () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, []);
const extractSelectedCode = React.useCallback((text: string, range: SelectedLineRange): string => {
const lines = text.split('\n');
const startLine = Math.max(1, range.start);
const endLine = Math.min(lines.length, range.end);
if (startLine > endLine) return '';
return lines.slice(startLine - 1, endLine).join('\n');
}, []);
const commentController = useInlineCommentController<SelectedLineRange>({
source: 'plan',
fileLabel: planFileLabel,
language: resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown',
getCodeForRange: (range) => extractSelectedCode(content, normalizeLineRange(range)),
toStoreRange: (range) => ({ startLine: range.start, endLine: range.end }),
fromDraftRange: (draft) => ({ start: draft.startLine, end: draft.endLine }),
});
const {
drafts: planFileDrafts,
commentText,
editingDraftId,
setSelection: setCommentSelection,
saveComment,
cancel,
reset,
startEdit,
deleteDraft,
} = commentController;
React.useEffect(() => {
setLineSelection(null);
reset();
}, [content, reset]);
React.useEffect(() => {
setCommentSelection(lineSelection);
}, [lineSelection, setCommentSelection]);
const handleCancelComment = React.useCallback(() => {
setLineSelection(null);
cancel();
}, [cancel]);
const handleSaveComment = React.useCallback((textToSave: string, rangeOverride?: { start: number; end: number }) => {
if (rangeOverride) {
setLineSelection(rangeOverride);
}
saveComment(textToSave, rangeOverride ?? lineSelection ?? undefined);
setLineSelection(null);
}, [lineSelection, saveComment]);
React.useEffect(() => {
if (!lineSelection) return;
2026-02-08 10:42:50 -03:00
if (isMobile && !editingDraftId) {
// Input handles mobile scroll/focus behavior.
2026-02-08 10:42:50 -03:00
}
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
2026-02-08 10:42:50 -03:00
if (
target.closest('[data-comment-card="true"]') ||
target.closest('[data-comment-input="true"]') ||
target.closest('.oc-block-widget')
) {
return;
}
if (target.closest('.cm-gutterElement')) return;
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
setLineSelection(null);
cancel();
};
const timeoutId = window.setTimeout(() => {
document.addEventListener('click', handleClickOutside);
}, 100);
return () => {
window.clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [cancel, editingDraftId, isMobile, lineSelection]);
2026-02-08 10:42:50 -03:00
const editorExtensions = React.useMemo(() => {
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
const language = languageByExtension(resolvedPath || 'plan.md');
if (language) {
extensions.push(language);
}
extensions.push(EditorView.lineWrapping);
return extensions;
}, [currentTheme, resolvedPath]);
React.useEffect(() => {
// Saved project plans opened via context panel should work even when session plan mode is off.
if (!planModeEnabled && !targetPath) {
setResolvedPath(null);
setContent('');
setLoading(false);
return;
}
let cancelled = false;
const readText = async (path: string): Promise<string> => {
if (runtimeApis.files?.readFile) {
const result = await runtimeApis.files.readFile(path);
return result?.content ?? '';
}
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
if (!response.ok) {
throw new Error(`Failed to read plan file (${response.status})`);
}
return response.text();
};
const run = async () => {
setResolvedPath(null);
setContent('');
setSaveError(null);
if (targetPath) {
setLoading(true);
try {
const text = await readText(targetPath);
if (cancelled) return;
setResolvedPath(targetPath);
setContent(text);
} catch {
if (cancelled) return;
setResolvedPath(null);
setContent('');
} finally {
if (!cancelled) setLoading(false);
}
return;
}
if (!session?.slug || !session?.time?.created || !sessionDirectory) {
setResolvedPath(null);
setContent('');
return;
}
setLoading(true);
try {
const repoPath = buildRepoPlanPath(sessionDirectory, session.time.created, session.slug);
const homePath = resolveTilde(buildHomePlanPath(session.time.created, session.slug), homeDirectory || null);
let resolved: string | null = null;
let text: string | null = null;
try {
text = await readText(repoPath);
resolved = repoPath;
} catch {
// ignore
}
if (!resolved) {
try {
text = await readText(homePath);
resolved = homePath;
} catch {
// ignore
}
}
if (cancelled) return;
if (!resolved || text === null) {
setResolvedPath(null);
setContent('');
return;
}
setResolvedPath(resolved);
setContent(text);
} catch {
if (cancelled) return;
setResolvedPath(null);
setContent('');
} finally {
if (!cancelled) setLoading(false);
}
};
void run();
return () => {
cancelled = true;
};
}, [homeDirectory, planModeEnabled, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, targetPath]);
React.useEffect(() => {
if (!resolvedPath) {
setSaveError(null);
return;
}
const controller = window.setTimeout(async () => {
setSaveError(null);
try {
if (runtimeApis.files?.writeFile) {
const result = await runtimeApis.files.writeFile(resolvedPath, content);
if (!result?.success) {
throw new Error(t('planView.error.writeFailed'));
}
} else {
const response = await fetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: resolvedPath, content }),
});
if (!response.ok) {
throw new Error(t('planView.error.writePlanFileFailed', { status: response.status }));
}
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : t('planView.error.saveFailed'));
}
}, 350);
return () => {
window.clearTimeout(controller);
};
}, [content, resolvedPath, runtimeApis.files, t]);
React.useEffect(() => {
return () => {
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
};
}, []);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
const handleConfirmPlanSend = React.useCallback(
async (execution: TodoSendExecution) => {
if (!currentProjectRef || !pendingPlanSend) {
return;
}
const visiblePrompt = await renderMagicPrompt(
pendingPlanSend.action === 'improve' ? 'plan.improve.visible' : 'plan.implement.visible',
{
plan_title: sendPromptTitle,
},
);
const instructionsText = await renderMagicPrompt(
pendingPlanSend.action === 'improve' ? 'plan.improve.instructions' : 'plan.implement.instructions',
{
plan_title: sendPromptTitle,
plan_path: resolvedPath ?? '',
},
);
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
setIsPlanSendSubmitting(true);
try {
routeToChat();
let sessionId: string | null = null;
let directoryHint: string | null = currentProjectRef.path;
if (pendingPlanSend.target === 'worktree') {
if (!canCreateWorktree) {
return;
}
const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName());
if (!created?.id) {
return;
}
sessionId = created.id;
directoryHint = null;
} else {
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
if (!sessionResult?.id) {
return;
}
sessionId = sessionResult.id;
directoryHint = sessionResult.directory ?? currentProjectRef.path;
initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []);
}
if (!sessionId) {
return;
}
const selectionState = useSelectionStore.getState();
selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID);
if (execution.agent.trim()) {
selectionState.saveSessionAgentSelection(sessionId, execution.agent);
selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID);
selectionState.saveAgentModelVariantForSession(
sessionId,
execution.agent,
execution.providerID,
execution.modelID,
execution.variant || undefined,
);
}
setCurrentSession(sessionId, directoryHint);
await sendMessage(
visiblePrompt,
execution.providerID,
execution.modelID,
execution.agent.trim() || undefined,
undefined,
undefined,
syntheticParts,
execution.variant || undefined,
);
setPendingPlanSend(null);
} finally {
setIsPlanSendSubmitting(false);
}
},
[canCreateWorktree, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
);
const blockWidgets = React.useMemo(() => {
return buildCodeMirrorCommentWidgets({
drafts: planFileDrafts,
editingDraftId,
commentText,
selection: lineSelection,
isDragging,
fileLabel: planFileLabel,
newWidgetId: 'plan-new-comment-input',
mapDraftToRange: (draft) => ({ start: draft.startLine, end: draft.endLine }),
onSave: handleSaveComment,
onCancel: handleCancelComment,
onEdit: (draft) => {
startEdit(draft);
setLineSelection({ start: draft.startLine, end: draft.endLine });
},
onDelete: deleteDraft,
});
}, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, startEdit]);
return (
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
<div className="min-w-0 flex-1">
<div className="typography-ui-label font-medium truncate">{parsedTitle}</div>
{saveError ? (
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
{t('planView.error.saveFailed')}
</div>
) : null}
</div>
{resolvedPath ? (
<div className="flex items-center gap-1">
<DropdownMenu>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
aria-label={t('planView.actions.improvePlanAria')}
disabled={!content.trim()}
>
<RiLoopRightAiLine className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('planView.actions.improve')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
{t('planView.actions.sendToNewSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'improve', target: 'worktree' })}
disabled={!canCreateWorktree}
>
{t('planView.actions.sendToNewWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
aria-label={t('planView.actions.implementPlanAria')}
disabled={!content.trim()}
>
<RiCodeAiLine className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{t('planView.actions.implement')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
{t('planView.actions.sendToNewSession')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'implement', target: 'worktree' })}
disabled={!canCreateWorktree}
>
{t('planView.actions.sendToNewWorktreeSession')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PreviewToggleButton
currentMode={mdViewMode}
onToggle={() => saveMdViewMode(mdViewMode === 'preview' ? 'edit' : 'preview')}
/>
<Button
variant="ghost"
size="sm"
onClick={async () => {
const result = await copyTextToClipboard(content);
if (result.ok) {
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} else {
// ignored
}
}}
className="h-5 w-5 p-0"
title={t('planView.actions.copyPlanContents')}
aria-label={t('planView.actions.copyPlanContents')}
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
</div>
) : null}
</div>
<TodoSendDialog
open={pendingPlanSend !== null}
onOpenChange={(open) => {
if (!open && !isPlanSendSubmitting) {
setPendingPlanSend(null);
}
}}
target={pendingPlanSend?.target ?? 'session'}
projectDirectory={currentProjectRef?.path ?? null}
submitting={isPlanSendSubmitting}
onConfirm={handleConfirmPlanSend}
/>
<div className="flex-1 min-h-0 min-w-0 relative">
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{loading ? (
<div className="p-3 typography-ui text-muted-foreground">{t('planView.state.loading')}</div>
) : (
<div className="relative h-full">
<div className="h-full">
{mdViewMode === 'preview' ? (
<div className="h-full overflow-auto p-3">
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('planView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('planView.error.switchToEditMode')}
</div>
</div>
}
>
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
</ErrorBoundary>
</div>
) : (
<div className="relative h-full" ref={editorWrapperRef}>
<CodeMirrorEditor
value={content}
onChange={setContent}
readOnly={false}
className="h-full"
extensions={editorExtensions}
onViewReady={(view) => { editorViewRef.current = view; }}
onViewDestroy={() => { editorViewRef.current = null; }}
blockWidgets={blockWidgets}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view, line, event) => {
if (!(event instanceof MouseEvent)) return false;
if (event.button !== 0) return false;
event.preventDefault();
const lineNumber = view.state.doc.lineAt(line.from).number;
if (isMobile && lineSelection && !event.shiftKey) {
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
setLineSelection({ start, end });
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return true;
}
isSelectingRef.current = true;
selectionStartRef.current = lineNumber;
setIsDragging(true);
if (lineSelection && event.shiftKey) {
const start = Math.min(lineSelection.start, lineNumber);
const end = Math.max(lineSelection.end, lineNumber);
setLineSelection({ start, end });
} else {
setLineSelection({ start: lineNumber, end: lineNumber });
}
return true;
},
mouseover: (view, line, event) => {
if (!(event instanceof MouseEvent)) return false;
if (event.buttons !== 1) return false;
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
const lineNumber = view.state.doc.lineAt(line.from).number;
const start = Math.min(selectionStartRef.current, lineNumber);
const end = Math.max(selectionStartRef.current, lineNumber);
setLineSelection({ start, end });
setIsDragging(true);
return false;
},
mouseup: () => {
isSelectingRef.current = false;
selectionStartRef.current = null;
setIsDragging(false);
return false;
},
},
}}
/>
</div>
)}
</div>
</div>
)}
</ScrollableOverlay>
</div>
</div>
);
};