Skip to content
Open
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
103 changes: 103 additions & 0 deletions electron/handlers/vault-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,106 @@ export function registerVaultHandlers(deps: VaultHandlerDependencies): void {
}
});
}

/**
* Register local file IPC handlers that don't depend on the vault database.
* These can be registered even if vault DB initialization fails.
*/
export function registerLocalFileHandlers(): void {
// Read a local file from disk
ipcMain.handle('vault:readLocalFile', async (_event, filePath: string) => {
try {
const resolvedPath = path.resolve(filePath);
// Constrain to user's home directory
const homeDir = require('os').homedir();
if (!resolvedPath.startsWith(homeDir)) {
return { error: 'Access denied: file is outside the home directory' };
}
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);
// Constrain to user's home directory
const homeDir = require('os').homedir();
if (!resolvedPath.startsWith(homeDir)) {
return { success: false, error: 'Access denied: file is outside the home directory' };
}
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.
}
32 changes: 28 additions & 4 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ import { registerSchedulerHandlers } from './handlers/scheduler-handlers';
import { registerAutomationHandlers } from './handlers/automation-handlers';
import { registerCLIPathsHandlers } from './handlers/cli-paths-handlers';
import { registerKanbanHandlers } from './handlers/kanban-handlers';
import { registerVaultHandlers } from './handlers/vault-handlers';
import { registerVaultHandlers, registerLocalFileHandlers } from './handlers/vault-handlers';
import { registerWorldHandlers } from './handlers/world-handlers';
import { initVaultDb, closeVaultDb } from './services/vault-db';
import { initAutoUpdater, checkForUpdates, setMainWindowGetter } from './services/update-checker';
Expand Down Expand Up @@ -394,10 +394,34 @@ 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 DB 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 DB handlers skipped due to database initialization failure');
}

// Register vault handlers
registerVaultHandlers({ getMainWindow });
// Register local file handlers (no DB dependency)
try {
registerLocalFileHandlers();
console.log('[Dorothy] Local file handlers registered successfully');
} catch (err) {
console.error('[Dorothy] Failed to register local file handlers:', err);
}
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