Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
87 changes: 87 additions & 0 deletions electron/handlers/vault-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,91 @@ export function registerVaultHandlers(deps: VaultHandlerDependencies): void {
return { success: false, error: String(err) };
}
});

// Read a local file from disk
ipcMain.handle('vault:readLocalFile', async (_event, filePath: string) => {
try {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
return { error: 'File not found' };
}
const stats = fs.statSync(resolvedPath);
if (stats.isDirectory()) {
return { error: 'Path is a directory' };
}
// Limit to 10MB
if (stats.size > 10 * 1024 * 1024) {
return { error: 'File too large (max 10MB)' };
}
// Check for binary content by reading a small buffer
const buffer = Buffer.alloc(Math.min(8192, stats.size));
const fd = fs.openSync(resolvedPath, 'r');
try {
fs.readSync(fd, buffer, 0, buffer.length, 0);
} finally {
fs.closeSync(fd);
}
// Check for null bytes (binary indicator)
if (buffer.includes(0)) {
return { error: 'This file appears to be binary and cannot be edited as text. Only text-based files (.md, .txt, .json, .ts, .js, etc.) are supported.' };
}
const content = fs.readFileSync(resolvedPath, 'utf-8');
const filename = path.basename(resolvedPath);
return { content, filename, filePath: resolvedPath };
} catch (err) {
console.error('Failed to read local file:', err);
return { error: String(err) };
}
});

// Write content back to a local file on disk
ipcMain.handle('vault:writeLocalFile', async (_event, params: { filePath: string; content: string }) => {
try {
const resolvedPath = path.resolve(params.filePath);
fs.writeFileSync(resolvedPath, params.content, 'utf-8');
return { success: true, filePath: resolvedPath };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
console.error('Failed to write local file:', err);
return { success: false, error: String(err) };
}
});

// Save clipboard image data to disk
ipcMain.handle('vault:saveClipboardImage', async (_event, params: { imageDataUrl: string; targetDir?: string }) => {
try {
// Parse data URL: data:image/png;base64,xxxx
const match = params.imageDataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
if (!match) {
return { error: 'Invalid image data URL' };
}
const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
const base64Data = match[2];
const buffer = Buffer.from(base64Data, 'base64');

// Save to target directory (same as the file being edited) or vault attachments
let saveDir = path.join(VAULT_DIR, 'attachments');
if (params.targetDir) {
const resolvedTarget = path.resolve(params.targetDir);
// Only allow targetDir under the user's home directory
const homeDir = require('os').homedir();
if (!resolvedTarget.startsWith(homeDir)) {
return { success: false, error: 'Target directory is outside the home directory' };
}
saveDir = resolvedTarget;
}
if (!fs.existsSync(saveDir)) {
fs.mkdirSync(saveDir, { recursive: true });
}

const timestamp = Date.now();
const filename = `pasted-image-${timestamp}.${ext}`;
const filePath = path.join(saveDir, filename);
fs.writeFileSync(filePath, buffer);

return { success: true, filePath, filename };
} catch (err) {
console.error('Failed to save clipboard image:', err);
return { success: false, error: String(err) };
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
22 changes: 19 additions & 3 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,10 +394,26 @@ app.whenReady().then(async () => {
});

// Initialize vault database
initVaultDb();
let vaultReady = false;
try {
initVaultDb();
console.log('[Dorothy] Vault database initialized successfully');
vaultReady = true;
} catch (err) {
console.error('[Dorothy] Failed to initialize vault database:', err);
}

// Register vault handlers
registerVaultHandlers({ getMainWindow });
// Register vault handlers only if DB init succeeded
if (vaultReady) {
try {
registerVaultHandlers({ getMainWindow });
console.log('[Dorothy] Vault handlers registered successfully');
} catch (err) {
console.error('[Dorothy] Failed to register vault handlers:', err);
}
} else {
console.warn('[Dorothy] Vault handlers skipped due to database initialization failure');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Register world (generative zone) handlers
registerWorldHandlers({ getMainWindow });
Expand Down
6 changes: 6 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('vault:deleteFolder', params),
attachFile: (params: { document_id: string; file_path: string }) =>
ipcRenderer.invoke('vault:attachFile', params),
readLocalFile: (filePath: string) =>
ipcRenderer.invoke('vault:readLocalFile', filePath) as Promise<{ content?: string; filename?: string; filePath?: string; error?: string }>,
writeLocalFile: (params: { filePath: string; content: string }) =>
ipcRenderer.invoke('vault:writeLocalFile', params) as Promise<{ success?: boolean; filePath?: string; error?: string }>,
saveClipboardImage: (params: { imageDataUrl: string; targetDir?: string }) =>
ipcRenderer.invoke('vault:saveClipboardImage', params) as Promise<{ success?: boolean; filePath?: string; filename?: string; error?: string }>,
// Event listeners
onDocumentCreated: (callback: (doc: unknown) => void) => {
const listener = (_: unknown, doc: unknown) => callback(doc);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

192 changes: 192 additions & 0 deletions src/components/TerminalsView/components/FileEditorPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
'use client';

import { useState, useRef, useCallback } from 'react';
import { Save, X, Eye, Pencil, PanelTop, PanelBottom, PanelLeft, PanelRight } from 'lucide-react';

export type DockPosition = 'top' | 'bottom' | 'left' | 'right';

interface FileEditorPanelProps {
filePath: string;
filename: string;
content: string;
position: DockPosition;
onSave: (content: string) => void;
onClose: () => void;
onPositionChange: (position: DockPosition) => void;
}

type EditorTab = 'write' | 'preview';

export default function FileEditorPanel({ filePath, filename, content: initialContent, position, onSave, onClose, onPositionChange }: FileEditorPanelProps) {
const [content, setContent] = useState(initialContent);
const [activeTab, setActiveTab] = useState<EditorTab>('write');
const [saved, setSaved] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const handleChange = useCallback((value: string) => {
setContent(value);
setSaved(false);
}, []);

const handleSave = useCallback(() => {
onSave(content);
setSaved(true);
}, [content, onSave]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
}, [handleSave]);

// Handle paste with image support
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (!items) return;

for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const blob = item.getAsFile();
if (!blob) return;

// Convert to data URL
const reader = new FileReader();
reader.onload = async () => {
const dataUrl = reader.result as string;
if (!window.electronAPI?.vault?.saveClipboardImage) return;

// Save to same directory as the file being edited
const dirPath = filePath.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
const result = await window.electronAPI.vault.saveClipboardImage({
imageDataUrl: dataUrl,
targetDir: dirPath || undefined,
});

if (result.success && result.filePath && result.filename) {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const mdImage = `![${result.filename}](${result.filePath})`;
setContent(prev => {
const newContent = prev.slice(0, start) + mdImage + prev.slice(end);
return newContent;
});
setSaved(false);

const newPos = start + mdImage.length;
requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(newPos, newPos);
});
}
};
reader.readAsDataURL(blob);
return;
}
}
}, [filePath]);

return (
<div
className={`flex flex-col h-full overflow-hidden ${position === 'top' || position === 'bottom' ? 'border-t border-b' : 'border-l border-r'} border-border`}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-1 px-2 py-1 bg-secondary border-b border-border select-none shrink-0">
<span className="text-[10px] font-medium text-foreground truncate flex-1" title={filePath}>
{filename}
</span>
{!saved && (
<span className="text-[9px] text-amber-400 font-medium">modified</span>
)}

{/* Tab toggle */}
<div className="flex items-center bg-secondary/50 rounded p-0.5">
<button
onClick={() => setActiveTab('write')}
className={`p-0.5 rounded ${activeTab === 'write' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
title="Edit"
>
<Pencil className="w-2.5 h-2.5" />
</button>
<button
onClick={() => setActiveTab('preview')}
className={`p-0.5 rounded ${activeTab === 'preview' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
title="Preview"
>
<Eye className="w-2.5 h-2.5" />
</button>
</div>

{/* Position buttons */}
<div className="flex items-center bg-secondary/50 rounded p-0.5">
<button
onClick={() => onPositionChange('top')}
className={`p-0.5 rounded ${position === 'top' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
title="Dock top"
>
<PanelTop className="w-2.5 h-2.5" />
</button>
<button
onClick={() => onPositionChange('bottom')}
className={`p-0.5 rounded ${position === 'bottom' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
title="Dock bottom"
>
<PanelBottom className="w-2.5 h-2.5" />
</button>
<button
onClick={() => onPositionChange('left')}
className={`p-0.5 rounded ${position === 'left' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
title="Dock left"
>
<PanelLeft className="w-2.5 h-2.5" />
</button>
<button
onClick={() => onPositionChange('right')}
className={`p-0.5 rounded ${position === 'right' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
title="Dock right"
>
<PanelRight className="w-2.5 h-2.5" />
</button>
</div>

<button
onClick={handleSave}
disabled={saved}
className="p-0.5 hover:bg-primary/10 transition-colors text-muted-foreground hover:text-foreground disabled:opacity-30"
title="Save (⌘S)"
>
<Save className="w-2.5 h-2.5" />
</button>
<button
onClick={onClose}
className="p-0.5 hover:bg-primary/10 transition-colors text-muted-foreground hover:text-red-400"
title="Close file"
>
<X className="w-2.5 h-2.5" />
</button>
</div>

{/* Content */}
{activeTab === 'write' ? (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => handleChange(e.target.value)}
onPaste={handlePaste}
className="flex-1 w-full text-xs bg-[#1a1a2e] text-foreground font-mono p-3 outline-none border-none resize-none leading-relaxed"
spellCheck={false}
/>
) : (
<div className="flex-1 overflow-y-auto p-3 text-xs text-foreground bg-[#1a1a2e]">
<pre className="whitespace-pre-wrap font-mono">{content}</pre>
</div>
)}
</div>
);
}
Loading