## CHANGES

- Added wiki-style internal Markdown file links that open documents instantly 🔗
- Introduced `remarkFileLinks` plugin to resolve links from the document tree 🧩
- Converted `documentTree` into `FileNode` format for consistent link indexing 🌳
- Implemented click-to-navigate handler stripping `.md` for smart selection 🧭
- Memoized dynamic remark plugins so link support loads only when needed 
- Extended Markdown component options with new `onFileClick` callback 🧰
- Enhanced `<a>` rendering to route internal `maestro-file://` links safely 🛡️
- Added `data-maestro-file` fallback when rehype strips custom protocols 🏷️
- Kept external link behavior intact via `onExternalLinkClick` passthrough 🌐
This commit is contained in:
Pedram Amini
2025-12-25 13:22:24 -06:00
parent ad4e1c20a8
commit d7dfad4d8a
2 changed files with 53 additions and 9 deletions

View File

@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm';
import { Eye, Edit, Play, Square, HelpCircle, Loader2, Image, X, Search, ChevronDown, ChevronRight, FolderOpen, FileText, RefreshCw, Maximize2, AlertTriangle, SkipForward, XCircle } from 'lucide-react';
import { getEncoder, formatTokenCount } from '../utils/tokenCounter';
import type { BatchRunState, SessionState, Theme, Shortcut } from '../types';
import type { FileNode } from '../types/fileTree';
import { AutoRunnerHelpModal } from './AutoRunnerHelpModal';
import { MermaidRenderer } from './MermaidRenderer';
import { AutoRunDocumentSelector, DocumentTaskCount } from './AutoRunDocumentSelector';
@@ -15,9 +16,7 @@ import { useAutoRunImageHandling, imageCache } from '../hooks/useAutoRunImageHan
import { TemplateAutocompleteDropdown } from './TemplateAutocompleteDropdown';
import { generateAutoRunProseStyles, createMarkdownComponents } from '../utils/markdownConfig';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
// Memoize remarkPlugins array - it never changes
const REMARK_PLUGINS = [remarkGfm];
import { remarkFileLinks } from '../utils/remarkFileLinks';
interface AutoRunProps {
theme: Theme;
@@ -1095,6 +1094,39 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
}
}, [currentMatchIndex]);
// Convert documentTree to FileNode format for remarkFileLinks
const fileTree = useMemo((): FileNode[] => {
if (!documentTree) return [];
const convert = (nodes: typeof documentTree): FileNode[] => {
return nodes.map(node => ({
name: node.name,
type: node.type,
fullPath: node.path,
children: node.children ? convert(node.children as typeof documentTree) : undefined,
}));
};
return convert(documentTree);
}, [documentTree]);
// Handle file link clicks - navigate to the document
const handleFileClick = useCallback((filePath: string) => {
// filePath from remarkFileLinks will be like "Note.md" or "Subfolder/Note.md"
// onSelectDocument expects the path without extension for simple files,
// or the full relative path for nested files
const pathWithoutExt = filePath.replace(/\.md$/, '');
onSelectDocument(pathWithoutExt);
}, [onSelectDocument]);
// Memoize remarkPlugins - include remarkFileLinks when we have file tree
const remarkPlugins = useMemo(() => {
const plugins: any[] = [remarkGfm];
if (fileTree.length > 0) {
// cwd is empty since we're at the root of the Auto Run folder
plugins.push([remarkFileLinks, { fileTree, cwd: '' }]);
}
return plugins;
}, [fileTree]);
// Memoize ReactMarkdown components - only regenerate when dependencies change
// Uses shared utility from markdownConfig.ts with custom image renderer
const markdownComponents = useMemo(() => {
@@ -1104,6 +1136,8 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
customLanguageRenderers: {
mermaid: ({ code, theme: t }) => <MermaidRenderer chart={code} theme={t} />,
},
// Handle internal file links (wiki-style [[links]])
onFileClick: handleFileClick,
// Open external links in system browser
onExternalLinkClick: (href) => window.maestro.shell.openExternal(href),
// Add search highlighting when search is active with matches
@@ -1130,7 +1164,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
/>
),
};
}, [theme, folderPath, openLightboxByFilename, searchOpen, searchQuery, totalMatches, currentMatchIndex, handleMatchRendered]);
}, [theme, folderPath, openLightboxByFilename, searchOpen, searchQuery, totalMatches, currentMatchIndex, handleMatchRendered, handleFileClick]);
return (
<div
@@ -1576,7 +1610,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
>
<style>{proseStyles}</style>
<ReactMarkdown
remarkPlugins={REMARK_PLUGINS}
remarkPlugins={remarkPlugins}
components={markdownComponents}
>
{localContent || '*No content yet. Switch to Edit mode to start writing.*'}

View File

@@ -46,6 +46,8 @@ export interface MarkdownComponentsOptions {
imageRenderer?: React.ComponentType<{ src?: string; alt?: string }>;
/** Custom code block renderer for specific languages (e.g., mermaid) */
customLanguageRenderers?: Record<string, React.ComponentType<{ code: string; theme: Theme }>>;
/** Callback when internal file link is clicked (maestro-file:// protocol) */
onFileClick?: (filePath: string) => void;
/** Callback when external link is clicked - if not provided, uses default browser behavior */
onExternalLinkClick?: (href: string) => void;
/** Search highlighting options */
@@ -284,7 +286,7 @@ function highlightSearchMatches(
}
export function createMarkdownComponents(options: MarkdownComponentsOptions): Partial<Components> {
const { theme, imageRenderer, customLanguageRenderers = {}, onExternalLinkClick, searchHighlight } = options;
const { theme, imageRenderer, customLanguageRenderers = {}, onFileClick, onExternalLinkClick, searchHighlight } = options;
// Reset match counter at start of each render
globalMatchCounter = 0;
@@ -363,9 +365,15 @@ export function createMarkdownComponents(options: MarkdownComponentsOptions): Pa
};
}
// External link handler if provided
if (onExternalLinkClick) {
// Link handler - supports both internal file links and external links
if (onFileClick || onExternalLinkClick) {
components.a = ({ node, href, children, ...props }: any) => {
// Check for maestro-file:// protocol OR data-maestro-file attribute
// (data attribute is fallback when rehype strips custom protocols)
const dataFilePath = props['data-maestro-file'];
const isMaestroFile = href?.startsWith('maestro-file://') || !!dataFilePath;
const filePath = dataFilePath || (href?.startsWith('maestro-file://') ? href.replace('maestro-file://', '') : null);
return React.createElement(
'a',
{
@@ -373,7 +381,9 @@ export function createMarkdownComponents(options: MarkdownComponentsOptions): Pa
...props,
onClick: (e: React.MouseEvent) => {
e.preventDefault();
if (href) {
if (isMaestroFile && filePath && onFileClick) {
onFileClick(filePath);
} else if (href && onExternalLinkClick) {
onExternalLinkClick(href);
}
},