diff --git a/package-lock.json b/package-lock.json index fed27638..73301f5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.8.3", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.8.3", + "version": "0.8.5", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { @@ -37,6 +37,7 @@ "react-syntax-highlighter": "^16.1.0", "react-virtuoso": "^4.15.0", "rehype-raw": "^7.0.0", + "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "ws": "^8.16.0" }, @@ -9354,6 +9355,36 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-gfm": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", @@ -9698,6 +9729,35 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-frontmatter/node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/micromark-extension-gfm": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", @@ -12102,6 +12162,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-frontmatter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", + "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-frontmatter": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/package.json b/package.json index d4a0d9d0..567cef8e 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "react-syntax-highlighter": "^16.1.0", "react-virtuoso": "^4.15.0", "rehype-raw": "^7.0.0", + "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "ws": "^8.16.0" }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6ccd1a6d..bc5996c4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -21,6 +21,7 @@ import { GitDiffViewer } from './components/GitDiffViewer'; import { GitLogViewer } from './components/GitLogViewer'; import { BatchRunnerModal, DEFAULT_BATCH_PROMPT } from './components/BatchRunnerModal'; import { TabSwitcherModal } from './components/TabSwitcherModal'; +import { FileSearchModal, type FlatFileItem } from './components/FileSearchModal'; import { PromptComposerModal } from './components/PromptComposerModal'; import { ExecutionQueueBrowser } from './components/ExecutionQueueBrowser'; import { StandingOvationOverlay } from './components/StandingOvationOverlay'; @@ -326,6 +327,9 @@ export default function MaestroConsole() { // Tab Switcher Modal State const [tabSwitcherOpen, setTabSwitcherOpen] = useState(false); + // Fuzzy File Search Modal State + const [fuzzyFileSearchOpen, setFuzzyFileSearchOpen] = useState(false); + // Prompt Composer Modal State const [promptComposerOpen, setPromptComposerOpen] = useState(false); const [renameGroupId, setRenameGroupId] = useState(null); @@ -4197,7 +4201,7 @@ export default function MaestroConsole() { setRenameTabModalOpen, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab, setFileTreeFilterOpen, isShortcut, isTabShortcut, handleNavBack, handleNavForward, toggleUnreadFilter, setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage, setMarkdownEditMode, - toggleTabStar, setPromptComposerOpen, openWizardModal, rightPanelRef, + toggleTabStar, setPromptComposerOpen, openWizardModal, rightPanelRef, setFuzzyFileSearchOpen, // Navigation handlers from useKeyboardNavigation hook handleSidebarNavigation, handleTabNavigation, handleEnterToActivate, handleEscapeInMain }; @@ -5491,6 +5495,38 @@ export default function MaestroConsole() { /> )} + {/* --- FUZZY FILE SEARCH MODAL --- */} + {fuzzyFileSearchOpen && activeSession && ( + { + // Expand all ancestor folders so the file is visible + setSessions(prev => prev.map(s => { + if (s.id !== activeSessionId) return s; + const newExpanded = new Set([...s.fileExplorerExpanded, ...ancestorPaths]); + return { ...s, fileExplorerExpanded: Array.from(newExpanded) }; + })); + + // Find the file index in the flattened tree to set selection + // We need to compute this after folders are expanded + setTimeout(() => { + // Set focus to right panel and files tab + setActiveRightTab('files'); + setActiveFocus('right'); + + // Preview the file if it's not a folder + if (!file.isFolder && activeSession) { + const absolutePath = `${activeSession.fullPath}/${file.fullPath}`; + handleFileClick({ name: file.name, type: 'file' }, absolutePath, activeSession); + } + }, 50); + }} + onClose={() => setFuzzyFileSearchOpen(false)} + /> + )} + {/* --- PROMPT COMPOSER MODAL --- */} 0 && cwd !== undefined ? [[remarkFileLinks, { fileTree, cwd }] as any] diff --git a/src/renderer/components/FileSearchModal.tsx b/src/renderer/components/FileSearchModal.tsx new file mode 100644 index 00000000..16a9c688 --- /dev/null +++ b/src/renderer/components/FileSearchModal.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { Search, File, Folder } from 'lucide-react'; +import type { Theme, Shortcut } from '../types'; +import { fuzzyMatchWithScore } from '../utils/search'; +import { useLayerStack } from '../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { formatShortcutKeys } from '../utils/shortcutFormatter'; + +interface FileNode { + name: string; + type: 'file' | 'folder'; + children?: FileNode[]; +} + +/** Flattened file item for the search list */ +export interface FlatFileItem { + name: string; + fullPath: string; + isFolder: boolean; + depth: number; +} + +interface FileSearchModalProps { + theme: Theme; + fileTree: FileNode[]; + shortcut?: Shortcut; + onFileSelect: (item: FlatFileItem, ancestorPaths: string[]) => void; + onClose: () => void; +} + +/** + * Get ancestor folder paths for a file path. + * e.g., "src/renderer/components/Button.tsx" => ["src", "src/renderer", "src/renderer/components"] + */ +function getAncestorPaths(filePath: string): string[] { + const parts = filePath.split('/'); + const paths: string[] = []; + for (let i = 1; i < parts.length; i++) { + paths.push(parts.slice(0, i).join('/')); + } + return paths; +} + +/** + * Recursively flatten the entire file tree (ignoring expansion state). + * Returns all files and folders with their full paths. + */ +function flattenEntireTree(nodes: FileNode[], currentPath = '', depth = 0): FlatFileItem[] { + const result: FlatFileItem[] = []; + + for (const node of nodes) { + const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name; + result.push({ + name: node.name, + fullPath, + isFolder: node.type === 'folder', + depth, + }); + + if (node.type === 'folder' && node.children) { + result.push(...flattenEntireTree(node.children, fullPath, depth + 1)); + } + } + + return result; +} + +/** + * Fuzzy File Search Modal - Quick navigation to any file in the file tree. + * Supports fuzzy search, arrow key navigation, and Cmd+1-9,0 quick select. + */ +export function FileSearchModal({ + theme, + fileTree, + shortcut, + onFileSelect, + onClose +}: FileSearchModalProps) { + const [search, setSearch] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const [firstVisibleIndex, setFirstVisibleIndex] = useState(0); + const inputRef = useRef(null); + const selectedItemRef = useRef(null); + const scrollContainerRef = useRef(null); + const layerIdRef = useRef(); + const onCloseRef = useRef(onClose); + + // Keep onClose ref up to date + useEffect(() => { + onCloseRef.current = onClose; + }); + + const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); + + // Register layer on mount + useEffect(() => { + layerIdRef.current = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.FUZZY_FILE_SEARCH, + blocksLowerLayers: true, + capturesFocus: true, + focusTrap: 'strict', + ariaLabel: 'Fuzzy File Search', + onEscape: () => onCloseRef.current() + }); + + return () => { + if (layerIdRef.current) { + unregisterLayer(layerIdRef.current); + } + }; + }, [registerLayer, unregisterLayer]); + + // Update handler when onClose changes + useEffect(() => { + if (layerIdRef.current) { + updateLayerHandler(layerIdRef.current, () => { + onCloseRef.current(); + }); + } + }, [updateLayerHandler]); + + // Focus input on mount + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(timer); + }, []); + + // Flatten the entire tree (all files, regardless of expansion state) + const allFiles = useMemo(() => { + return flattenEntireTree(fileTree); + }, [fileTree]); + + // Filter and sort files based on search query + const filteredFiles = useMemo(() => { + if (!search.trim()) { + // No search - show all files sorted alphabetically by path + return [...allFiles].sort((a, b) => a.fullPath.localeCompare(b.fullPath)); + } + + // Fuzzy search on both name and full path + const results = allFiles.map(file => { + const nameResult = fuzzyMatchWithScore(file.name, search); + const pathResult = fuzzyMatchWithScore(file.fullPath, search); + const bestScore = Math.max(nameResult.score, pathResult.score); + const matches = nameResult.matches || pathResult.matches; + + return { file, score: bestScore, matches }; + }); + + return results + .filter(r => r.matches) + .sort((a, b) => b.score - a.score) + .map(r => r.file); + }, [allFiles, search]); + + // Reset selection when search changes + useEffect(() => { + setSelectedIndex(0); + setFirstVisibleIndex(0); + }, [search]); + + // Scroll selected item into view + useEffect(() => { + selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }, [selectedIndex]); + + // Track scroll position to determine which items are visible + const handleScroll = () => { + if (scrollContainerRef.current) { + const scrollTop = scrollContainerRef.current.scrollTop; + const itemHeight = 40; // Approximate height of each item + const visibleIndex = Math.floor(scrollTop / itemHeight); + setFirstVisibleIndex(visibleIndex); + } + }; + + const handleItemSelect = (file: FlatFileItem) => { + const ancestors = getAncestorPaths(file.fullPath); + onFileSelect(file, ancestors); + onClose(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(prev => Math.min(prev + 1, filteredFiles.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (filteredFiles[selectedIndex]) { + handleItemSelect(filteredFiles[selectedIndex]); + } + } else if (e.metaKey && ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'].includes(e.key)) { + e.preventDefault(); + // 1-9 map to positions 1-9, 0 maps to position 10 + const number = e.key === '0' ? 10 : parseInt(e.key); + // Cap firstVisibleIndex so hotkeys always work for the last 10 items + const maxFirstIndex = Math.max(0, filteredFiles.length - 10); + const effectiveFirstIndex = Math.min(firstVisibleIndex, maxFirstIndex); + const targetIndex = effectiveFirstIndex + number - 1; + if (filteredFiles[targetIndex]) { + handleItemSelect(filteredFiles[targetIndex]); + } + } + }; + + // Get the directory part of a path (everything before the last /) + const getDirectory = (fullPath: string): string => { + const lastSlash = fullPath.lastIndexOf('/'); + return lastSlash > 0 ? fullPath.substring(0, lastSlash) : ''; + }; + + return ( +
+
+ {/* Search Header */} +
+ + setSearch(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ {shortcut && ( + + {formatShortcutKeys(shortcut.keys)} + + )} +
+ ESC +
+
+
+ + {/* File List */} +
+ {filteredFiles.map((file, i) => { + const isSelected = i === selectedIndex; + + // Calculate dynamic number badge + const maxFirstIndex = Math.max(0, filteredFiles.length - 10); + const effectiveFirstIndex = Math.min(firstVisibleIndex, maxFirstIndex); + const distanceFromFirstVisible = i - effectiveFirstIndex; + const showNumber = distanceFromFirstVisible >= 0 && distanceFromFirstVisible < 10; + const numberBadge = distanceFromFirstVisible === 9 ? 0 : distanceFromFirstVisible + 1; + + const directory = getDirectory(file.fullPath); + + return ( + + ); + })} + + {filteredFiles.length === 0 && ( +
+ {search ? 'No files match your search' : 'No files to search'} +
+ )} +
+ + {/* Footer with stats */} +
+ {filteredFiles.length} files + ↑↓ navigate • Enter select • ⌘1-9 quick select +
+
+
+ ); +} diff --git a/src/renderer/components/MarkdownRenderer.tsx b/src/renderer/components/MarkdownRenderer.tsx index f40cbf66..7851b591 100644 --- a/src/renderer/components/MarkdownRenderer.tsx +++ b/src/renderer/components/MarkdownRenderer.tsx @@ -7,6 +7,8 @@ import { Clipboard, Loader2, ImageOff } from 'lucide-react'; import type { Theme } from '../types'; import type { FileNode } from '../hooks/useFileExplorer'; import { remarkFileLinks } from '../utils/remarkFileLinks'; +import remarkFrontmatter from 'remark-frontmatter'; +import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable'; // ============================================================================ // LocalImage - Loads local images via IPC @@ -199,7 +201,11 @@ interface MarkdownRendererProps { export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '', fileTree, cwd, projectRoot, onFileClick }: MarkdownRendererProps) => { // Memoize remark plugins to avoid recreating on every render const remarkPlugins = useMemo(() => { - const plugins: any[] = [remarkGfm]; + const plugins: any[] = [ + remarkGfm, + remarkFrontmatter, + remarkFrontmatterTable, + ]; // Add remarkFileLinks if we have file tree for relative paths, // OR if we have projectRoot for absolute paths (even with empty file tree) if ((fileTree && fileTree.length > 0 && cwd !== undefined) || projectRoot) { diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index c5549718..4f0ebf72 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -1810,7 +1810,18 @@ export function SessionList(props: SessionListProps) { ) : ( /* SIDEBAR CONTENT: SKINNY MODE */
- {sortedSessions.map(session => ( + {sortedSessions.map(session => { + const isInBatch = activeBatchSessionIds.includes(session.id); + const hasUnreadTabs = session.aiTabs?.some(tab => tab.hasUnread); + // Sessions in Auto Run mode should show yellow/warning color + const effectiveStatusColor = isInBatch + ? theme.colors.warning + : (session.toolType === 'claude' && !session.claudeSessionId + ? undefined // Will use border style instead + : getStatusColor(session.state, theme)); + const shouldPulse = session.state === 'busy' || isInBatch; + + return (
setActiveSessionId(session.id)} @@ -1818,15 +1829,25 @@ export function SessionList(props: SessionListProps) { className={`group relative w-8 h-8 rounded-full flex items-center justify-center cursor-pointer transition-all ${activeSessionId === session.id ? 'ring-2' : 'hover:bg-white/10'}`} style={{ ringColor: theme.colors.accent }} > -
+
+
+ {/* Unread Notification Badge */} + {activeSessionId !== session.id && hasUnreadTabs && ( +
+ )} +
{/* Hover Tooltip for Skinny Mode */}
- ))} + ); + })}
)} diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts index b9c6a660..32827e31 100644 --- a/src/renderer/constants/modalPriorities.ts +++ b/src/renderer/constants/modalPriorities.ts @@ -77,6 +77,9 @@ export const MODAL_PRIORITIES = { /** Quick actions command palette (Cmd+K) */ QUICK_ACTION: 700, + /** Fuzzy file search modal (Cmd+G) */ + FUZZY_FILE_SEARCH: 690, + /** Agent sessions browser (Cmd+Shift+L) */ AGENT_SESSIONS: 680, diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 3c7e44ae..a76048d3 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -34,6 +34,7 @@ export const DEFAULT_SHORTCUTS: Record = { toggleTabStar: { id: 'toggleTabStar', label: 'Toggle Tab Star', keys: ['Meta', 'Shift', 's'] }, openPromptComposer: { id: 'openPromptComposer', label: 'Open Prompt Composer', keys: ['Meta', 'Shift', 'p'] }, openWizard: { id: 'openWizard', label: 'New Agent Wizard', keys: ['Meta', 'Shift', 'n'] }, + fuzzyFileSearch: { id: 'fuzzyFileSearch', label: 'Fuzzy File Search', keys: ['Meta', 'g'] }, }; // Non-editable shortcuts (displayed in help but not configurable) diff --git a/src/renderer/hooks/useInputProcessing.ts b/src/renderer/hooks/useInputProcessing.ts index 9af1b819..03faa1d0 100644 --- a/src/renderer/hooks/useInputProcessing.ts +++ b/src/renderer/hooks/useInputProcessing.ts @@ -319,9 +319,11 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces } } - // Check if we're in read-only mode for the log entry + // Check if we're in read-only mode for the log entry (tab setting OR Auto Run without worktree) const activeTabForEntry = currentMode === 'ai' ? getActiveTab(activeSession) : null; - const isReadOnlyEntry = activeTabForEntry?.readOnlyMode === true; + const currentBatchState = getBatchState(activeSession.id); + const isAutoRunReadOnly = currentBatchState.isRunning && !currentBatchState.worktreeActive; + const isReadOnlyEntry = activeTabForEntry?.readOnlyMode === true || isAutoRunReadOnly; const newEntry: LogEntry = { id: generateId(), @@ -522,7 +524,10 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces const freshActiveTab = getActiveTab(freshSession); const tabClaudeSessionId = freshActiveTab?.claudeSessionId; const isNewSession = !tabClaudeSessionId; - const isReadOnly = activeBatchRunState.isRunning || freshActiveTab?.readOnlyMode; + // Check CURRENT session's Auto Run state (not any session's) and respect worktree bypass + const currentSessionBatchState = getBatchState(activeSessionId); + const isAutoRunReadOnly = currentSessionBatchState.isRunning && !currentSessionBatchState.worktreeActive; + const isReadOnly = isAutoRunReadOnly || freshActiveTab?.readOnlyMode; // Filter out --dangerously-skip-permissions when read-only mode is active // (it would override --permission-mode plan) diff --git a/src/renderer/hooks/useMainKeyboardHandler.ts b/src/renderer/hooks/useMainKeyboardHandler.ts index 9ba6b3c3..bdc7f614 100644 --- a/src/renderer/hooks/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/useMainKeyboardHandler.ts @@ -186,6 +186,7 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn { else if (ctx.isShortcut(e, 'goToFiles')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('files'); ctx.setActiveFocus('right'); } else if (ctx.isShortcut(e, 'goToHistory')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('history'); ctx.setActiveFocus('right'); } else if (ctx.isShortcut(e, 'goToAutoRun')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('autorun'); ctx.setActiveFocus('right'); } + else if (ctx.isShortcut(e, 'fuzzyFileSearch')) { e.preventDefault(); if (ctx.activeSession) ctx.setFuzzyFileSearchOpen(true); } else if (ctx.isShortcut(e, 'openImageCarousel')) { e.preventDefault(); if (ctx.stagedImages.length > 0) { diff --git a/src/renderer/utils/remarkFrontmatterTable.ts b/src/renderer/utils/remarkFrontmatterTable.ts new file mode 100644 index 00000000..d50d06d6 --- /dev/null +++ b/src/renderer/utils/remarkFrontmatterTable.ts @@ -0,0 +1,134 @@ +/** + * remarkFrontmatterTable - A remark plugin that transforms YAML frontmatter into a styled metadata table. + * + * Requires remark-frontmatter to be used first to parse the frontmatter into a YAML AST node. + * This plugin then transforms that node into an HTML table for display. + * + * Example input: + * --- + * share_note_link: https://example.com + * share_note_updated: 2025-05-19T13:15:43-05:00 + * --- + * + * Output: A compact two-column table with key/value pairs, styled with smaller font. + */ + +import { visit } from 'unist-util-visit'; +import type { Root } from 'mdast'; + +/** + * Parse simple YAML key-value pairs from frontmatter content. + * Handles basic YAML syntax (key: value on each line). + */ +function parseYamlKeyValues(yamlContent: string): Array<{ key: string; value: string }> { + const lines = yamlContent.split('\n'); + const entries: Array<{ key: string; value: string }> = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + // Match key: value pattern + const colonIndex = trimmed.indexOf(':'); + if (colonIndex > 0) { + const key = trimmed.substring(0, colonIndex).trim(); + let value = trimmed.substring(colonIndex + 1).trim(); + + // Remove surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + entries.push({ key, value }); + } + } + + return entries; +} + +/** + * Check if a value looks like a URL + */ +function isUrl(value: string): boolean { + return value.startsWith('http://') || value.startsWith('https://'); +} + +/** + * Escape HTML special characters + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Generate HTML table from frontmatter entries + */ +function generateTableHtml(entries: Array<{ key: string; value: string }>): string { + if (entries.length === 0) return ''; + + const rows = entries.map(({ key, value }) => { + const escapedKey = escapeHtml(key); + let valueHtml: string; + + if (isUrl(value)) { + // Render URLs as clickable links + const escapedUrl = escapeHtml(value); + // Truncate long URLs for display + const displayUrl = value.length > 50 ? value.substring(0, 47) + '...' : value; + valueHtml = `${escapeHtml(displayUrl)}`; + } else { + valueHtml = escapeHtml(value); + } + + return ` + ${escapedKey} + ${valueHtml} + `; + }).join('\n'); + + return `
+ + + ${rows} + +
+
`; +} + +/** + * The remark plugin - transforms YAML frontmatter nodes into HTML tables + */ +export function remarkFrontmatterTable() { + return (tree: Root) => { + visit(tree, 'yaml', (node: any, index, parent) => { + if (!parent || index === undefined) return; + + const yamlContent = node.value; + const entries = parseYamlKeyValues(yamlContent); + + if (entries.length === 0) { + // No valid entries, remove the node entirely + parent.children.splice(index, 1); + return index; + } + + const tableHtml = generateTableHtml(entries); + + // Replace the YAML node with an HTML node + const htmlNode = { + type: 'html', + value: tableHtml, + }; + + parent.children.splice(index, 1, htmlNode); + return index + 1; + }); + }; +} + +export default remarkFrontmatterTable;