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
16 changes: 16 additions & 0 deletions apps/web/src/components/file-browser/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type ContextMenuProps = {
onClose: () => void;
/** Callback when download is clicked (files only) */
onDownload?: () => void;
/** Callback when edit is clicked (text files only) */
onEdit?: () => void;
/** Callback when rename is clicked */
onRename: () => void;
/** Callback when move is clicked */
Expand Down Expand Up @@ -50,6 +52,7 @@ export function ContextMenu({
item,
onClose,
onDownload,
onEdit,
onRename,
onMove,
onDelete,
Expand Down Expand Up @@ -128,6 +131,11 @@ export function ContextMenu({
onClose();
};

const handleEdit = () => {
onEdit?.();
onClose();
};

const handleRename = () => {
onRename();
onClose();
Expand Down Expand Up @@ -176,6 +184,14 @@ export function ContextMenu({
</button>
)}

{/* Edit - text files only */}
{isFile && onEdit && (
<button type="button" className="context-menu-item" onClick={handleEdit} role="menuitem">
<span className="context-menu-item-icon">&#9998;</span>
Edit
</button>
)}

{/* Rename */}
<button type="button" className="context-menu-item" onClick={handleRename} role="menuitem">
<span className="context-menu-item-icon">&#9998;</span>
Expand Down
90 changes: 90 additions & 0 deletions apps/web/src/components/file-browser/FileBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { CreateFolderDialog } from './CreateFolderDialog';
import { MoveDialog } from './MoveDialog';
import { DetailsDialog } from './DetailsDialog';
import { UploadZone } from './UploadZone';
import { TextEditorDialog } from './TextEditorDialog';
import { UploadModal } from './UploadModal';
import { Breadcrumbs } from './Breadcrumbs';
import { SyncIndicator } from './SyncIndicator';
Expand All @@ -32,6 +33,70 @@ function isFileEntry(item: FolderChild): item is FileEntry {
return item.type === 'file';
}

/** Extensions recognized as editable text files. */
const TEXT_EXTENSIONS = new Set([
'.txt',
'.md',
'.json',
'.yaml',
'.yml',
'.xml',
'.csv',
'.log',
'.env',
'.toml',
'.ini',
'.cfg',
'.conf',
'.sh',
'.bash',
'.zsh',
'.fish',
'.html',
'.css',
'.js',
'.ts',
'.jsx',
'.tsx',
'.py',
'.rb',
'.rs',
'.go',
'.java',
'.c',
'.cpp',
'.h',
'.hpp',
'.sql',
'.graphql',
'.gitignore',
'.editorconfig',
]);

/** Well-known extensionless text filenames. */
const TEXT_FILENAMES = new Set([
'dockerfile',
'makefile',
'rakefile',
'gemfile',
'procfile',
'vagrantfile',
]);

/**
* Check if a filename has a text-editable extension.
*/
function isTextFile(name: string): boolean {
const lower = name.toLowerCase();
// Handle dotfiles like .gitignore, .editorconfig
if (TEXT_EXTENSIONS.has(lower)) return true;
// Handle well-known extensionless filenames like Dockerfile, Makefile
if (TEXT_FILENAMES.has(lower)) return true;
const lastDot = lower.lastIndexOf('.');
if (lastDot === -1) return false;
return TEXT_EXTENSIONS.has(lower.slice(lastDot));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
FSM1 marked this conversation as resolved.

/**
* Dialog state for rename/delete operations.
*/
Expand Down Expand Up @@ -244,6 +309,7 @@ export function FileBrowser() {
const [renameDialog, setRenameDialog] = useState<DialogState>({ open: false, item: null });
const [moveDialog, setMoveDialog] = useState<DialogState>({ open: false, item: null });
const [detailsDialog, setDetailsDialog] = useState<DialogState>({ open: false, item: null });
const [editorDialog, setEditorDialog] = useState<DialogState>({ open: false, item: null });
const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false);

// Clear selection when navigating to a new folder
Expand Down Expand Up @@ -336,6 +402,13 @@ export function FileBrowser() {
}
}, [contextMenu.item]);

// Open text editor dialog
const handleEditClick = useCallback(() => {
if (contextMenu.item) {
setEditorDialog({ open: true, item: contextMenu.item });
}
}, [contextMenu.item]);

// Confirm rename
const handleRenameConfirm = useCallback(
async (newName: string) => {
Expand Down Expand Up @@ -398,6 +471,10 @@ export function FileBrowser() {
setDetailsDialog({ open: false, item: null });
}, []);

const closeEditorDialog = useCallback(() => {
setEditorDialog({ open: false, item: null });
}, []);

// Create folder handlers
const openCreateFolderDialog = useCallback(() => {
setCreateFolderDialogOpen(true);
Expand Down Expand Up @@ -526,6 +603,11 @@ export function FileBrowser() {
item={contextMenu.item}
onClose={contextMenu.hide}
onDownload={isFileEntry(contextMenu.item) ? handleDownload : undefined}
onEdit={
isFileEntry(contextMenu.item) && isTextFile(contextMenu.item.name)
? handleEditClick
: undefined
}
onRename={handleRenameClick}
onMove={handleMoveClick}
onDelete={handleDeleteClick}
Expand Down Expand Up @@ -581,6 +663,14 @@ export function FileBrowser() {
parentFolderId={currentFolderId}
/>

{/* Text editor dialog */}
<TextEditorDialog
open={editorDialog.open}
onClose={closeEditorDialog}
item={editorDialog.item && isFileEntry(editorDialog.item) ? editorDialog.item : null}
parentFolderId={currentFolderId}
/>

{/* Upload modal (self-manages visibility) */}
<UploadModal />
</div>
Expand Down
212 changes: 212 additions & 0 deletions apps/web/src/components/file-browser/TextEditorDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { FileEntry } from '@cipherbox/crypto';
import { Modal } from '../ui/Modal';
import { useAuthStore } from '../../stores/auth.store';
import { useFolder } from '../../hooks/useFolder';
import { downloadFile } from '../../services/download.service';
import { encryptFile } from '../../services/file-crypto.service';
import { addToIpfs } from '../../lib/api/ipfs';
import '../../styles/text-editor-dialog.css';

type TextEditorDialogProps = {
open: boolean;
onClose: () => void;
item: FileEntry | null;
parentFolderId: string;
};

/**
* Modal dialog for editing text files in-browser.
*
* Full crypto round-trip: download -> decrypt -> edit -> encrypt -> re-upload
* -> update folder metadata -> unpin old CID.
*/
export function TextEditorDialog({ open, onClose, item, parentFolderId }: TextEditorDialogProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [content, setContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const { updateFile } = useFolder();

const isDirty = content !== originalContent;
const lineCount = content.split('\n').length;

// Load file content when dialog opens
useEffect(() => {
if (!open || !item) {
setContent('');
setOriginalContent('');
setError(null);
setLoading(false);
setSaving(false);
return;
}

let cancelled = false;
setLoading(true);
setError(null);

(async () => {
try {
const auth = useAuthStore.getState();
if (!auth.derivedKeypair) {
throw new Error('No keypair available - please log in again');
}

const plaintext = await downloadFile(
{
cid: item.cid,
iv: item.fileIv,
wrappedKey: item.fileKeyEncrypted,
originalName: item.name,
},
auth.derivedKeypair.privateKey
);

if (cancelled) return;

const text = new TextDecoder().decode(plaintext);
setContent(text);
setOriginalContent(text);
setLoading(false);

// Focus textarea after content loads
requestAnimationFrame(() => {
textareaRef.current?.focus();
});
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'Failed to load file');
setLoading(false);
}
})();

return () => {
cancelled = true;
};
}, [open, item]);

const handleSave = useCallback(async () => {
if (!item || !isDirty) return;

setSaving(true);
setError(null);

try {
const auth = useAuthStore.getState();
if (!auth.derivedKeypair) {
throw new Error('No keypair available - please log in again');
}

// 1. Encode content
const encoded = new TextEncoder().encode(content);
const file = new File([encoded], item.name);

// 2. Encrypt with new key/IV
const encrypted = await encryptFile(file, auth.derivedKeypair.publicKey);

// 3. Upload to IPFS
// Use .slice() to get a clean copy with its own ArrayBuffer,
// avoiding sub-view issues if ciphertext is an offset view.
const ciphertextBytes = encrypted.ciphertext.slice();
const blob = new Blob([ciphertextBytes.buffer as ArrayBuffer]);
const { cid } = await addToIpfs(blob);

// 4. Update folder metadata
await updateFile(parentFolderId, {
fileId: item.id,
newCid: cid,
newFileKeyEncrypted: encrypted.wrappedKey,
newFileIv: encrypted.iv,
newSize: encrypted.originalSize,
});

onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save file');
setSaving(false);
}
}, [item, content, isDirty, parentFolderId, updateFile, onClose]);

// Handle Ctrl/Cmd+S to save
useEffect(() => {
if (!open) return;

const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (isDirty && !saving && !loading) {
handleSave();
}
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, isDirty, saving, loading, handleSave]);

if (!item) return null;

const title = `Edit: ${item.name}`;
const isDisabled = saving || loading;

return (
<Modal
open={open}
onClose={isDisabled ? undefined : onClose}
title={title}
className="text-editor-modal"
>
{loading ? (
<div className="text-editor-loading">decrypting...</div>
) : (
<div className="text-editor-body">
<textarea
ref={textareaRef}
className="text-editor-textarea"
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={isDisabled}
spellCheck={false}
aria-label={`Content of ${item.name}`}
/>
<div className={`text-editor-status${isDirty ? ' text-editor-status--modified' : ''}`}>
<span>
{'// '}
{lineCount} {lineCount === 1 ? 'line' : 'lines'}
{' | utf-8'}
{isDirty ? ' | modified' : ''}
</span>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{error && (
<div className="text-editor-error">
{'> '}
{error}
</div>
)}
<div className="text-editor-footer">
<button
type="button"
className="dialog-button dialog-button--secondary"
onClick={onClose}
disabled={isDisabled}
>
cancel
</button>
<button
type="button"
className="dialog-button dialog-button--primary"
onClick={handleSave}
disabled={isDisabled || !isDirty}
>
{saving ? 'encrypting...' : '--save'}
</button>
</div>
</div>
)}
</Modal>
);
}
Loading
Loading