2026-01-25 15:52:02 +02:00
|
|
|
import React from 'react';
|
2026-02-08 10:42:50 -03:00
|
|
|
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
|
|
|
|
|
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
|
2026-01-27 12:33:12 +02:00
|
|
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
|
|
|
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
|
|
|
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
2026-01-25 15:52:02 +02:00
|
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-02-08 10:42:50 -03:00
|
|
|
|
2026-01-25 15:52:02 +02: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';
|
2026-02-08 10:42:50 -03:00
|
|
|
import { RiCheckLine, RiClipboardLine, RiFileCopy2Line } from '@remixicon/react';
|
2026-01-25 15:52:02 +02:00
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
|
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
|
|
|
import { EditorView } from '@codemirror/view';
|
2026-02-03 15:05:01 -03:00
|
|
|
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
|
|
|
|
import { toast } from '@/components/ui';
|
2026-01-25 15:52:02 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type SelectedLineRange = {
|
|
|
|
|
start: number;
|
|
|
|
|
end: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const PlanView: React.FC = () => {
|
|
|
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
|
|
|
const sessions = useSessionStore((state) => state.sessions);
|
|
|
|
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
|
|
|
|
const runtimeApis = useRuntimeAPIs();
|
2026-02-03 15:05:01 -03:00
|
|
|
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
|
2026-02-08 10:42:50 -03:00
|
|
|
useUIStore();
|
2026-01-25 15:52:02 +02:00
|
|
|
const { isMobile } = useDeviceInfo();
|
|
|
|
|
const { currentTheme } = useThemeSystem();
|
|
|
|
|
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
|
|
|
|
|
2026-02-03 15:05:01 -03:00
|
|
|
// Inline comment drafts
|
|
|
|
|
const addDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
2026-02-05 21:51:03 -03:00
|
|
|
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
2026-02-03 15:05:01 -03:00
|
|
|
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
|
|
|
|
const allDrafts = useInlineCommentDraftStore((state) => state.drafts);
|
|
|
|
|
|
|
|
|
|
// Get session key for drafts
|
|
|
|
|
const getSessionKey = React.useCallback(() => {
|
|
|
|
|
return currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
|
|
|
|
}, [currentSessionId, newSessionDraftOpen]);
|
|
|
|
|
|
2026-01-25 15:52:02 +02:00
|
|
|
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 [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 [loading, setLoading] = React.useState(false);
|
|
|
|
|
const [copiedPath, setCopiedPath] = React.useState(false);
|
|
|
|
|
const [copiedContent, setCopiedContent] = React.useState(false);
|
2026-01-27 12:33:12 +02:00
|
|
|
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
|
2026-01-25 15:52:02 +02:00
|
|
|
const copiedTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
|
const copiedContentTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
|
|
|
|
|
|
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
|
|
|
|
const [commentText, setCommentText] = React.useState('');
|
2026-02-05 21:51:03 -03:00
|
|
|
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
2026-01-27 12:33:12 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
2026-01-25 15:52:02 +02:00
|
|
|
const isSelectingRef = React.useRef(false);
|
|
|
|
|
const selectionStartRef = React.useRef<number | null>(null);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const handleGlobalMouseUp = () => {
|
|
|
|
|
isSelectingRef.current = false;
|
|
|
|
|
selectionStartRef.current = null;
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('mouseup', handleGlobalMouseUp);
|
|
|
|
|
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
setLineSelection(null);
|
|
|
|
|
setCommentText('');
|
2026-02-05 21:51:03 -03:00
|
|
|
setEditingDraftId(null);
|
2026-01-25 15:52:02 +02:00
|
|
|
}, [content]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!lineSelection) return;
|
2026-02-08 10:42:50 -03:00
|
|
|
|
|
|
|
|
// Auto-scroll input into view on mobile
|
|
|
|
|
if (isMobile && !editingDraftId) {
|
|
|
|
|
// We rely on InlineCommentInput doing this now via useEffect
|
|
|
|
|
}
|
2026-01-25 15:52:02 +02:00
|
|
|
|
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
2026-02-08 10:42:50 -03:00
|
|
|
// Check if click is inside any comment component
|
|
|
|
|
if (
|
|
|
|
|
target.closest('[data-comment-card="true"]') ||
|
|
|
|
|
target.closest('[data-comment-input="true"]') ||
|
|
|
|
|
target.closest('.oc-block-widget')
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-25 15:52:02 +02:00
|
|
|
if (target.closest('.cm-gutterElement')) return;
|
|
|
|
|
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
2026-02-08 10:42:50 -03:00
|
|
|
|
|
|
|
|
// If clicking outside while editing, maybe we should save or ask confirmation?
|
|
|
|
|
// For now, cancel edit
|
2026-01-25 15:52:02 +02:00
|
|
|
setLineSelection(null);
|
|
|
|
|
setCommentText('');
|
2026-02-05 21:51:03 -03:00
|
|
|
setEditingDraftId(null);
|
2026-01-25 15:52:02 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
|
|
|
document.addEventListener('click', handleClickOutside);
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.clearTimeout(timeoutId);
|
|
|
|
|
document.removeEventListener('click', handleClickOutside);
|
|
|
|
|
};
|
2026-02-08 10:42:50 -03:00
|
|
|
}, [lineSelection, editingDraftId, isMobile]);
|
2026-01-25 15:52:02 +02:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-03 15:05:01 -03:00
|
|
|
const handleCancelComment = React.useCallback(() => {
|
|
|
|
|
setCommentText('');
|
|
|
|
|
setLineSelection(null);
|
2026-02-05 21:51:03 -03:00
|
|
|
setEditingDraftId(null);
|
2026-02-03 15:05:01 -03:00
|
|
|
}, []);
|
2026-01-25 15:52:02 +02:00
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
const handleSaveComment = React.useCallback((textToSave: string, rangeOverride?: { start: number; end: number }) => {
|
|
|
|
|
// Use provided range override or fall back to current selection
|
|
|
|
|
const targetRange = rangeOverride ?? lineSelection;
|
|
|
|
|
if (!targetRange || !textToSave.trim()) return;
|
2026-01-25 15:52:02 +02:00
|
|
|
|
2026-02-03 15:05:01 -03:00
|
|
|
const sessionKey = getSessionKey();
|
|
|
|
|
if (!sessionKey) {
|
|
|
|
|
toast.error('Select a session to save comment');
|
2026-01-25 15:52:02 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
const code = extractSelectedCode(content, targetRange);
|
2026-01-25 15:52:02 +02:00
|
|
|
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
|
|
|
|
const language = resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown';
|
|
|
|
|
|
2026-02-05 21:51:03 -03:00
|
|
|
if (editingDraftId) {
|
|
|
|
|
updateDraft(sessionKey, editingDraftId, {
|
|
|
|
|
fileLabel,
|
2026-02-08 10:42:50 -03:00
|
|
|
startLine: targetRange.start,
|
|
|
|
|
endLine: targetRange.end,
|
2026-02-05 21:51:03 -03:00
|
|
|
code,
|
|
|
|
|
language,
|
2026-02-08 10:42:50 -03:00
|
|
|
text: textToSave.trim(),
|
2026-02-05 21:51:03 -03:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
addDraft({
|
|
|
|
|
sessionKey,
|
|
|
|
|
source: 'plan',
|
|
|
|
|
fileLabel,
|
2026-02-08 10:42:50 -03:00
|
|
|
startLine: targetRange.start,
|
|
|
|
|
endLine: targetRange.end,
|
2026-02-05 21:51:03 -03:00
|
|
|
code,
|
|
|
|
|
language,
|
2026-02-08 10:42:50 -03:00
|
|
|
text: textToSave.trim(),
|
2026-02-05 21:51:03 -03:00
|
|
|
});
|
|
|
|
|
}
|
2026-01-25 15:52:02 +02:00
|
|
|
|
|
|
|
|
setCommentText('');
|
|
|
|
|
setLineSelection(null);
|
2026-02-05 21:51:03 -03:00
|
|
|
setEditingDraftId(null);
|
2026-02-03 15:05:01 -03:00
|
|
|
|
2026-02-05 21:51:03 -03:00
|
|
|
toast.success(editingDraftId ? 'Comment updated' : 'Comment saved');
|
2026-02-08 10:42:50 -03:00
|
|
|
}, [lineSelection, content, displayPath, resolvedPath, addDraft, updateDraft, getSessionKey, extractSelectedCode, editingDraftId]);
|
|
|
|
|
|
2026-01-25 15:52:02 +02: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(() => {
|
|
|
|
|
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 (showLoading: boolean) => {
|
|
|
|
|
if (showLoading) {
|
|
|
|
|
setResolvedPath(null);
|
|
|
|
|
setContent('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!session?.slug || !session?.time?.created || !sessionDirectory) {
|
|
|
|
|
setResolvedPath(null);
|
|
|
|
|
setContent('');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (showLoading) {
|
|
|
|
|
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 && showLoading) setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void run(true);
|
|
|
|
|
|
|
|
|
|
const interval = window.setInterval(() => {
|
|
|
|
|
void run(false);
|
|
|
|
|
}, 3000);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
window.clearInterval(interval);
|
|
|
|
|
};
|
|
|
|
|
}, [sessionDirectory, session?.slug, session?.time?.created, homeDirectory, runtimeApis.files]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (copiedTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
if (copiedContentTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
const blockWidgets = React.useMemo(() => {
|
|
|
|
|
if (mdViewMode === 'preview') return [];
|
2026-02-03 15:05:01 -03:00
|
|
|
|
|
|
|
|
const sessionKey = getSessionKey();
|
2026-02-08 10:42:50 -03:00
|
|
|
if (!sessionKey) return [];
|
2026-02-03 15:05:01 -03:00
|
|
|
|
|
|
|
|
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
|
|
|
|
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
|
|
|
|
|
const fileDrafts = sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === fileLabel);
|
|
|
|
|
|
2026-02-08 10:42:50 -03:00
|
|
|
const widgets: BlockWidgetDef[] = [];
|
|
|
|
|
|
|
|
|
|
// Add saved drafts
|
|
|
|
|
fileDrafts.forEach((draft) => {
|
|
|
|
|
if (draft.id === editingDraftId) {
|
|
|
|
|
// Always show edit input (even on mobile)
|
|
|
|
|
widgets.push({
|
|
|
|
|
afterLine: draft.endLine,
|
|
|
|
|
id: `edit-${draft.id}`,
|
|
|
|
|
content: (
|
|
|
|
|
<InlineCommentInput
|
|
|
|
|
fileLabel={fileLabel}
|
|
|
|
|
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
|
|
|
|
initialText={commentText}
|
|
|
|
|
onSave={handleSaveComment}
|
|
|
|
|
onCancel={handleCancelComment}
|
|
|
|
|
isEditing={true}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Show saved cards on all devices
|
|
|
|
|
widgets.push({
|
|
|
|
|
afterLine: draft.endLine,
|
|
|
|
|
id: `draft-${draft.id}`,
|
|
|
|
|
content: (
|
|
|
|
|
<InlineCommentCard
|
|
|
|
|
draft={draft}
|
|
|
|
|
onEdit={() => {
|
|
|
|
|
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
|
|
|
|
setCommentText(draft.text);
|
|
|
|
|
setEditingDraftId(draft.id);
|
2026-02-03 15:05:01 -03:00
|
|
|
}}
|
2026-02-08 10:42:50 -03:00
|
|
|
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add new comment input if selecting AND not editing an existing draft
|
|
|
|
|
if (lineSelection && !editingDraftId) {
|
|
|
|
|
widgets.push({
|
|
|
|
|
afterLine: lineSelection.end,
|
|
|
|
|
id: 'new-comment-input',
|
|
|
|
|
content: (
|
|
|
|
|
<InlineCommentInput
|
|
|
|
|
fileLabel={fileLabel}
|
|
|
|
|
lineRange={lineSelection}
|
|
|
|
|
initialText={commentText} // Usually empty for new, unless restored?
|
|
|
|
|
onSave={handleSaveComment}
|
|
|
|
|
onCancel={handleCancelComment}
|
|
|
|
|
isEditing={false}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return widgets;
|
|
|
|
|
}, [
|
|
|
|
|
mdViewMode,
|
|
|
|
|
getSessionKey,
|
|
|
|
|
allDrafts,
|
|
|
|
|
displayPath,
|
|
|
|
|
editingDraftId,
|
|
|
|
|
lineSelection,
|
|
|
|
|
commentText,
|
|
|
|
|
handleSaveComment,
|
|
|
|
|
handleCancelComment,
|
|
|
|
|
removeDraft,
|
|
|
|
|
]);
|
2026-02-03 15:05:01 -03:00
|
|
|
|
2026-01-25 15:52:02 +02:00
|
|
|
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">Plan</div>
|
|
|
|
|
{resolvedPath ? (
|
|
|
|
|
<div className="typography-meta text-muted-foreground truncate" title={displayPath ?? resolvedPath}>
|
|
|
|
|
{displayPath ?? resolvedPath}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
{resolvedPath ? (
|
|
|
|
|
<div className="flex items-center gap-1">
|
2026-01-27 12:33:12 +02:00
|
|
|
<PreviewToggleButton
|
|
|
|
|
currentMode={mdViewMode}
|
|
|
|
|
onToggle={() => saveMdViewMode(mdViewMode === 'preview' ? 'edit' : 'preview')}
|
|
|
|
|
/>
|
2026-01-25 15:52:02 +02:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(content);
|
|
|
|
|
setCopiedContent(true);
|
|
|
|
|
if (copiedContentTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopiedContent(false);
|
|
|
|
|
}, 1200);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignored
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="h-5 w-5 p-0"
|
|
|
|
|
title="Copy plan contents"
|
|
|
|
|
aria-label="Copy plan contents"
|
|
|
|
|
>
|
|
|
|
|
{copiedContent ? (
|
|
|
|
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiClipboardLine className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(displayPath ?? resolvedPath);
|
|
|
|
|
setCopiedPath(true);
|
|
|
|
|
if (copiedTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
copiedTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopiedPath(false);
|
|
|
|
|
}, 1200);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignored
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="h-5 w-5 p-0"
|
|
|
|
|
title={`Copy plan path (${displayPath ?? resolvedPath})`}
|
|
|
|
|
aria-label={`Copy plan path (${displayPath ?? resolvedPath})`}
|
|
|
|
|
>
|
|
|
|
|
{copiedPath ? (
|
|
|
|
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiFileCopy2Line className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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">Loading…</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="relative h-full">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full"
|
|
|
|
|
style={{
|
2026-02-08 10:42:50 -03:00
|
|
|
['--oc-plan-comment-pad' as string]: '0px',
|
2026-01-25 15:52:02 +02:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="h-full oc-plan-editor">
|
2026-01-27 12:33:12 +02:00
|
|
|
{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">Preview unavailable</div>
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
Switch to edit mode to fix the issue.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-02-03 15:05:01 -03:00
|
|
|
<div className="relative h-full">
|
|
|
|
|
<CodeMirrorEditor
|
|
|
|
|
value={content}
|
|
|
|
|
onChange={() => {
|
|
|
|
|
// read-only
|
|
|
|
|
}}
|
|
|
|
|
readOnly={true}
|
2026-02-05 21:51:03 -03:00
|
|
|
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
|
2026-02-03 15:05:01 -03:00
|
|
|
extensions={editorExtensions}
|
|
|
|
|
highlightLines={lineSelection
|
|
|
|
|
? {
|
|
|
|
|
start: Math.min(lineSelection.start, lineSelection.end),
|
|
|
|
|
end: Math.max(lineSelection.start, lineSelection.end),
|
|
|
|
|
}
|
|
|
|
|
: undefined}
|
2026-02-08 10:42:50 -03:00
|
|
|
blockWidgets={blockWidgets}
|
2026-02-03 15:05:01 -03:00
|
|
|
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;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isSelectingRef.current = true;
|
|
|
|
|
selectionStartRef.current = lineNumber;
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
}
|
2026-01-27 12:33:12 +02:00
|
|
|
|
|
|
|
|
return true;
|
2026-02-03 15:05:01 -03:00
|
|
|
},
|
|
|
|
|
mouseover: (view, line, event) => {
|
|
|
|
|
if (!(event instanceof MouseEvent)) return false;
|
|
|
|
|
if (event.buttons !== 1) return false;
|
|
|
|
|
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
2026-01-27 12:33:12 +02:00
|
|
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
|
|
|
|
const start = Math.min(selectionStartRef.current, lineNumber);
|
|
|
|
|
const end = Math.max(selectionStartRef.current, lineNumber);
|
2026-01-25 15:52:02 +02:00
|
|
|
setLineSelection({ start, end });
|
2026-01-27 12:33:12 +02:00
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
mouseup: () => {
|
2026-01-25 15:52:02 +02:00
|
|
|
isSelectingRef.current = false;
|
|
|
|
|
selectionStartRef.current = null;
|
2026-01-27 12:33:12 +02:00
|
|
|
return false;
|
|
|
|
|
},
|
2026-01-25 15:52:02 +02:00
|
|
|
},
|
2026-01-27 12:33:12 +02:00
|
|
|
}}
|
|
|
|
|
/>
|
2026-02-03 15:05:01 -03:00
|
|
|
</div>
|
2026-01-27 12:33:12 +02:00
|
|
|
)}
|
2026-01-25 15:52:02 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|