mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
I'm ready to analyze GitHub project changes and create an exciting update summary! However, I don't see any input provided after "INPUT:" in your message.
Please share the changelog, commit history, release notes, or any other information about what has changed in the GitHub project since the last release, and I'll create a clean, exciting CHANGES section with 10-word bullets and emojis as requested.
This commit is contained in:
80
package-lock.json
generated
80
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<string | null>(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 && (
|
||||
<FileSearchModal
|
||||
theme={theme}
|
||||
fileTree={filteredFileTree}
|
||||
shortcut={shortcuts.fuzzyFileSearch}
|
||||
onFileSelect={(file: FlatFileItem, ancestorPaths: string[]) => {
|
||||
// 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 --- */}
|
||||
<PromptComposerModal
|
||||
isOpen={promptComposerOpen}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MermaidRenderer } from './MermaidRenderer';
|
||||
import { getEncoding } from 'js-tiktoken';
|
||||
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
||||
import { remarkFileLinks } from '../utils/remarkFileLinks';
|
||||
import remarkFrontmatter from 'remark-frontmatter';
|
||||
import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
|
||||
import type { FileNode } from '../hooks/useFileExplorer';
|
||||
|
||||
interface FileStats {
|
||||
@@ -1278,6 +1280,8 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkFrontmatter,
|
||||
remarkFrontmatterTable,
|
||||
remarkHighlight,
|
||||
...(fileTree && fileTree.length > 0 && cwd !== undefined
|
||||
? [[remarkFileLinks, { fileTree, cwd }] as any]
|
||||
|
||||
336
src/renderer/components/FileSearchModal.tsx
Normal file
336
src/renderer/components/FileSearchModal.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||
const selectedItemRef = useRef<HTMLButtonElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const layerIdRef = useRef<string>();
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-16 z-[9999] animate-in fade-in duration-100">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Fuzzy File Search"
|
||||
tabIndex={-1}
|
||||
className="w-[600px] rounded-xl shadow-2xl border overflow-hidden flex flex-col max-h-[500px] outline-none"
|
||||
style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.border }}
|
||||
>
|
||||
{/* Search Header */}
|
||||
<div className="p-4 border-b flex items-center gap-3" style={{ borderColor: theme.colors.border }}>
|
||||
<Search className="w-5 h-5" style={{ color: theme.colors.textDim }} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 bg-transparent outline-none text-lg placeholder-opacity-50"
|
||||
placeholder="Search files..."
|
||||
style={{ color: theme.colors.textMain }}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{shortcut && (
|
||||
<span className="text-xs font-mono opacity-60" style={{ color: theme.colors.textDim }}>
|
||||
{formatShortcutKeys(shortcut.keys)}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className="px-2 py-0.5 rounded text-xs font-bold"
|
||||
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.textDim }}
|
||||
>
|
||||
ESC
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="overflow-y-auto py-2 scrollbar-thin flex-1"
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={file.fullPath}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
onClick={() => handleItemSelect(file)}
|
||||
className="w-full text-left px-4 py-2 flex items-center gap-3 hover:bg-opacity-10"
|
||||
style={{
|
||||
backgroundColor: isSelected ? theme.colors.accent : 'transparent',
|
||||
color: isSelected ? theme.colors.accentForeground : theme.colors.textMain
|
||||
}}
|
||||
>
|
||||
{/* Number Badge */}
|
||||
{showNumber ? (
|
||||
<div
|
||||
className="flex-shrink-0 w-5 h-5 rounded flex items-center justify-center text-xs font-bold"
|
||||
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.textDim }}
|
||||
>
|
||||
{numberBadge}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-shrink-0 w-5 h-5" />
|
||||
)}
|
||||
|
||||
{/* File/Folder Icon */}
|
||||
{file.isFolder ? (
|
||||
<Folder className="w-4 h-4 flex-shrink-0" style={{ color: isSelected ? theme.colors.accentForeground : theme.colors.warning }} />
|
||||
) : (
|
||||
<File className="w-4 h-4 flex-shrink-0" style={{ color: isSelected ? theme.colors.accentForeground : theme.colors.textDim }} />
|
||||
)}
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate">{file.name}</span>
|
||||
{directory && (
|
||||
<span
|
||||
className="text-[10px] truncate"
|
||||
style={{ color: isSelected ? theme.colors.accentForeground : theme.colors.textDim, opacity: 0.7 }}
|
||||
>
|
||||
{directory}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredFiles.length === 0 && (
|
||||
<div className="px-4 py-4 text-center opacity-50 text-sm" style={{ color: theme.colors.textDim }}>
|
||||
{search ? 'No files match your search' : 'No files to search'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with stats */}
|
||||
<div
|
||||
className="px-4 py-2 border-t text-xs flex items-center justify-between"
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
|
||||
>
|
||||
<span>{filteredFiles.length} files</span>
|
||||
<span>↑↓ navigate • Enter select • ⌘1-9 quick select</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1810,7 +1810,18 @@ export function SessionList(props: SessionListProps) {
|
||||
) : (
|
||||
/* SIDEBAR CONTENT: SKINNY MODE */
|
||||
<div className="flex-1 flex flex-col items-center py-4 gap-2 overflow-y-auto overflow-x-visible no-scrollbar">
|
||||
{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 (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => 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 }}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${session.state === 'busy' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${shouldPulse ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId && !isInBatch
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: effectiveStatusColor }
|
||||
}
|
||||
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
|
||||
/>
|
||||
{/* Unread Notification Badge */}
|
||||
{activeSessionId !== session.id && hasUnreadTabs && (
|
||||
<div
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.error }}
|
||||
title="Unread messages"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover Tooltip for Skinny Mode */}
|
||||
<div
|
||||
@@ -1915,7 +1936,8 @@ export function SessionList(props: SessionListProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
134
src/renderer/utils/remarkFrontmatterTable.ts
Normal file
134
src/renderer/utils/remarkFrontmatterTable.ts
Normal file
@@ -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, '>')
|
||||
.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 = `<a href="${escapedUrl}" style="color: inherit; text-decoration: underline;" title="${escapedUrl}">${escapeHtml(displayUrl)}</a>`;
|
||||
} else {
|
||||
valueHtml = escapeHtml(value);
|
||||
}
|
||||
|
||||
return `<tr>
|
||||
<td style="padding: 2px 8px 2px 0; font-weight: 500; white-space: nowrap; vertical-align: top;">${escapedKey}</td>
|
||||
<td style="padding: 2px 0; word-break: break-word;">${valueHtml}</td>
|
||||
</tr>`;
|
||||
}).join('\n');
|
||||
|
||||
return `<div class="frontmatter-table" style="font-size: 0.75em; opacity: 0.7; margin-bottom: 1em; padding: 8px; border-radius: 4px; background: rgba(128,128,128,0.1);">
|
||||
<table style="border-collapse: collapse; width: 100%;">
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user