mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
The AutoRun panel was showing stale content when switching between sessions. The dropdown correctly updated to show the new session's selected document, but the content displayed remained from the previous session. Root cause: Two separate useEffects for syncing content had a stale closure issue - the second effect didn't properly track localContent changes, causing the content comparison to use outdated values. Solution: Consolidated into a single effect using a ref to track content changes, avoiding the stale closure problem while properly handling both session switches and external content updates. Claude ID: 6ff853d1-9e0d-4a3b-9ae5-4a72a1d23eea Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
2012 lines
76 KiB
TypeScript
2012 lines
76 KiB
TypeScript
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, memo, useMemo, forwardRef, useImperativeHandle } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
import { Eye, Edit, Play, Square, HelpCircle, Loader2, Image, X, Search, ChevronUp, ChevronDown, ChevronRight, ChevronLeft, Copy, Check, Trash2, FolderOpen, FileText, RefreshCw } from 'lucide-react';
|
|
import type { BatchRunState, SessionState, Theme } from '../types';
|
|
import { AutoRunnerHelpModal } from './AutoRunnerHelpModal';
|
|
import { MermaidRenderer } from './MermaidRenderer';
|
|
import { AutoRunDocumentSelector } from './AutoRunDocumentSelector';
|
|
import { useTemplateAutocomplete } from '../hooks/useTemplateAutocomplete';
|
|
import { TemplateAutocompleteDropdown } from './TemplateAutocompleteDropdown';
|
|
|
|
// Memoize remarkPlugins array - it never changes
|
|
const REMARK_PLUGINS = [remarkGfm];
|
|
|
|
interface AutoRunProps {
|
|
theme: Theme;
|
|
sessionId: string; // Maestro session ID for per-session attachment storage
|
|
|
|
// Folder & document state
|
|
folderPath: string | null;
|
|
selectedFile: string | null;
|
|
documentList: string[]; // Filenames without .md
|
|
documentTree?: Array<{ name: string; type: 'file' | 'folder'; path: string; children?: unknown[] }>; // Tree structure for subfolders
|
|
|
|
// Content state
|
|
content: string;
|
|
onContentChange: (content: string) => void;
|
|
|
|
// Mode state
|
|
mode: 'edit' | 'preview';
|
|
onModeChange: (mode: 'edit' | 'preview') => void;
|
|
|
|
// Scroll/cursor state
|
|
initialCursorPosition?: number;
|
|
initialEditScrollPos?: number;
|
|
initialPreviewScrollPos?: number;
|
|
onStateChange?: (state: {
|
|
mode: 'edit' | 'preview';
|
|
cursorPosition: number;
|
|
editScrollPos: number;
|
|
previewScrollPos: number;
|
|
}) => void;
|
|
|
|
// Actions
|
|
onOpenSetup: () => void;
|
|
onRefresh: () => void;
|
|
onSelectDocument: (filename: string) => void;
|
|
onCreateDocument: (filename: string) => Promise<boolean>;
|
|
isLoadingDocuments?: boolean;
|
|
|
|
// Batch processing props
|
|
batchRunState?: BatchRunState;
|
|
onOpenBatchRunner?: () => void;
|
|
onStopBatchRun?: () => void;
|
|
|
|
// Session state for disabling Run when agent is busy
|
|
sessionState?: SessionState;
|
|
|
|
// Legacy prop for backwards compatibility
|
|
onChange?: (content: string) => void;
|
|
}
|
|
|
|
export interface AutoRunHandle {
|
|
focus: () => void;
|
|
}
|
|
|
|
// Cache for loaded images to avoid repeated IPC calls
|
|
const imageCache = new Map<string, string>();
|
|
|
|
// Undo/Redo state interface
|
|
interface UndoState {
|
|
content: string;
|
|
cursorPosition: number;
|
|
}
|
|
|
|
// Maximum undo history entries per document
|
|
const MAX_UNDO_HISTORY = 50;
|
|
|
|
// Custom image component that loads images from the Auto Run folder or external URLs
|
|
function AttachmentImage({
|
|
src,
|
|
alt,
|
|
folderPath,
|
|
theme,
|
|
onImageClick
|
|
}: {
|
|
src?: string;
|
|
alt?: string;
|
|
folderPath: string | null;
|
|
theme: any;
|
|
onImageClick?: (filename: string) => void;
|
|
}) {
|
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [filename, setFilename] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!src) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Check if this is a relative path (e.g., images/{docName}-{timestamp}.{ext})
|
|
if (src.startsWith('images/') && folderPath) {
|
|
const fname = src.split('/').pop() || src;
|
|
setFilename(fname);
|
|
const cacheKey = `${folderPath}:${src}`;
|
|
|
|
// Check cache first
|
|
if (imageCache.has(cacheKey)) {
|
|
setDataUrl(imageCache.get(cacheKey)!);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Load from folder using absolute path
|
|
const absolutePath = `${folderPath}/${src}`;
|
|
window.maestro.fs.readFile(absolutePath)
|
|
.then((result) => {
|
|
if (result.startsWith('data:')) {
|
|
imageCache.set(cacheKey, result);
|
|
setDataUrl(result);
|
|
} else {
|
|
setError('Invalid image data');
|
|
}
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
|
setLoading(false);
|
|
});
|
|
} else if (src.startsWith('data:')) {
|
|
// Already a data URL
|
|
setDataUrl(src);
|
|
setFilename(null);
|
|
setLoading(false);
|
|
} else if (src.startsWith('http://') || src.startsWith('https://')) {
|
|
// External URL - just use it directly
|
|
setDataUrl(src);
|
|
setFilename(null);
|
|
setLoading(false);
|
|
} else if (src.startsWith('/')) {
|
|
// Absolute file path - load via IPC
|
|
setFilename(src.split('/').pop() || null);
|
|
window.maestro.fs.readFile(src)
|
|
.then((result) => {
|
|
if (result.startsWith('data:')) {
|
|
setDataUrl(result);
|
|
} else {
|
|
setError('Invalid image data');
|
|
}
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
|
setLoading(false);
|
|
});
|
|
} else {
|
|
// Other relative path - try to load as file from folderPath if available
|
|
setFilename(src.split('/').pop() || null);
|
|
const pathToLoad = folderPath ? `${folderPath}/${src}` : src;
|
|
window.maestro.fs.readFile(pathToLoad)
|
|
.then((result) => {
|
|
if (result.startsWith('data:')) {
|
|
setDataUrl(result);
|
|
} else {
|
|
setError('Invalid image data');
|
|
}
|
|
setLoading(false);
|
|
})
|
|
.catch((err) => {
|
|
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
|
|
setLoading(false);
|
|
});
|
|
}
|
|
}, [src, folderPath]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded"
|
|
style={{ backgroundColor: theme.colors.bgActivity }}
|
|
>
|
|
<Loader2 className="w-4 h-4 animate-spin" style={{ color: theme.colors.textDim }} />
|
|
<span className="text-xs" style={{ color: theme.colors.textDim }}>Loading image...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded"
|
|
style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.error, border: '1px solid' }}
|
|
>
|
|
<Image className="w-4 h-4" style={{ color: theme.colors.error }} />
|
|
<span className="text-xs" style={{ color: theme.colors.error }}>{error}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!dataUrl) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<span
|
|
className="inline-block align-middle mx-1 my-1 cursor-pointer group relative"
|
|
onClick={() => onImageClick?.(filename || src || '')}
|
|
title={filename ? `Click to enlarge: ${filename}` : 'Click to enlarge'}
|
|
>
|
|
<img
|
|
src={dataUrl}
|
|
alt={alt || ''}
|
|
className="rounded border hover:opacity-90 transition-all hover:shadow-lg"
|
|
style={{
|
|
maxHeight: '120px',
|
|
maxWidth: '200px',
|
|
objectFit: 'contain',
|
|
borderColor: theme.colors.border,
|
|
}}
|
|
/>
|
|
{/* Zoom hint overlay */}
|
|
<span
|
|
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity rounded"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}
|
|
>
|
|
<Search className="w-5 h-5 text-white" />
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// Component for displaying search-highlighted content using safe DOM methods
|
|
function SearchHighlightedContent({
|
|
content,
|
|
searchQuery,
|
|
currentMatchIndex,
|
|
theme
|
|
}: {
|
|
content: string;
|
|
searchQuery: string;
|
|
currentMatchIndex: number;
|
|
theme: any;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!ref.current) return;
|
|
|
|
// Clear existing content
|
|
ref.current.textContent = '';
|
|
|
|
if (!searchQuery.trim()) {
|
|
ref.current.textContent = content;
|
|
return;
|
|
}
|
|
|
|
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
|
const parts = content.split(regex);
|
|
let matchIndex = 0;
|
|
|
|
parts.forEach((part) => {
|
|
if (part.toLowerCase() === searchQuery.toLowerCase()) {
|
|
// This is a match - create a highlighted mark element
|
|
const mark = document.createElement('mark');
|
|
mark.className = 'search-match';
|
|
mark.textContent = part;
|
|
mark.style.padding = '0 2px';
|
|
mark.style.borderRadius = '2px';
|
|
if (matchIndex === currentMatchIndex) {
|
|
mark.style.backgroundColor = theme.colors.accent;
|
|
mark.style.color = '#fff';
|
|
} else {
|
|
mark.style.backgroundColor = '#ffd700';
|
|
mark.style.color = '#000';
|
|
}
|
|
ref.current!.appendChild(mark);
|
|
matchIndex++;
|
|
} else {
|
|
// Regular text - create a text node
|
|
ref.current!.appendChild(document.createTextNode(part));
|
|
}
|
|
});
|
|
}, [content, searchQuery, currentMatchIndex, theme.colors.accent]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="font-mono text-sm whitespace-pre-wrap"
|
|
style={{ color: theme.colors.textMain }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Image preview thumbnail for staged images in edit mode
|
|
function ImagePreview({
|
|
src,
|
|
filename,
|
|
theme,
|
|
onRemove,
|
|
onImageClick
|
|
}: {
|
|
src: string;
|
|
filename: string;
|
|
theme: any;
|
|
onRemove: () => void;
|
|
onImageClick: (filename: string) => void;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="relative inline-block group"
|
|
style={{ margin: '4px' }}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt={filename}
|
|
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
|
|
style={{ border: `1px solid ${theme.colors.border}` }}
|
|
onClick={() => onImageClick(filename)}
|
|
/>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
className="absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
style={{
|
|
backgroundColor: theme.colors.error,
|
|
color: 'white'
|
|
}}
|
|
title="Remove image"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[9px] truncate rounded-b"
|
|
style={{
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
color: 'white'
|
|
}}
|
|
>
|
|
{filename}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Inner implementation component
|
|
const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInner({
|
|
theme,
|
|
sessionId,
|
|
folderPath,
|
|
selectedFile,
|
|
documentList,
|
|
documentTree,
|
|
content,
|
|
onContentChange,
|
|
mode: externalMode,
|
|
onModeChange,
|
|
initialCursorPosition = 0,
|
|
initialEditScrollPos = 0,
|
|
initialPreviewScrollPos = 0,
|
|
onStateChange,
|
|
onOpenSetup,
|
|
onRefresh,
|
|
onSelectDocument,
|
|
onCreateDocument,
|
|
isLoadingDocuments = false,
|
|
batchRunState,
|
|
onOpenBatchRunner,
|
|
onStopBatchRun,
|
|
sessionState,
|
|
onChange, // Legacy prop for backwards compatibility
|
|
}, ref) {
|
|
const isLocked = batchRunState?.isRunning || false;
|
|
const isAgentBusy = sessionState === 'busy' || sessionState === 'connecting';
|
|
const isStopping = batchRunState?.isStopping || false;
|
|
|
|
// Use external mode if provided, otherwise use local state
|
|
const [localMode, setLocalMode] = useState<'edit' | 'preview'>(externalMode || 'edit');
|
|
const mode = externalMode || localMode;
|
|
const setMode = useCallback((newMode: 'edit' | 'preview') => {
|
|
if (onModeChange) {
|
|
onModeChange(newMode);
|
|
} else {
|
|
setLocalMode(newMode);
|
|
}
|
|
}, [onModeChange]);
|
|
|
|
// Use onContentChange if provided, otherwise fall back to legacy onChange
|
|
const handleContentChange = onContentChange || onChange || (() => {});
|
|
|
|
// Local content state for responsive typing - syncs to parent on blur
|
|
const [localContent, setLocalContent] = useState(content);
|
|
const prevSessionIdRef = useRef(sessionId);
|
|
// Track if user is actively editing (to avoid overwriting their changes)
|
|
const isEditingRef = useRef(false);
|
|
|
|
// Auto-save timer ref for 5-second debounce
|
|
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Track the last saved content to avoid unnecessary saves
|
|
const lastSavedContentRef = useRef<string>(content);
|
|
|
|
// Undo/Redo history maps - keyed by document filename (selectedFile)
|
|
// Using refs so history persists across re-renders without triggering re-renders
|
|
const undoHistoryRef = useRef<Map<string, UndoState[]>>(new Map());
|
|
const redoHistoryRef = useRef<Map<string, UndoState[]>>(new Map());
|
|
// Track last content that was snapshotted for undo
|
|
const lastUndoSnapshotRef = useRef<string>(content);
|
|
// Timer ref for debounced undo snapshots
|
|
const undoSnapshotTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Push current state to undo history (call BEFORE making changes)
|
|
const pushUndoState = useCallback((contentToSnapshot?: string, cursorPos?: number) => {
|
|
if (!selectedFile) return;
|
|
|
|
const snapshotContent = contentToSnapshot ?? localContent;
|
|
const snapshotCursor = cursorPos ?? textareaRef.current?.selectionStart ?? 0;
|
|
|
|
// Only push if content actually differs from last snapshot
|
|
if (snapshotContent === lastUndoSnapshotRef.current) return;
|
|
|
|
const currentState: UndoState = {
|
|
content: snapshotContent,
|
|
cursorPosition: snapshotCursor,
|
|
};
|
|
|
|
// Get or create history array for this document
|
|
const history = undoHistoryRef.current.get(selectedFile) || [];
|
|
history.push(currentState);
|
|
|
|
// Limit to MAX_UNDO_HISTORY entries
|
|
if (history.length > MAX_UNDO_HISTORY) {
|
|
history.shift();
|
|
}
|
|
|
|
undoHistoryRef.current.set(selectedFile, history);
|
|
|
|
// Update last snapshot reference
|
|
lastUndoSnapshotRef.current = snapshotContent;
|
|
|
|
// Clear redo stack on new edit action
|
|
redoHistoryRef.current.set(selectedFile, []);
|
|
}, [selectedFile, localContent]);
|
|
|
|
// Schedule a debounced undo snapshot (called on each content change)
|
|
const scheduleUndoSnapshot = useCallback((previousContent: string, previousCursor: number) => {
|
|
// Clear any pending snapshot
|
|
if (undoSnapshotTimeoutRef.current) {
|
|
clearTimeout(undoSnapshotTimeoutRef.current);
|
|
}
|
|
|
|
// Schedule snapshot after 1 second of inactivity
|
|
undoSnapshotTimeoutRef.current = setTimeout(() => {
|
|
pushUndoState(previousContent, previousCursor);
|
|
}, 1000);
|
|
}, [pushUndoState]);
|
|
|
|
// Handle undo (Cmd+Z)
|
|
const handleUndo = useCallback(() => {
|
|
if (!selectedFile) return;
|
|
|
|
const undoStack = undoHistoryRef.current.get(selectedFile) || [];
|
|
if (undoStack.length === 0) return;
|
|
|
|
// Save current state to redo stack before undoing
|
|
const redoStack = redoHistoryRef.current.get(selectedFile) || [];
|
|
redoStack.push({
|
|
content: localContent,
|
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
|
});
|
|
redoHistoryRef.current.set(selectedFile, redoStack);
|
|
|
|
// Pop and apply the undo state
|
|
const prevState = undoStack.pop()!;
|
|
undoHistoryRef.current.set(selectedFile, undoStack);
|
|
|
|
// Update content without pushing to undo stack
|
|
setLocalContent(prevState.content);
|
|
lastUndoSnapshotRef.current = prevState.content;
|
|
|
|
// Restore cursor position after React re-renders
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.setSelectionRange(prevState.cursorPosition, prevState.cursorPosition);
|
|
textareaRef.current.focus();
|
|
}
|
|
});
|
|
}, [selectedFile, localContent]);
|
|
|
|
// Handle redo (Cmd+Shift+Z)
|
|
const handleRedo = useCallback(() => {
|
|
if (!selectedFile) return;
|
|
|
|
const redoStack = redoHistoryRef.current.get(selectedFile) || [];
|
|
if (redoStack.length === 0) return;
|
|
|
|
// Save current state to undo stack before redoing
|
|
const undoStack = undoHistoryRef.current.get(selectedFile) || [];
|
|
undoStack.push({
|
|
content: localContent,
|
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
|
});
|
|
undoHistoryRef.current.set(selectedFile, undoStack);
|
|
|
|
// Pop and apply the redo state
|
|
const nextState = redoStack.pop()!;
|
|
redoHistoryRef.current.set(selectedFile, redoStack);
|
|
|
|
// Update content without pushing to undo stack
|
|
setLocalContent(nextState.content);
|
|
lastUndoSnapshotRef.current = nextState.content;
|
|
|
|
// Restore cursor position after React re-renders
|
|
requestAnimationFrame(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.setSelectionRange(nextState.cursorPosition, nextState.cursorPosition);
|
|
textareaRef.current.focus();
|
|
}
|
|
});
|
|
}, [selectedFile, localContent]);
|
|
|
|
// Track content prop to detect external changes (for session switch sync)
|
|
const prevContentForSyncRef = useRef(content);
|
|
|
|
// Sync local content from prop when session changes (switching sessions)
|
|
// or when content changes externally (e.g., switching documents, batch run modifying tasks)
|
|
useEffect(() => {
|
|
const sessionChanged = sessionId !== prevSessionIdRef.current;
|
|
const contentChanged = content !== prevContentForSyncRef.current;
|
|
|
|
if (sessionChanged) {
|
|
// Reset editing flag so content can sync properly when returning to this session
|
|
isEditingRef.current = false;
|
|
setLocalContent(content);
|
|
prevSessionIdRef.current = sessionId;
|
|
prevContentForSyncRef.current = content;
|
|
} else if (contentChanged && !isEditingRef.current) {
|
|
// Content changed externally (document switch, batch run, etc.) - sync if not editing
|
|
setLocalContent(content);
|
|
prevContentForSyncRef.current = content;
|
|
}
|
|
}, [sessionId, content]);
|
|
|
|
// Sync local content to parent on blur
|
|
const syncContentToParent = useCallback(() => {
|
|
isEditingRef.current = false;
|
|
if (localContent !== content) {
|
|
handleContentChange(localContent);
|
|
}
|
|
}, [localContent, content, handleContentChange]);
|
|
|
|
// Auto-save to disk with 5-second debounce
|
|
useEffect(() => {
|
|
// Only save if we have a folder and selected file
|
|
if (!folderPath || !selectedFile) return;
|
|
|
|
// Never auto-save empty content - this prevents wiping files during load
|
|
if (!localContent) return;
|
|
|
|
// Only save if content has actually changed from last saved
|
|
if (localContent === lastSavedContentRef.current) return;
|
|
|
|
// Clear any existing timeout
|
|
if (autoSaveTimeoutRef.current) {
|
|
clearTimeout(autoSaveTimeoutRef.current);
|
|
}
|
|
|
|
// Schedule save after 5 seconds of inactivity
|
|
autoSaveTimeoutRef.current = setTimeout(async () => {
|
|
// Double-check content hasn't been externally synced
|
|
if (localContent !== lastSavedContentRef.current) {
|
|
try {
|
|
await window.maestro.autorun.writeDoc(folderPath, selectedFile + '.md', localContent);
|
|
lastSavedContentRef.current = localContent;
|
|
// Also sync to parent state so UI stays consistent
|
|
handleContentChange(localContent);
|
|
} catch (err) {
|
|
console.error('Auto-save failed:', err);
|
|
}
|
|
}
|
|
}, 5000);
|
|
|
|
// Cleanup on unmount or when dependencies change
|
|
return () => {
|
|
if (autoSaveTimeoutRef.current) {
|
|
clearTimeout(autoSaveTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [localContent, folderPath, selectedFile, handleContentChange]);
|
|
|
|
// Clear auto-save timer and update lastSavedContent when document changes (session or file change)
|
|
useEffect(() => {
|
|
// Clear pending auto-save when document changes
|
|
if (autoSaveTimeoutRef.current) {
|
|
clearTimeout(autoSaveTimeoutRef.current);
|
|
autoSaveTimeoutRef.current = null;
|
|
}
|
|
// Clear pending undo snapshot when document changes
|
|
if (undoSnapshotTimeoutRef.current) {
|
|
clearTimeout(undoSnapshotTimeoutRef.current);
|
|
undoSnapshotTimeoutRef.current = null;
|
|
}
|
|
// Reset lastSavedContent to the new content
|
|
lastSavedContentRef.current = content;
|
|
// Reset lastUndoSnapshot to the new content (so first edit creates a proper undo point)
|
|
lastUndoSnapshotRef.current = content;
|
|
}, [selectedFile, sessionId, content]);
|
|
|
|
// Track mode before auto-run to restore when it ends
|
|
const modeBeforeAutoRunRef = useRef<'edit' | 'preview' | null>(null);
|
|
const [helpModalOpen, setHelpModalOpen] = useState(false);
|
|
// Lightbox state: stores the filename/URL of the currently viewed image (null = closed)
|
|
const [lightboxFilename, setLightboxFilename] = useState<string | null>(null);
|
|
// For external URLs, store the direct URL separately (attachments use attachmentPreviews)
|
|
const [lightboxExternalUrl, setLightboxExternalUrl] = useState<string | null>(null);
|
|
const [lightboxCopied, setLightboxCopied] = useState(false);
|
|
const [attachmentsList, setAttachmentsList] = useState<string[]>([]);
|
|
const [attachmentPreviews, setAttachmentPreviews] = useState<Map<string, string>>(new Map());
|
|
const [attachmentsExpanded, setAttachmentsExpanded] = useState(true);
|
|
// Search state
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
|
const [totalMatches, setTotalMatches] = useState(0);
|
|
const matchElementsRef = useRef<HTMLElement[]>([]);
|
|
// Refresh animation state for empty state button
|
|
const [isRefreshingEmpty, setIsRefreshingEmpty] = useState(false);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const previewRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
// Track scroll positions in refs to preserve across re-renders
|
|
const previewScrollPosRef = useRef(initialPreviewScrollPos);
|
|
const editScrollPosRef = useRef(initialEditScrollPos);
|
|
|
|
// Template variable autocomplete hook
|
|
const {
|
|
autocompleteState,
|
|
handleKeyDown: handleAutocompleteKeyDown,
|
|
handleChange: handleAutocompleteChange,
|
|
selectVariable,
|
|
closeAutocomplete,
|
|
autocompleteRef,
|
|
} = useTemplateAutocomplete({
|
|
textareaRef,
|
|
value: localContent,
|
|
onChange: setLocalContent,
|
|
});
|
|
|
|
// Expose focus method to parent via ref
|
|
useImperativeHandle(ref, () => ({
|
|
focus: () => {
|
|
// Focus the appropriate element based on current mode
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
}
|
|
}), [mode]);
|
|
|
|
// Load existing images for the current document from the Auto Run folder
|
|
useEffect(() => {
|
|
if (folderPath && selectedFile) {
|
|
window.maestro.autorun.listImages(folderPath, selectedFile).then((result: { success: boolean; images?: { filename: string; relativePath: string }[]; error?: string }) => {
|
|
if (result.success && result.images) {
|
|
// Store relative paths (e.g., "images/{docName}-{timestamp}.{ext}")
|
|
const relativePaths = result.images.map((img: { filename: string; relativePath: string }) => img.relativePath);
|
|
setAttachmentsList(relativePaths);
|
|
// Load previews for existing images
|
|
result.images.forEach((img: { filename: string; relativePath: string }) => {
|
|
const absolutePath = `${folderPath}/${img.relativePath}`;
|
|
window.maestro.fs.readFile(absolutePath).then(dataUrl => {
|
|
if (dataUrl.startsWith('data:')) {
|
|
setAttachmentPreviews(prev => new Map(prev).set(img.relativePath, dataUrl));
|
|
}
|
|
}).catch(() => {
|
|
// Image file might be missing, ignore
|
|
});
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
// Clear attachments when no document is selected
|
|
setAttachmentsList([]);
|
|
setAttachmentPreviews(new Map());
|
|
}
|
|
}, [folderPath, selectedFile]);
|
|
|
|
// Auto-switch to preview mode when auto-run starts, restore when it ends
|
|
useEffect(() => {
|
|
if (isLocked) {
|
|
// Auto-run started: save current mode and switch to preview
|
|
modeBeforeAutoRunRef.current = mode;
|
|
if (mode !== 'preview') {
|
|
setMode('preview');
|
|
}
|
|
} else if (modeBeforeAutoRunRef.current !== null) {
|
|
// Auto-run ended: restore previous mode
|
|
setMode(modeBeforeAutoRunRef.current);
|
|
modeBeforeAutoRunRef.current = null;
|
|
}
|
|
}, [isLocked]);
|
|
|
|
// Restore cursor and scroll positions when component mounts
|
|
useEffect(() => {
|
|
if (textareaRef.current && initialCursorPosition > 0) {
|
|
textareaRef.current.setSelectionRange(initialCursorPosition, initialCursorPosition);
|
|
textareaRef.current.scrollTop = initialEditScrollPos;
|
|
}
|
|
if (previewRef.current && initialPreviewScrollPos > 0) {
|
|
previewRef.current.scrollTop = initialPreviewScrollPos;
|
|
}
|
|
}, []);
|
|
|
|
// Restore scroll position after content changes cause ReactMarkdown to rebuild DOM
|
|
// useLayoutEffect runs synchronously after DOM mutations but before paint
|
|
useLayoutEffect(() => {
|
|
if (mode === 'preview' && previewRef.current && previewScrollPosRef.current > 0) {
|
|
// Use requestAnimationFrame to ensure DOM is fully updated
|
|
requestAnimationFrame(() => {
|
|
if (previewRef.current) {
|
|
previewRef.current.scrollTop = previewScrollPosRef.current;
|
|
}
|
|
});
|
|
}
|
|
}, [localContent, mode, searchOpen, searchQuery]);
|
|
|
|
// Notify parent when mode changes
|
|
const toggleMode = () => {
|
|
const newMode = mode === 'edit' ? 'preview' : 'edit';
|
|
setMode(newMode);
|
|
|
|
if (onStateChange) {
|
|
onStateChange({
|
|
mode: newMode,
|
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
|
editScrollPos: textareaRef.current?.scrollTop || 0,
|
|
previewScrollPos: previewRef.current?.scrollTop || 0
|
|
});
|
|
}
|
|
};
|
|
|
|
// Auto-focus the active element after mode change
|
|
useEffect(() => {
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
}, [mode]);
|
|
|
|
// Track previous selectedFile to detect document changes
|
|
const prevSelectedFileRef = useRef(selectedFile);
|
|
// Track previous content to detect when content prop actually changes
|
|
const prevContentRef = useRef(content);
|
|
|
|
// Handle document selection change - focus and reset editing state
|
|
useEffect(() => {
|
|
if (!selectedFile) return;
|
|
|
|
const isNewDocument = selectedFile !== prevSelectedFileRef.current;
|
|
prevSelectedFileRef.current = selectedFile;
|
|
|
|
if (isNewDocument) {
|
|
// Reset editing flag so content syncs properly for new document
|
|
isEditingRef.current = false;
|
|
// Reset lastSavedContent to prevent auto-save from firing with stale comparison
|
|
lastSavedContentRef.current = content;
|
|
// Focus on document change
|
|
requestAnimationFrame(() => {
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
});
|
|
}
|
|
}, [selectedFile, content, mode]);
|
|
|
|
// Sync content from prop - only when content prop actually changes
|
|
useEffect(() => {
|
|
// Skip if no document selected
|
|
if (!selectedFile) return;
|
|
|
|
// Only sync when the content PROP actually changed (not just a re-render)
|
|
const contentPropChanged = content !== prevContentRef.current;
|
|
prevContentRef.current = content;
|
|
|
|
if (!contentPropChanged) return;
|
|
|
|
// Skip if user is actively editing - don't overwrite their changes
|
|
if (isEditingRef.current) return;
|
|
|
|
// Content prop changed - sync to local state
|
|
setLocalContent(content);
|
|
// Update lastSavedContent so auto-save knows this is the baseline
|
|
lastSavedContentRef.current = content;
|
|
}, [selectedFile, content]);
|
|
|
|
// Save cursor position and scroll position when they change
|
|
const handleCursorOrScrollChange = () => {
|
|
if (textareaRef.current) {
|
|
// Save to ref for persistence across re-renders
|
|
editScrollPosRef.current = textareaRef.current.scrollTop;
|
|
if (onStateChange) {
|
|
onStateChange({
|
|
mode,
|
|
cursorPosition: textareaRef.current.selectionStart,
|
|
editScrollPos: textareaRef.current.scrollTop,
|
|
previewScrollPos: previewRef.current?.scrollTop || 0
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePreviewScroll = () => {
|
|
if (previewRef.current) {
|
|
// Save to ref for persistence across re-renders
|
|
previewScrollPosRef.current = previewRef.current.scrollTop;
|
|
if (onStateChange) {
|
|
onStateChange({
|
|
mode,
|
|
cursorPosition: textareaRef.current?.selectionStart || 0,
|
|
editScrollPos: textareaRef.current?.scrollTop || 0,
|
|
previewScrollPos: previewRef.current.scrollTop
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle refresh for empty state with animation
|
|
const handleEmptyStateRefresh = useCallback(async () => {
|
|
setIsRefreshingEmpty(true);
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
// Keep spinner visible for at least 500ms for visual feedback
|
|
setTimeout(() => setIsRefreshingEmpty(false), 500);
|
|
}
|
|
}, [onRefresh]);
|
|
|
|
// Open search function
|
|
const openSearch = useCallback(() => {
|
|
setSearchOpen(true);
|
|
setTimeout(() => searchInputRef.current?.focus(), 0);
|
|
}, []);
|
|
|
|
// Close search function
|
|
const closeSearch = useCallback(() => {
|
|
setSearchOpen(false);
|
|
setSearchQuery('');
|
|
setCurrentMatchIndex(0);
|
|
setTotalMatches(0);
|
|
matchElementsRef.current = [];
|
|
// Refocus appropriate element
|
|
if (mode === 'edit' && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
} else if (mode === 'preview' && previewRef.current) {
|
|
previewRef.current.focus();
|
|
}
|
|
}, [mode]);
|
|
|
|
// Update match count when search query changes
|
|
useEffect(() => {
|
|
if (searchQuery.trim()) {
|
|
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(escapedQuery, 'gi');
|
|
const matches = localContent.match(regex);
|
|
const count = matches ? matches.length : 0;
|
|
setTotalMatches(count);
|
|
if (count > 0 && currentMatchIndex >= count) {
|
|
setCurrentMatchIndex(0);
|
|
}
|
|
} else {
|
|
setTotalMatches(0);
|
|
setCurrentMatchIndex(0);
|
|
}
|
|
}, [searchQuery, localContent]);
|
|
|
|
// Navigate to next search match
|
|
const goToNextMatch = useCallback(() => {
|
|
if (totalMatches === 0) return;
|
|
const nextIndex = (currentMatchIndex + 1) % totalMatches;
|
|
setCurrentMatchIndex(nextIndex);
|
|
}, [currentMatchIndex, totalMatches]);
|
|
|
|
// Navigate to previous search match
|
|
const goToPrevMatch = useCallback(() => {
|
|
if (totalMatches === 0) return;
|
|
const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches;
|
|
setCurrentMatchIndex(prevIndex);
|
|
}, [currentMatchIndex, totalMatches]);
|
|
|
|
// Scroll to current match
|
|
useEffect(() => {
|
|
if (!searchOpen || !searchQuery.trim() || totalMatches === 0) return;
|
|
|
|
// Find the current match element and scroll to it
|
|
const container = mode === 'edit' ? textareaRef.current : previewRef.current;
|
|
if (!container) return;
|
|
|
|
// For preview mode, find and scroll to the highlighted match
|
|
if (mode === 'preview') {
|
|
const marks = previewRef.current?.querySelectorAll('mark.search-match');
|
|
if (marks && marks.length > 0 && currentMatchIndex >= 0 && currentMatchIndex < marks.length) {
|
|
marks.forEach((mark, i) => {
|
|
const el = mark as HTMLElement;
|
|
if (i === currentMatchIndex) {
|
|
el.style.backgroundColor = theme.colors.accent;
|
|
el.style.color = '#fff';
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
} else {
|
|
el.style.backgroundColor = '#ffd700';
|
|
el.style.color = '#000';
|
|
}
|
|
});
|
|
}
|
|
} else if (mode === 'edit') {
|
|
// For edit mode, find the match position in the text and scroll
|
|
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(escapedQuery, 'gi');
|
|
let matchCount = 0;
|
|
let match;
|
|
let matchPosition = -1;
|
|
|
|
while ((match = regex.exec(localContent)) !== null) {
|
|
if (matchCount === currentMatchIndex) {
|
|
matchPosition = match.index;
|
|
break;
|
|
}
|
|
matchCount++;
|
|
}
|
|
|
|
if (matchPosition >= 0 && textareaRef.current) {
|
|
// Calculate approximate scroll position based on character position
|
|
const textarea = textareaRef.current;
|
|
const textBeforeMatch = localContent.substring(0, matchPosition);
|
|
const lineCount = (textBeforeMatch.match(/\n/g) || []).length;
|
|
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 20;
|
|
const scrollTarget = Math.max(0, lineCount * lineHeight - textarea.clientHeight / 2);
|
|
textarea.scrollTop = scrollTarget;
|
|
|
|
// Also select the match text
|
|
textarea.setSelectionRange(matchPosition, matchPosition + searchQuery.length);
|
|
}
|
|
}
|
|
}, [currentMatchIndex, searchOpen, searchQuery, totalMatches, mode, localContent, theme.colors.accent]);
|
|
|
|
// Handle image paste
|
|
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
|
if (isLocked || !folderPath || !selectedFile) {
|
|
console.log('[AutoRun] Paste blocked:', { isLocked, folderPath, selectedFile });
|
|
return;
|
|
}
|
|
|
|
const items = e.clipboardData?.items;
|
|
if (!items) {
|
|
console.log('[AutoRun] No clipboard items');
|
|
return;
|
|
}
|
|
|
|
console.log('[AutoRun] Clipboard items:', items.length);
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
console.log('[AutoRun] Item', i, ':', item.type);
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
|
|
const file = item.getAsFile();
|
|
if (!file) {
|
|
console.log('[AutoRun] Could not get file from item');
|
|
continue;
|
|
}
|
|
|
|
console.log('[AutoRun] Processing image:', file.name, file.type, file.size);
|
|
|
|
// Read as base64
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
const base64Data = event.target?.result as string;
|
|
if (!base64Data) {
|
|
console.log('[AutoRun] No base64 data');
|
|
return;
|
|
}
|
|
|
|
// Extract the base64 content without the data URL prefix
|
|
const base64Content = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
|
const extension = item.type.split('/')[1] || 'png';
|
|
|
|
console.log('[AutoRun] Saving image to:', folderPath, 'doc:', selectedFile, 'ext:', extension);
|
|
|
|
// Save to Auto Run folder using the new API
|
|
const result = await window.maestro.autorun.saveImage(folderPath, selectedFile, base64Content, extension);
|
|
console.log('[AutoRun] Save result:', result);
|
|
if (result.success && result.relativePath) {
|
|
// Update attachments list with the relative path
|
|
const filename = result.relativePath.split('/').pop() || result.relativePath;
|
|
setAttachmentsList(prev => [...prev, result.relativePath!]);
|
|
setAttachmentPreviews(prev => new Map(prev).set(result.relativePath!, base64Data));
|
|
|
|
// Insert markdown reference at cursor position using relative path
|
|
const textarea = textareaRef.current;
|
|
if (textarea) {
|
|
const cursorPos = textarea.selectionStart;
|
|
const textBefore = localContent.substring(0, cursorPos);
|
|
const textAfter = localContent.substring(cursorPos);
|
|
const imageMarkdown = ``;
|
|
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
|
|
// Add newlines if not at start of line
|
|
let prefix = '';
|
|
let suffix = '';
|
|
if (textBefore.length > 0 && !textBefore.endsWith('\n')) {
|
|
prefix = '\n';
|
|
}
|
|
if (textAfter.length > 0 && !textAfter.startsWith('\n')) {
|
|
suffix = '\n';
|
|
}
|
|
|
|
const newContent = textBefore + prefix + imageMarkdown + suffix + textAfter;
|
|
// Update local state and sync to parent immediately for explicit user action
|
|
setLocalContent(newContent);
|
|
handleContentChange(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
|
|
// Move cursor after the inserted markdown
|
|
const newCursorPos = cursorPos + prefix.length + imageMarkdown.length + suffix.length;
|
|
setTimeout(() => {
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
|
textarea.focus();
|
|
}, 0);
|
|
}
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
break; // Only handle first image
|
|
}
|
|
}
|
|
}, [localContent, isLocked, handleContentChange, folderPath, selectedFile, pushUndoState]);
|
|
|
|
// Handle file input for manual image upload
|
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file || !folderPath || !selectedFile) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
const base64Data = event.target?.result as string;
|
|
if (!base64Data) return;
|
|
|
|
// Extract the base64 content without the data URL prefix
|
|
const base64Content = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
|
const extension = file.name.split('.').pop() || 'png';
|
|
|
|
// Save to Auto Run folder using the new API
|
|
const result = await window.maestro.autorun.saveImage(folderPath, selectedFile, base64Content, extension);
|
|
if (result.success && result.relativePath) {
|
|
const filename = result.relativePath.split('/').pop() || result.relativePath;
|
|
setAttachmentsList(prev => [...prev, result.relativePath!]);
|
|
setAttachmentPreviews(prev => new Map(prev).set(result.relativePath!, base64Data));
|
|
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
|
|
// Insert at end of content - update local and sync to parent immediately
|
|
const imageMarkdown = `\n\n`;
|
|
const newContent = localContent + imageMarkdown;
|
|
setLocalContent(newContent);
|
|
handleContentChange(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
// Reset input so same file can be selected again
|
|
e.target.value = '';
|
|
}, [localContent, handleContentChange, folderPath, selectedFile, pushUndoState]);
|
|
|
|
// Handle removing an attachment (relativePath is like "images/{docName}-{timestamp}.{ext}")
|
|
const handleRemoveAttachment = useCallback(async (relativePath: string) => {
|
|
if (!folderPath) return;
|
|
|
|
// Delete the image file
|
|
await window.maestro.autorun.deleteImage(folderPath, relativePath);
|
|
setAttachmentsList(prev => prev.filter(f => f !== relativePath));
|
|
setAttachmentPreviews(prev => {
|
|
const newMap = new Map(prev);
|
|
newMap.delete(relativePath);
|
|
return newMap;
|
|
});
|
|
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
|
|
// Extract just the filename for the alt text pattern
|
|
const filename = relativePath.split('/').pop() || relativePath;
|
|
// Remove the markdown reference from content - update local and sync to parent immediately
|
|
// Match both the full relative path and just filename in the alt text
|
|
const escapedPath = relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(`!\\[${escapedFilename}\\]\\(${escapedPath}\\)\\n?`, 'g');
|
|
const newContent = localContent.replace(regex, '');
|
|
setLocalContent(newContent);
|
|
handleContentChange(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
|
|
// Clear from cache
|
|
imageCache.delete(`${folderPath}:${relativePath}`);
|
|
}, [localContent, handleContentChange, folderPath, pushUndoState]);
|
|
|
|
// Lightbox helpers - handles both attachment filenames and external URLs
|
|
const openLightboxByFilename = useCallback((filenameOrUrl: string) => {
|
|
// Check if it's an external URL (http/https/data:)
|
|
if (filenameOrUrl.startsWith('http://') || filenameOrUrl.startsWith('https://') || filenameOrUrl.startsWith('data:')) {
|
|
setLightboxExternalUrl(filenameOrUrl);
|
|
setLightboxFilename(filenameOrUrl); // Use URL as display name
|
|
} else {
|
|
// It's an attachment filename
|
|
setLightboxExternalUrl(null);
|
|
setLightboxFilename(filenameOrUrl);
|
|
}
|
|
setLightboxCopied(false);
|
|
}, []);
|
|
|
|
const closeLightbox = useCallback(() => {
|
|
setLightboxFilename(null);
|
|
setLightboxExternalUrl(null);
|
|
setLightboxCopied(false);
|
|
}, []);
|
|
|
|
const lightboxCurrentIndex = lightboxFilename ? attachmentsList.indexOf(lightboxFilename) : -1;
|
|
const canNavigateLightbox = attachmentsList.length > 1;
|
|
|
|
const goToPrevImage = useCallback(() => {
|
|
if (canNavigateLightbox) {
|
|
const newIndex = lightboxCurrentIndex > 0 ? lightboxCurrentIndex - 1 : attachmentsList.length - 1;
|
|
setLightboxFilename(attachmentsList[newIndex]);
|
|
setLightboxCopied(false);
|
|
}
|
|
}, [canNavigateLightbox, lightboxCurrentIndex, attachmentsList]);
|
|
|
|
const goToNextImage = useCallback(() => {
|
|
if (canNavigateLightbox) {
|
|
const newIndex = lightboxCurrentIndex < attachmentsList.length - 1 ? lightboxCurrentIndex + 1 : 0;
|
|
setLightboxFilename(attachmentsList[newIndex]);
|
|
setLightboxCopied(false);
|
|
}
|
|
}, [canNavigateLightbox, lightboxCurrentIndex, attachmentsList]);
|
|
|
|
const copyLightboxImageToClipboard = useCallback(async () => {
|
|
if (!lightboxFilename) return;
|
|
|
|
// Get image URL from external URL or attachment previews
|
|
const imageUrl = lightboxExternalUrl || attachmentPreviews.get(lightboxFilename);
|
|
if (!imageUrl) return;
|
|
|
|
try {
|
|
const response = await fetch(imageUrl);
|
|
const blob = await response.blob();
|
|
await navigator.clipboard.write([
|
|
new ClipboardItem({ [blob.type]: blob })
|
|
]);
|
|
setLightboxCopied(true);
|
|
setTimeout(() => setLightboxCopied(false), 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy image to clipboard:', err);
|
|
}
|
|
}, [lightboxFilename, lightboxExternalUrl, attachmentPreviews]);
|
|
|
|
const deleteLightboxImage = useCallback(async () => {
|
|
if (!lightboxFilename || !folderPath) return;
|
|
|
|
// Store the current index before deletion
|
|
const currentIndex = lightboxCurrentIndex;
|
|
const totalImages = attachmentsList.length;
|
|
|
|
// Delete the image file using autorun API
|
|
await window.maestro.autorun.deleteImage(folderPath, lightboxFilename);
|
|
setAttachmentsList(prev => prev.filter(f => f !== lightboxFilename));
|
|
setAttachmentPreviews(prev => {
|
|
const newMap = new Map(prev);
|
|
newMap.delete(lightboxFilename);
|
|
return newMap;
|
|
});
|
|
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
|
|
// Extract just the filename for the alt text pattern
|
|
const filename = lightboxFilename.split('/').pop() || lightboxFilename;
|
|
// Remove the markdown reference from content - update local and sync to parent immediately
|
|
const escapedPath = lightboxFilename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const escapedFilename = filename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const regex = new RegExp(`!\\[${escapedFilename}\\]\\(${escapedPath}\\)\\n?`, 'g');
|
|
const newContent = localContent.replace(regex, '');
|
|
setLocalContent(newContent);
|
|
handleContentChange(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
|
|
// Clear from cache
|
|
imageCache.delete(`${folderPath}:${lightboxFilename}`);
|
|
|
|
// Navigate to next/prev image or close lightbox
|
|
if (totalImages <= 1) {
|
|
// No more images, close lightbox
|
|
closeLightbox();
|
|
} else if (currentIndex >= totalImages - 1) {
|
|
// Was last image, go to previous
|
|
const newList = attachmentsList.filter(f => f !== lightboxFilename);
|
|
setLightboxFilename(newList[newList.length - 1] || null);
|
|
} else {
|
|
// Go to next image (same index in new list)
|
|
const newList = attachmentsList.filter(f => f !== lightboxFilename);
|
|
setLightboxFilename(newList[currentIndex] || null);
|
|
}
|
|
}, [lightboxFilename, lightboxCurrentIndex, attachmentsList, folderPath, localContent, handleContentChange, closeLightbox, pushUndoState]);
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Let template autocomplete handle keys first
|
|
if (handleAutocompleteKeyDown(e)) {
|
|
return;
|
|
}
|
|
|
|
// Cmd+Z to undo, Cmd+Shift+Z to redo
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.shiftKey) {
|
|
handleRedo();
|
|
} else {
|
|
handleUndo();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Command-E to toggle between edit and preview
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggleMode();
|
|
return;
|
|
}
|
|
|
|
// Command-F to open search in edit mode (without Shift)
|
|
// Cmd+Shift+F is allowed to propagate to the global handler for "Go to Files"
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openSearch();
|
|
return;
|
|
}
|
|
|
|
// Command-L to insert a markdown checkbox
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const textarea = e.currentTarget;
|
|
const cursorPos = textarea.selectionStart;
|
|
const textBeforeCursor = localContent.substring(0, cursorPos);
|
|
const textAfterCursor = localContent.substring(cursorPos);
|
|
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
|
|
// Check if we're at the start of a line or have text before
|
|
const lastNewline = textBeforeCursor.lastIndexOf('\n');
|
|
const lineStart = lastNewline === -1 ? 0 : lastNewline + 1;
|
|
const textOnCurrentLine = textBeforeCursor.substring(lineStart);
|
|
|
|
let newContent: string;
|
|
let newCursorPos: number;
|
|
|
|
if (textOnCurrentLine.length === 0) {
|
|
// At start of line, just insert checkbox
|
|
newContent = textBeforeCursor + '- [ ] ' + textAfterCursor;
|
|
newCursorPos = cursorPos + 6; // "- [ ] " is 6 chars
|
|
} else {
|
|
// In middle of line, insert newline then checkbox
|
|
newContent = textBeforeCursor + '\n- [ ] ' + textAfterCursor;
|
|
newCursorPos = cursorPos + 7; // "\n- [ ] " is 7 chars
|
|
}
|
|
|
|
setLocalContent(newContent);
|
|
// Update lastUndoSnapshot since we pushed state explicitly
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
|
}
|
|
}, 0);
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
const textarea = e.currentTarget;
|
|
const cursorPos = textarea.selectionStart;
|
|
const textBeforeCursor = localContent.substring(0, cursorPos);
|
|
const textAfterCursor = localContent.substring(cursorPos);
|
|
const currentLineStart = textBeforeCursor.lastIndexOf('\n') + 1;
|
|
const currentLine = textBeforeCursor.substring(currentLineStart);
|
|
|
|
// Check for list patterns
|
|
const unorderedListMatch = currentLine.match(/^(\s*)([-*])\s+/);
|
|
const orderedListMatch = currentLine.match(/^(\s*)(\d+)\.\s+/);
|
|
const taskListMatch = currentLine.match(/^(\s*)- \[([ x])\]\s+/);
|
|
|
|
if (taskListMatch) {
|
|
// Task list: continue with unchecked checkbox
|
|
const indent = taskListMatch[1];
|
|
e.preventDefault();
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
const newContent = textBeforeCursor + '\n' + indent + '- [ ] ' + textAfterCursor;
|
|
setLocalContent(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
const newPos = cursorPos + indent.length + 7; // "\n" + indent + "- [ ] "
|
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
|
}
|
|
}, 0);
|
|
} else if (unorderedListMatch) {
|
|
// Unordered list: continue with same marker
|
|
const indent = unorderedListMatch[1];
|
|
const marker = unorderedListMatch[2];
|
|
e.preventDefault();
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
const newContent = textBeforeCursor + '\n' + indent + marker + ' ' + textAfterCursor;
|
|
setLocalContent(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
const newPos = cursorPos + indent.length + 3; // "\n" + indent + marker + " "
|
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
|
}
|
|
}, 0);
|
|
} else if (orderedListMatch) {
|
|
// Ordered list: increment number
|
|
const indent = orderedListMatch[1];
|
|
const num = parseInt(orderedListMatch[2]);
|
|
e.preventDefault();
|
|
// Push undo state before modifying content
|
|
pushUndoState();
|
|
const newContent = textBeforeCursor + '\n' + indent + (num + 1) + '. ' + textAfterCursor;
|
|
setLocalContent(newContent);
|
|
lastUndoSnapshotRef.current = newContent;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
const newPos = cursorPos + indent.length + (num + 1).toString().length + 3; // "\n" + indent + num + ". "
|
|
textareaRef.current.setSelectionRange(newPos, newPos);
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Memoize prose CSS styles - only regenerate when theme changes
|
|
const proseStyles = useMemo(() => `
|
|
.prose h1 { color: ${theme.colors.textMain}; font-size: 2em; font-weight: bold; margin: 0.67em 0; }
|
|
.prose h2 { color: ${theme.colors.textMain}; font-size: 1.5em; font-weight: bold; margin: 0.75em 0; }
|
|
.prose h3 { color: ${theme.colors.textMain}; font-size: 1.17em; font-weight: bold; margin: 0.83em 0; }
|
|
.prose h4 { color: ${theme.colors.textMain}; font-size: 1em; font-weight: bold; margin: 1em 0; }
|
|
.prose h5 { color: ${theme.colors.textMain}; font-size: 0.83em; font-weight: bold; margin: 1.17em 0; }
|
|
.prose h6 { color: ${theme.colors.textMain}; font-size: 0.67em; font-weight: bold; margin: 1.33em 0; }
|
|
.prose p { color: ${theme.colors.textMain}; margin: 0.5em 0; }
|
|
.prose ul, .prose ol { color: ${theme.colors.textMain}; margin: 0.5em 0; padding-left: 1.5em; }
|
|
.prose ul { list-style-type: disc; }
|
|
.prose ol { list-style-type: decimal; }
|
|
.prose li { margin: 0.25em 0; display: list-item; }
|
|
.prose li::marker { color: ${theme.colors.textMain}; }
|
|
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.5em; }
|
|
.prose code { background-color: ${theme.colors.bgActivity}; color: ${theme.colors.textMain}; padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; }
|
|
.prose pre { background-color: ${theme.colors.bgActivity}; color: ${theme.colors.textMain}; padding: 1em; border-radius: 6px; overflow-x: auto; }
|
|
.prose pre code { background: none; padding: 0; }
|
|
.prose blockquote { border-left: 4px solid ${theme.colors.border}; padding-left: 1em; margin: 0.5em 0; color: ${theme.colors.textDim}; }
|
|
.prose a { color: ${theme.colors.accent}; text-decoration: underline; }
|
|
.prose hr { border: none; border-top: 2px solid ${theme.colors.border}; margin: 1em 0; }
|
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
|
.prose th, .prose td { border: 1px solid ${theme.colors.border}; padding: 0.5em; text-align: left; }
|
|
.prose th { background-color: ${theme.colors.bgActivity}; font-weight: bold; }
|
|
.prose strong { font-weight: bold; }
|
|
.prose em { font-style: italic; }
|
|
.prose input[type="checkbox"] {
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid ${theme.colors.accent};
|
|
border-radius: 3px;
|
|
background-color: transparent;
|
|
cursor: pointer;
|
|
vertical-align: middle;
|
|
margin-right: 8px;
|
|
position: relative;
|
|
}
|
|
.prose input[type="checkbox"]:checked {
|
|
background-color: ${theme.colors.accent};
|
|
border-color: ${theme.colors.accent};
|
|
}
|
|
.prose input[type="checkbox"]:checked::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 4px;
|
|
top: 1px;
|
|
width: 5px;
|
|
height: 9px;
|
|
border: solid ${theme.colors.bgMain};
|
|
border-width: 0 2px 2px 0;
|
|
transform: rotate(45deg);
|
|
}
|
|
.prose input[type="checkbox"]:hover {
|
|
border-color: ${theme.colors.highlight};
|
|
box-shadow: 0 0 4px ${theme.colors.accent}40;
|
|
}
|
|
.prose li:has(> input[type="checkbox"]) {
|
|
list-style-type: none;
|
|
margin-left: -1.5em;
|
|
}
|
|
`, [theme]);
|
|
|
|
// Memoize ReactMarkdown components - only regenerate when dependencies change
|
|
const markdownComponents = useMemo(() => ({
|
|
code: ({ node, inline, className, children, ...props }: any) => {
|
|
const match = (className || '').match(/language-(\w+)/);
|
|
const language = match ? match[1] : 'text';
|
|
const codeContent = String(children).replace(/\n$/, '');
|
|
|
|
// Handle mermaid code blocks
|
|
if (!inline && language === 'mermaid') {
|
|
return <MermaidRenderer chart={codeContent} theme={theme} />;
|
|
}
|
|
|
|
return !inline && match ? (
|
|
<SyntaxHighlighter
|
|
language={language}
|
|
style={vscDarkPlus}
|
|
customStyle={{
|
|
margin: '0.5em 0',
|
|
padding: '1em',
|
|
background: theme.colors.bgActivity,
|
|
fontSize: '0.9em',
|
|
borderRadius: '6px',
|
|
}}
|
|
PreTag="div"
|
|
>
|
|
{codeContent}
|
|
</SyntaxHighlighter>
|
|
) : (
|
|
<code className={className} {...props}>
|
|
{children}
|
|
</code>
|
|
);
|
|
},
|
|
img: ({ src, alt, ...props }: any) => (
|
|
<AttachmentImage
|
|
src={src}
|
|
alt={alt}
|
|
folderPath={folderPath}
|
|
theme={theme}
|
|
onImageClick={openLightboxByFilename}
|
|
{...props}
|
|
/>
|
|
)
|
|
}), [theme, folderPath, openLightboxByFilename]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="h-full flex flex-col outline-none relative"
|
|
tabIndex={-1}
|
|
onKeyDown={(e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggleMode();
|
|
}
|
|
// CMD+F to open search (works in both modes from container)
|
|
// Only intercept Cmd+F (without Shift) - let Cmd+Shift+F propagate to global "Go to Files" handler
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'f' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openSearch();
|
|
}
|
|
}}
|
|
>
|
|
{/* Select Folder Button - shown when no folder is configured */}
|
|
{!folderPath && (
|
|
<div className="pt-2 flex justify-center">
|
|
<button
|
|
onClick={onOpenSetup}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors hover:opacity-90"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
}}
|
|
>
|
|
<FolderOpen className="w-3.5 h-3.5" />
|
|
Select Auto Run Folder
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mode Toggle */}
|
|
<div className="flex gap-2 mb-3 justify-center pt-2">
|
|
<button
|
|
onClick={() => !isLocked && setMode('edit')}
|
|
disabled={isLocked}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${
|
|
mode === 'edit' && !isLocked ? 'font-semibold' : ''
|
|
} ${isLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
style={{
|
|
backgroundColor: mode === 'edit' && !isLocked ? theme.colors.bgActivity : 'transparent',
|
|
color: isLocked ? theme.colors.textDim : (mode === 'edit' ? theme.colors.textMain : theme.colors.textDim),
|
|
border: `1px solid ${mode === 'edit' && !isLocked ? theme.colors.accent : theme.colors.border}`
|
|
}}
|
|
title={isLocked ? 'Editing disabled while Auto Run active' : 'Edit document'}
|
|
>
|
|
<Edit className="w-3.5 h-3.5" />
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('preview')}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${
|
|
mode === 'preview' || isLocked ? 'font-semibold' : ''
|
|
}`}
|
|
style={{
|
|
backgroundColor: mode === 'preview' || isLocked ? theme.colors.bgActivity : 'transparent',
|
|
color: mode === 'preview' || isLocked ? theme.colors.textMain : theme.colors.textDim,
|
|
border: `1px solid ${mode === 'preview' || isLocked ? theme.colors.accent : theme.colors.border}`
|
|
}}
|
|
title="Preview document"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
Preview
|
|
</button>
|
|
{/* Image upload button (edit mode only) */}
|
|
{mode === 'edit' && !isLocked && (
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors hover:opacity-80"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: theme.colors.textDim,
|
|
border: `1px solid ${theme.colors.border}`
|
|
}}
|
|
title="Add image (or paste from clipboard)"
|
|
>
|
|
<Image className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
{/* Run / Stop button */}
|
|
{isLocked ? (
|
|
<button
|
|
onClick={onStopBatchRun}
|
|
disabled={isStopping}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors font-semibold ${isStopping ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
style={{
|
|
backgroundColor: theme.colors.error,
|
|
color: 'white',
|
|
border: `1px solid ${theme.colors.error}`
|
|
}}
|
|
title={isStopping ? 'Stopping after current task...' : 'Stop batch run'}
|
|
>
|
|
{isStopping ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<Square className="w-3.5 h-3.5" />
|
|
)}
|
|
{isStopping ? 'Stopping...' : 'Stop'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
// Sync local content to parent before opening batch runner
|
|
// This ensures Run uses the latest edits, not stale content
|
|
syncContentToParent();
|
|
onOpenBatchRunner?.();
|
|
}}
|
|
disabled={isAgentBusy}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded text-xs transition-colors ${isAgentBusy ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-90'}`}
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
border: `1px solid ${theme.colors.accent}`
|
|
}}
|
|
title={isAgentBusy ? "Cannot run while agent is thinking" : "Run batch processing on Auto Run tasks"}
|
|
>
|
|
<Play className="w-3.5 h-3.5" />
|
|
Run
|
|
</button>
|
|
)}
|
|
{/* Help button */}
|
|
<button
|
|
onClick={() => setHelpModalOpen(true)}
|
|
className="flex items-center justify-center w-8 h-8 rounded-full transition-colors hover:bg-white/10"
|
|
style={{ color: theme.colors.textDim }}
|
|
title="Learn about Auto Runner"
|
|
>
|
|
<HelpCircle className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Document Selector */}
|
|
{folderPath && (
|
|
<div className="px-2 mb-2">
|
|
<AutoRunDocumentSelector
|
|
theme={theme}
|
|
documents={documentList}
|
|
documentTree={documentTree as import('./AutoRunDocumentSelector').DocTreeNode[] | undefined}
|
|
selectedDocument={selectedFile}
|
|
onSelectDocument={onSelectDocument}
|
|
onRefresh={onRefresh}
|
|
onChangeFolder={onOpenSetup}
|
|
onCreateDocument={onCreateDocument}
|
|
isLoading={isLoadingDocuments}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Attached Images Preview (edit mode) */}
|
|
{mode === 'edit' && attachmentsList.length > 0 && (
|
|
<div
|
|
className="px-2 py-2 mx-2 mb-2 rounded"
|
|
style={{ backgroundColor: theme.colors.bgActivity }}
|
|
>
|
|
<button
|
|
onClick={() => setAttachmentsExpanded(!attachmentsExpanded)}
|
|
className="w-full flex items-center gap-1 text-[10px] uppercase font-semibold hover:opacity-80 transition-opacity"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
{attachmentsExpanded ? (
|
|
<ChevronDown className="w-3 h-3" />
|
|
) : (
|
|
<ChevronRight className="w-3 h-3" />
|
|
)}
|
|
Attached Images ({attachmentsList.length})
|
|
</button>
|
|
{attachmentsExpanded && (
|
|
<div className="flex flex-wrap gap-1 mt-2">
|
|
{attachmentsList.map(filename => (
|
|
<ImagePreview
|
|
key={filename}
|
|
src={attachmentPreviews.get(filename) || ''}
|
|
filename={filename}
|
|
theme={theme}
|
|
onRemove={() => handleRemoveAttachment(filename)}
|
|
onImageClick={openLightboxByFilename}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Search Bar */}
|
|
{searchOpen && (
|
|
<div
|
|
className="mx-2 mb-2 flex items-center gap-2 px-3 py-2 rounded"
|
|
style={{ backgroundColor: theme.colors.bgActivity, border: `1px solid ${theme.colors.accent}` }}
|
|
>
|
|
<Search className="w-4 h-4 shrink-0" style={{ color: theme.colors.accent }} />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
closeSearch();
|
|
} else if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
goToNextMatch();
|
|
} else if (e.key === 'Enter' && e.shiftKey) {
|
|
e.preventDefault();
|
|
goToPrevMatch();
|
|
}
|
|
}}
|
|
placeholder={mode === 'edit' ? "Search... (⌘F to open, Enter: next, Shift+Enter: prev)" : "Search... (/ to open, Enter: next, Shift+Enter: prev)"}
|
|
className="flex-1 bg-transparent outline-none text-sm"
|
|
style={{ color: theme.colors.textMain }}
|
|
autoFocus
|
|
/>
|
|
{searchQuery.trim() && (
|
|
<>
|
|
<span className="text-xs whitespace-nowrap" style={{ color: theme.colors.textDim }}>
|
|
{totalMatches > 0 ? `${currentMatchIndex + 1}/${totalMatches}` : 'No matches'}
|
|
</span>
|
|
<button
|
|
onClick={goToPrevMatch}
|
|
disabled={totalMatches === 0}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors disabled:opacity-30"
|
|
style={{ color: theme.colors.textDim }}
|
|
title="Previous match (Shift+Enter)"
|
|
>
|
|
<ChevronUp className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={goToNextMatch}
|
|
disabled={totalMatches === 0}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors disabled:opacity-30"
|
|
style={{ color: theme.colors.textDim }}
|
|
title="Next match (Enter)"
|
|
>
|
|
<ChevronDown className="w-4 h-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={closeSearch}
|
|
className="p-1 rounded hover:bg-white/10 transition-colors"
|
|
style={{ color: theme.colors.textDim }}
|
|
title="Close search (Esc)"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
{/* Empty folder state - show when folder is configured but has no documents */}
|
|
{folderPath && documentList.length === 0 && !isLoadingDocuments ? (
|
|
<div
|
|
className="h-full flex flex-col items-center justify-center text-center px-6"
|
|
style={{ color: theme.colors.textDim }}
|
|
>
|
|
<div
|
|
className="w-16 h-16 rounded-full flex items-center justify-center mb-4"
|
|
style={{ backgroundColor: theme.colors.bgActivity }}
|
|
>
|
|
<FileText className="w-8 h-8" style={{ color: theme.colors.textDim }} />
|
|
</div>
|
|
<h3
|
|
className="text-lg font-semibold mb-2"
|
|
style={{ color: theme.colors.textMain }}
|
|
>
|
|
No Documents Found
|
|
</h3>
|
|
<p className="mb-4 max-w-xs text-sm">
|
|
The selected folder doesn't contain any markdown (.md) files.
|
|
</p>
|
|
<p className="mb-6 max-w-xs text-xs" style={{ color: theme.colors.textDim }}>
|
|
Create a markdown file in the folder to get started, or select a different folder.
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleEmptyStateRefresh}
|
|
className="flex items-center gap-2 px-4 py-2 rounded text-sm transition-colors hover:opacity-90"
|
|
style={{
|
|
backgroundColor: 'transparent',
|
|
color: theme.colors.textMain,
|
|
border: `1px solid ${theme.colors.border}`,
|
|
}}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isRefreshingEmpty ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</button>
|
|
<button
|
|
onClick={onOpenSetup}
|
|
className="flex items-center gap-2 px-4 py-2 rounded text-sm transition-colors hover:opacity-90"
|
|
style={{
|
|
backgroundColor: theme.colors.accent,
|
|
color: theme.colors.accentForeground,
|
|
}}
|
|
>
|
|
<FolderOpen className="w-4 h-4" />
|
|
Change Folder
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : mode === 'edit' ? (
|
|
<div className="relative w-full h-full">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={localContent}
|
|
onChange={(e) => {
|
|
if (!isLocked) {
|
|
isEditingRef.current = true;
|
|
// Schedule undo snapshot with current content before the change
|
|
const previousContent = localContent;
|
|
const previousCursor = textareaRef.current?.selectionStart || 0;
|
|
// Use autocomplete handler to detect "{{" triggers
|
|
handleAutocompleteChange(e);
|
|
scheduleUndoSnapshot(previousContent, previousCursor);
|
|
}
|
|
}}
|
|
onFocus={() => { isEditingRef.current = true; }}
|
|
onBlur={syncContentToParent}
|
|
onKeyDown={!isLocked ? handleKeyDown : undefined}
|
|
onPaste={handlePaste}
|
|
placeholder="Capture notes, images, and tasks in Markdown. (type {{ for variables)"
|
|
readOnly={isLocked}
|
|
className={`w-full h-full border rounded p-4 bg-transparent outline-none resize-none font-mono text-sm ${isLocked ? 'cursor-not-allowed opacity-70' : ''}`}
|
|
style={{
|
|
borderColor: isLocked ? theme.colors.warning : theme.colors.border,
|
|
color: theme.colors.textMain,
|
|
backgroundColor: isLocked ? theme.colors.bgActivity + '30' : 'transparent'
|
|
}}
|
|
/>
|
|
{/* Template Variable Autocomplete Dropdown */}
|
|
<TemplateAutocompleteDropdown
|
|
ref={autocompleteRef}
|
|
theme={theme}
|
|
state={autocompleteState}
|
|
onSelect={selectVariable}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={previewRef}
|
|
className="border rounded p-4 prose prose-sm max-w-none outline-none"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
toggleMode();
|
|
}
|
|
// '/' to open search in preview mode (mutually exclusive with Cmd+F in edit mode)
|
|
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openSearch();
|
|
}
|
|
}}
|
|
onScroll={handlePreviewScroll}
|
|
style={{
|
|
borderColor: theme.colors.border,
|
|
color: theme.colors.textMain,
|
|
fontSize: '13px'
|
|
}}
|
|
>
|
|
<style>{proseStyles}</style>
|
|
{searchOpen && searchQuery.trim() ? (
|
|
// When searching, show raw text with highlights for easy search navigation
|
|
<SearchHighlightedContent
|
|
content={localContent || '*No content yet.*'}
|
|
searchQuery={searchQuery}
|
|
currentMatchIndex={currentMatchIndex}
|
|
theme={theme}
|
|
/>
|
|
) : (
|
|
<ReactMarkdown
|
|
remarkPlugins={REMARK_PLUGINS}
|
|
components={markdownComponents}
|
|
>
|
|
{localContent || '*No content yet. Switch to Edit mode to start writing.*'}
|
|
</ReactMarkdown>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Help Modal */}
|
|
{helpModalOpen && (
|
|
<AutoRunnerHelpModal
|
|
theme={theme}
|
|
onClose={() => setHelpModalOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Lightbox for viewing images with navigation, copy, and delete */}
|
|
{lightboxFilename && (lightboxExternalUrl || attachmentPreviews.get(lightboxFilename)) && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
|
onClick={closeLightbox}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); }
|
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); goToPrevImage(); }
|
|
else if (e.key === 'ArrowRight') { e.preventDefault(); goToNextImage(); }
|
|
else if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
e.preventDefault();
|
|
// Only allow delete for attachments, not external URLs
|
|
if (!lightboxExternalUrl) deleteLightboxImage();
|
|
}
|
|
}}
|
|
tabIndex={-1}
|
|
ref={(el) => el?.focus()}
|
|
>
|
|
{/* Previous button - only for attachments carousel */}
|
|
{!lightboxExternalUrl && canNavigateLightbox && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); goToPrevImage(); }}
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 text-white rounded-full p-3 backdrop-blur-sm transition-colors"
|
|
title="Previous image (←)"
|
|
>
|
|
<ChevronLeft className="w-6 h-6" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Image */}
|
|
<img
|
|
src={lightboxExternalUrl || attachmentPreviews.get(lightboxFilename)}
|
|
alt={lightboxFilename}
|
|
className="max-w-[90%] max-h-[90%] rounded shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
|
|
{/* Top right buttons: Copy and Delete */}
|
|
<div className="absolute top-4 right-4 flex gap-2">
|
|
{/* Copy to clipboard */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); copyLightboxImageToClipboard(); }}
|
|
className="bg-white/10 hover:bg-white/20 text-white rounded-full p-3 backdrop-blur-sm transition-colors flex items-center gap-2"
|
|
title="Copy image to clipboard"
|
|
>
|
|
{lightboxCopied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
|
{lightboxCopied && <span className="text-sm">Copied!</span>}
|
|
</button>
|
|
|
|
{/* Delete image - only for attachments, not external URLs */}
|
|
{!lightboxExternalUrl && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); deleteLightboxImage(); }}
|
|
className="bg-red-500/80 hover:bg-red-500 text-white rounded-full p-3 backdrop-blur-sm transition-colors"
|
|
title="Delete image (Delete key)"
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Close button */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); closeLightbox(); }}
|
|
className="bg-white/10 hover:bg-white/20 text-white rounded-full p-3 backdrop-blur-sm transition-colors"
|
|
title="Close (ESC)"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Next button - only for attachments carousel */}
|
|
{!lightboxExternalUrl && canNavigateLightbox && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); goToNextImage(); }}
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/20 text-white rounded-full p-3 backdrop-blur-sm transition-colors"
|
|
title="Next image (→)"
|
|
>
|
|
<ChevronRight className="w-6 h-6" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Bottom info */}
|
|
<div className="absolute bottom-10 text-white text-sm opacity-70 text-center max-w-[80%]">
|
|
<div className="truncate">{lightboxFilename}</div>
|
|
<div className="mt-1">
|
|
{!lightboxExternalUrl && canNavigateLightbox ? `Image ${lightboxCurrentIndex + 1} of ${attachmentsList.length} • ← → to navigate • ` : ''}
|
|
{!lightboxExternalUrl ? 'Delete to remove • ' : ''}ESC to close
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// Memoized AutoRun component with custom comparison to prevent unnecessary re-renders
|
|
export const AutoRun = memo(AutoRunInner, (prevProps, nextProps) => {
|
|
// Only re-render when these specific props actually change
|
|
return (
|
|
prevProps.content === nextProps.content &&
|
|
prevProps.sessionId === nextProps.sessionId &&
|
|
prevProps.mode === nextProps.mode &&
|
|
prevProps.theme === nextProps.theme &&
|
|
// Document state
|
|
prevProps.folderPath === nextProps.folderPath &&
|
|
prevProps.selectedFile === nextProps.selectedFile &&
|
|
prevProps.documentList === nextProps.documentList &&
|
|
prevProps.isLoadingDocuments === nextProps.isLoadingDocuments &&
|
|
// Compare batch run state values, not object reference
|
|
prevProps.batchRunState?.isRunning === nextProps.batchRunState?.isRunning &&
|
|
prevProps.batchRunState?.isStopping === nextProps.batchRunState?.isStopping &&
|
|
prevProps.batchRunState?.currentTaskIndex === nextProps.batchRunState?.currentTaskIndex &&
|
|
prevProps.batchRunState?.totalTasks === nextProps.batchRunState?.totalTasks &&
|
|
// Session state affects UI (busy disables Run button)
|
|
prevProps.sessionState === nextProps.sessionState &&
|
|
// Callbacks are typically stable, but check identity
|
|
prevProps.onContentChange === nextProps.onContentChange &&
|
|
prevProps.onModeChange === nextProps.onModeChange &&
|
|
prevProps.onStateChange === nextProps.onStateChange &&
|
|
prevProps.onOpenBatchRunner === nextProps.onOpenBatchRunner &&
|
|
prevProps.onStopBatchRun === nextProps.onStopBatchRun &&
|
|
prevProps.onOpenSetup === nextProps.onOpenSetup &&
|
|
prevProps.onRefresh === nextProps.onRefresh &&
|
|
prevProps.onSelectDocument === nextProps.onSelectDocument
|
|
// Note: initialCursorPosition, initialEditScrollPos, initialPreviewScrollPos
|
|
// are intentionally NOT compared - they're only used on mount
|
|
);
|
|
});
|