Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const MainApp: React.FC = () => {
const commandPaletteTargetRef = useRef<HTMLDivElement>(null);
const commandPaletteInputRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const ensureNodeVisibleRef = useRef<(node: Pick<DocumentOrFolder, 'id' | 'type' | 'parentId'>) => void>();

const llmStatus = useLLMStatus(settings.llmProviderUrl);
const { logs, addLog } = useLogger();
Expand Down Expand Up @@ -323,8 +324,23 @@ const MainApp: React.FC = () => {
};
});

await addDocumentsFromFiles(fileEntries, parentId);
}, [addDocumentsFromFiles]);
const importedNodes = await addDocumentsFromFiles(fileEntries, parentId);

if (importedNodes.length > 0) {
const imageNodes = importedNodes.filter(node => node.docType === 'image');
const targetNode = imageNodes[imageNodes.length - 1] ?? importedNodes[importedNodes.length - 1];
if (targetNode) {
const nodeForReveal = { id: targetNode.nodeId, type: 'document' as const, parentId: targetNode.parentId };
setActiveNodeId(targetNode.nodeId);
setSelectedIds(new Set([targetNode.nodeId]));
setLastClickedId(targetNode.nodeId);
setActiveTemplateId(null);
setDocumentView('editor');
setView('editor');
ensureNodeVisibleRef.current?.(nodeForReveal);
}
}
}, [addDocumentsFromFiles, setActiveNodeId, setSelectedIds, setLastClickedId, setActiveTemplateId, setDocumentView, setView]);

useEffect(() => {
const handleDragEnter = (e: DragEvent) => {
Expand Down Expand Up @@ -447,6 +463,10 @@ const MainApp: React.FC = () => {
setPendingRevealId(node.id);
}, [items, setPendingRevealId]);

useEffect(() => {
ensureNodeVisibleRef.current = ensureNodeVisible;
}, [ensureNodeVisible]);

const handleNewDocument = useCallback(async (parentId?: string | null) => {
addLog('INFO', 'User action: Create New Document.');
const effectiveParentId = parentId !== undefined ? parentId : getParentIdForNewItem();
Expand Down
2 changes: 1 addition & 1 deletion components/PreviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const PreviewPane = React.forwardRef<HTMLDivElement, PreviewPaneProps>(({ conten

setError(null);
const renderer = previewService.getRendererForLanguage(language);
const result = await renderer.render(content, addLog);
const result = await renderer.render(content, addLog, language);

clearTimeout(loadingTimer);
if (!isCancelled) {
Expand Down
46 changes: 40 additions & 6 deletions components/PromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,43 @@ interface DocumentEditorProps {
formatTrigger: number;
}

const PREVIEWABLE_LANGUAGES = new Set<string>([
'markdown',
'html',
'pdf',
'application/pdf',
'image',
'png',
'jpg',
'jpeg',
'gif',
'bmp',
'webp',
'svg',
'svg+xml',
'image/png',
'image/jpg',
'image/jpeg',
'image/gif',
'image/webp',
'image/bmp',
'image/svg',
'image/svg+xml',
]);

const resolveDefaultViewMode = (mode: ViewMode | null | undefined, languageHint: string | null | undefined): ViewMode => {
if (mode) return mode;
const normalizedHint = languageHint?.toLowerCase();
return normalizedHint === 'pdf' || normalizedHint === 'application/pdf' ? 'preview' : 'edit';
if (!normalizedHint) {
return 'edit';
}
if (normalizedHint === 'pdf' || normalizedHint === 'application/pdf') {
return 'preview';
}
if (normalizedHint === 'image' || normalizedHint.startsWith('image/')) {
return 'preview';
}
return 'edit';
};

const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, onCommitVersion, onDelete, settings, onShowHistory, onLanguageChange, onViewModeChange, formatTrigger }) => {
Expand Down Expand Up @@ -357,7 +390,7 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
}
setRefinedContent(null);
};

const handleCopy = async () => {
if (!content.trim()) return;
await navigator.clipboard.writeText(content);
Expand All @@ -367,10 +400,11 @@ const DocumentEditor: React.FC<DocumentEditorProps> = ({ documentNode, onSave, o
};

const language = documentNode.language_hint || 'plaintext';
const supportsAiTools = ['markdown', 'plaintext'].includes(language);
const supportsPreview = ['markdown', 'html', 'pdf'].includes(language);
const supportsFormatting = ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(language);
const isPythonDocument = typeof window !== 'undefined' && !!window.electronAPI && (language === 'python');
const normalizedLanguage = language.toLowerCase();
const supportsAiTools = ['markdown', 'plaintext'].includes(normalizedLanguage);
const supportsPreview = PREVIEWABLE_LANGUAGES.has(normalizedLanguage);
const supportsFormatting = ['javascript', 'typescript', 'json', 'html', 'css', 'xml', 'yaml'].includes(normalizedLanguage);
const isPythonDocument = typeof window !== 'undefined' && !!window.electronAPI && (normalizedLanguage === 'python');
const pythonDefaults = useMemo(() => ({
...settings.pythonDefaults,
workingDirectory: settings.pythonWorkingDirectory ?? settings.pythonDefaults.workingDirectory ?? null,
Expand Down
52 changes: 43 additions & 9 deletions electron/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { INITIAL_SCHEMA } from './schema';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
// Fix: Import types to use for casting
import type { Node, Document, DocVersion, DatabaseStats } from '../types';
import type { Node, Document, DocVersion, DatabaseStats, DocType, ViewMode, ImportedNodeSummary } from '../types';

let db: Database.Database;

Expand Down Expand Up @@ -43,6 +43,23 @@ const mapExtensionToLanguageId_local = (extension: string | null): string => {
case 'application/pdf':
case 'pdf':
return 'pdf';
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'bmp':
case 'webp':
case 'svg':
case 'svgz':
case 'image/png':
case 'image/jpg':
case 'image/jpeg':
case 'image/gif':
case 'image/bmp':
case 'image/webp':
case 'image/svg':
case 'image/svg+xml':
return 'image';
default: return 'plaintext';
}
};
Expand Down Expand Up @@ -409,7 +426,8 @@ export const databaseService = {
}
},

importFiles(filesData: {path: string, name: string, content: string}[], targetParentId: string | null): { success: boolean, error?: string } {
importFiles(filesData: {path: string; name: string; content: string}[], targetParentId: string | null): { success: boolean; error?: string; createdNodes: ImportedNodeSummary[] } {
const createdNodes: ImportedNodeSummary[] = [];
const transaction = db.transaction(() => {
console.log(`Starting import transaction for ${filesData.length} files.`);
const knownFolderPaths = new Map<string, string>(); // 'parentId/folderName' -> 'node_id'
Expand Down Expand Up @@ -461,15 +479,23 @@ export const databaseService = {
const extension = file.name.split('.').pop() || null;
let languageHint = mapExtensionToLanguageId_local(extension);

db.prepare(`INSERT INTO nodes (node_id, parent_id, node_type, title, sort_order, created_at, updated_at) VALUES (?, ?, 'document', ?, ?, ?, ?)`).run(newNodeId, currentParentId, file.name, sortOrder, now, now);

const trimmedContent = file.content.trim();
const isPdf = languageHint === 'pdf' || languageHint === 'application/pdf' || trimmedContent.startsWith('data:application/pdf');
const sample = trimmedContent.slice(0, 64).toLowerCase();
const isPdf = languageHint === 'pdf' || sample.startsWith('data:application/pdf');
const isSvgContent = sample.startsWith('<svg');
const isImageDataUrl = sample.startsWith('data:image/');
const isImage = languageHint === 'image' || isImageDataUrl || isSvgContent;

if (isPdf) {
languageHint = 'pdf';
} else if (isImage) {
languageHint = 'image';
}
const docType = isPdf ? 'pdf' : 'source_code';
const defaultViewMode = isPdf ? 'preview' : null;

const docType: DocType = isPdf ? 'pdf' : isImage ? 'image' : 'source_code';
const defaultViewMode: ViewMode | null = docType === 'pdf' || docType === 'image' ? 'preview' : null;

db.prepare(`INSERT INTO nodes (node_id, parent_id, node_type, title, sort_order, created_at, updated_at) VALUES (?, ?, 'document', ?, ?, ?, ?)`).run(newNodeId, currentParentId, file.name, sortOrder, now, now);

const docResult = db.prepare(`INSERT INTO documents (node_id, doc_type, language_hint, default_view_mode) VALUES (?, ?, ?, ?)`)
.run(newNodeId, docType, languageHint, defaultViewMode);
Expand All @@ -480,15 +506,23 @@ export const databaseService = {
const newVersionId = Number(versionResult.lastInsertRowid);
db.prepare('UPDATE documents SET current_version_id = ? WHERE document_id = ?').run(newVersionId, documentId);
console.log(`Created document "${file.name}" with node id ${newNodeId}`);

createdNodes.push({
nodeId: newNodeId,
parentId: currentParentId ?? null,
docType,
languageHint,
defaultViewMode,
});
}
});

try {
transaction();
return { success: true };
return { success: true, createdNodes };
} catch (error) {
console.error('File import transaction failed:', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
return { success: false, error: error instanceof Error ? error.message : String(error), createdNodes: [] };
}
},

Expand Down
14 changes: 10 additions & 4 deletions hooks/useNodes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import type { Node, ViewMode } from '../types';
import type { Node, ViewMode, ImportedNodeSummary } from '../types';
import { repository } from '../services/repository';
import { useLogger } from './useLogger';

Expand Down Expand Up @@ -68,9 +68,15 @@ export const useNodes = () => {
addLog('DEBUG', `Content for node ${nodeId} saved.`);
}, [addLog]);

const importFiles = useCallback(async (filesData: {path: string, name: string, content: string}[], targetParentId: string | null) => {
await repository.importFiles(filesData, targetParentId);
}, []);
const importFiles = useCallback(
async (
filesData: { path: string; name: string; content: string }[],
targetParentId: string | null
): Promise<ImportedNodeSummary[]> => {
return repository.importFiles(filesData, targetParentId);
},
[]
);

return { nodes, isLoading, refreshNodes, addNode, updateNode, deleteNode, deleteNodes, moveNodes, updateDocumentContent, duplicateNodes, importFiles, addLog };
};
23 changes: 18 additions & 5 deletions hooks/usePrompts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import type { Node, DocumentOrFolder, DocType } from '../types';
import type { Node, DocumentOrFolder, DocType, ImportedNodeSummary } from '../types';
import { useNodes } from './useNodes';
import { mapExtensionToLanguageId } from '../services/languageService';

Expand Down Expand Up @@ -59,7 +59,8 @@ export const useDocuments = () => {

const addDocument = useCallback(async ({ parentId, title = 'New Document', content = '', doc_type = 'prompt', language_hint = 'markdown' }: { parentId: string | null, title?: string, content?: string, doc_type?: DocType, language_hint?: string | null }) => {
const resolvedLanguage = mapExtensionToLanguageId(language_hint);
const defaultViewMode = doc_type === 'pdf' || resolvedLanguage === 'pdf' ? 'preview' : undefined;
const shouldPreviewByDefault = doc_type === 'pdf' || doc_type === 'image' || resolvedLanguage === 'pdf' || resolvedLanguage === 'image';
const defaultViewMode = shouldPreviewByDefault ? 'preview' : undefined;
const newNode = await addNode({
parent_id: parentId,
node_type: 'document',
Expand Down Expand Up @@ -113,7 +114,10 @@ export const useDocuments = () => {
await moveNodes(draggedIds, targetId, position);
}, [moveNodes]);

const addDocumentsFromFiles = useCallback(async (files: { path: string; name: string; file: File }[], targetNodeId: string | null) => {
const addDocumentsFromFiles = useCallback(async (
files: { path: string; name: string; file: File }[],
targetNodeId: string | null
): Promise<ImportedNodeSummary[]> => {
addLog('INFO', `Importing ${files.length} files...`);

const fileReadPromises = files.map(entry => {
Expand All @@ -124,7 +128,14 @@ export const useDocuments = () => {

const fileName = entry.name.toLowerCase();
const mimeType = entry.file.type;
const shouldReadAsDataUrl = (mimeType && mimeType.includes('pdf')) || fileName.endsWith('.pdf');
const extension = fileName.split('.').pop() || '';
const isPdf = (mimeType && mimeType.includes('pdf')) || extension === 'pdf';
const isSvg = extension === 'svg' || extension === 'svgz' || mimeType === 'image/svg+xml';
const isImage =
(!isSvg && !!mimeType && mimeType.startsWith('image/')) ||
['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].some(ext => extension === ext);

const shouldReadAsDataUrl = isPdf || isImage;

if (shouldReadAsDataUrl) {
reader.readAsDataURL(entry.file);
Expand All @@ -136,12 +147,14 @@ export const useDocuments = () => {

try {
const filesData = await Promise.all(fileReadPromises);
await importFiles(filesData, targetNodeId);
const createdNodes = await importFiles(filesData, targetNodeId);
addLog('INFO', 'File import process completed successfully in the backend.');
await refreshNodes();
return createdNodes;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addLog('ERROR', `File import failed: ${message}`);
return [];
}
}, [addLog, importFiles, refreshNodes]);

Expand Down
19 changes: 19 additions & 0 deletions services/languageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const SUPPORTED_LANGUAGES = [
{ id: 'pascal', label: 'Pascal' },
{ id: 'ini', label: 'INI' },
{ id: 'pdf', label: 'PDF' },
{ id: 'image', label: 'Image (PNG/JPEG/GIF/WebP/SVG/BMP)' },
];

export const mapExtensionToLanguageId = (extension: string | null): string => {
Expand Down Expand Up @@ -80,6 +81,24 @@ export const mapExtensionToLanguageId = (extension: string | null): string => {
return 'pdf';
case 'pdf':
return 'pdf';
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'bmp':
case 'webp':
case 'svg':
case 'svgz':
return 'image';
case 'image/png':
case 'image/jpg':
case 'image/jpeg':
case 'image/gif':
case 'image/bmp':
case 'image/webp':
case 'image/svg':
case 'image/svg+xml':
return 'image';
default:
// Try to find a direct match in supported languages by id
const match = SUPPORTED_LANGUAGES.find(l => l.id === extension.toLowerCase());
Expand Down
6 changes: 5 additions & 1 deletion services/preview/IRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ export interface IRenderer {
/**
* Takes a string of content and transforms it into a renderable React element or HTML string.
*/
render(content: string, addLog?: (level: LogLevel, message: string) => void): Promise<{ output: React.ReactElement | string; error?: string }>;
render(
content: string,
addLog?: (level: LogLevel, message: string) => void,
languageId?: string | null,
): Promise<{ output: React.ReactElement | string; error?: string }>;
}
6 changes: 5 additions & 1 deletion services/preview/htmlRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export class HtmlRenderer implements IRenderer {
return languageId === 'html';
}

async render(content: string, addLog?: (level: LogLevel, message: string) => void): Promise<{ output: React.ReactElement; error?: string }> {
async render(
content: string,
addLog?: (level: LogLevel, message: string) => void,
languageId?: string | null,
): Promise<{ output: React.ReactElement; error?: string }> {
// Using `color-scheme` allows the iframe content to respect the system's light/dark mode preference.
const fullHtml = `<html><head><style>body { color-scheme: light dark; font-family: sans-serif; padding: 1rem; }</style></head><body>${content}</body></html>`;

Expand Down
Loading