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
10 changes: 10 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
relPath: string,
remote?: { connectionId: string; remotePath: string }
) => ipcRenderer.invoke('fs:remove', { root, relPath, ...remote }),
fsRename: (
root: string,
oldName: string,
newName: string,
remote?: { connectionId: string; remotePath: string }
) => ipcRenderer.invoke('fs:rename', { root, oldName, newName, ...remote }),
fsMkdir: (root: string, relPath: string, remote?: { connectionId: string; remotePath: string }) =>
ipcRenderer.invoke('fs:mkdir', { root, relPath, ...remote }),
fsRmdir: (root: string, relPath: string, remote?: { connectionId: string; remotePath: string }) =>
ipcRenderer.invoke('fs:rmdir', { root, relPath, ...remote }),
getProjectConfig: (projectPath: string) =>
ipcRenderer.invoke('fs:getProjectConfig', { projectPath }),
saveProjectConfig: (projectPath: string, content: string) =>
Expand Down
41 changes: 41 additions & 0 deletions src/main/services/fs/LocalFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,4 +594,45 @@ export class LocalFileSystem implements IFileSystem {
return { success: false, error: err.message };
}
}

/**
* Rename a file or directory
*/
async rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> {
try {
const fullOldPath = this.resolvePath(oldPath);
const fullNewPath = this.resolvePath(newPath);

try {
await fs.stat(fullOldPath);
} catch {
return { success: false, error: 'Source does not exist' };
}

try {
await fs.stat(fullNewPath);
return { success: false, error: 'Destination already exists' };
} catch {
// Destination doesn't exist - good
}

await fs.rename(fullOldPath, fullNewPath);
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}

/**
* Create a directory
*/
async mkdir(dirPath: string): Promise<{ success: boolean; error?: string }> {
try {
const fullPath = this.resolvePath(dirPath);
await fs.mkdir(fullPath, { recursive: true });
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
}
35 changes: 35 additions & 0 deletions src/main/services/fs/RemoteFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,41 @@ export class RemoteFileSystem implements IFileSystem {
return { success: false, error: message };
}
}
/**
* Rename a file or directory
*/
async rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }> {
try {
const sftp = await this.sshService.getSftp(this.connectionId);
const fullOldPath = this.resolveRemotePath(oldPath);
const fullNewPath = this.resolveRemotePath(newPath);
return new Promise((resolve) => {
sftp.rename(fullOldPath, fullNewPath, (err) => {
if (err) {
resolve({ success: false, error: err.message });
} else {
resolve({ success: true });
}
});
});
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Create a directory
*/
async mkdir(dirPath: string): Promise<{ success: boolean; error?: string }> {
try {
const sftp = await this.sshService.getSftp(this.connectionId);
const fullPath = this.resolveRemotePath(dirPath);

await this.ensureRemoteDir(sftp, fullPath);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}

/**
* Read image file as base64 data URL via SFTP
Expand Down
8 changes: 8 additions & 0 deletions src/main/services/fs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,14 @@ export interface IFileSystem {
*/
remove?(path: string): Promise<{ success: boolean; error?: string }>;

/**
* Rename a file or directory
*/
rename(oldPath: string, newPath: string): Promise<{ success: boolean; error?: string }>;
/**
* Create a directory
*/
mkdir(path: string): Promise<{ success: boolean; error?: string }>;
/**
* Read image file as base64 data URL
* @param path - Image file path relative to project root
Expand Down
106 changes: 106 additions & 0 deletions src/main/services/fsIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,4 +879,110 @@ export function registerFsIpc(): void {
}
}
);

// rename a file or directory

ipcMain.handle(
'fs:rename',
async (_event, args: { root: string; oldName: string; newName: string } & RemoteParams) => {
try {
if (isRemoteRequest(args)) {
try {
const rfs = createRemoteFs(args);
const oldPath = path.posix.join(args.remotePath, args.oldName);
const newPath = path.posix.join(args.remotePath, args.newName);
return await rfs.rename(oldPath, newPath);
} catch (error) {
console.error('fs:rename failed:', error);
return { success: false, error: 'Failed to rename file or directory' };
}
}

// local path

const { root, oldName, newName } = args;
if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' };
if (!oldName || !newName) return { success: false, error: 'Invalid file names' };
const oldAbs = path.resolve(root, oldName);
const newAbs = path.resolve(root, newName);
const normRoot = path.resolve(root) + path.sep;
if (!oldAbs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' };
if (!newAbs.startsWith(normRoot)) return { success: false, error: 'New path escapes root' };
if (!fs.existsSync(oldAbs)) return { success: false, error: 'Source does not exist' };
if (fs.existsSync(newAbs)) return { success: false, error: 'Destination already exists' };
fs.renameSync(oldAbs, newAbs);
return { success: true };
} catch (error) {
console.error('fs:rename failed:', error);
return { success: false, error: 'Failed to rename file or directory' };
}
}
);

// Create a directory
ipcMain.handle(
'fs:mkdir',
async (_event, args: { root: string; relPath: string } & RemoteParams) => {
try {
// --- Remote path ---
if (isRemoteRequest(args)) {
try {
const rfs = createRemoteFs(args);
const targetPath = path.posix.join(args.remotePath, args.relPath);
return await rfs.mkdir(targetPath);
} catch (error) {
console.error('fs:mkdir remote failed:', error);
return { success: false, error: 'Failed to create remote directory' };
}
}
// --- Local path ---
const { root, relPath } = args;
if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' };
if (!relPath) return { success: false, error: 'Invalid path' };
const abs = path.resolve(root, relPath);
const normRoot = path.resolve(root) + path.sep;
if (!abs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' };
fs.mkdirSync(abs, { recursive: true });
return { success: true };
} catch (error) {
console.error('fs:mkdir failed:', error);
return { success: false, error: 'Failed to create directory' };
}
}
);

// Remove a directory (recursive)
ipcMain.handle(
'fs:rmdir',
async (_event, args: { root: string; relPath: string } & RemoteParams) => {
try {
// --- Remote path ---
if (isRemoteRequest(args)) {
try {
const rfs = createRemoteFs(args);
const targetPath = path.posix.join(args.remotePath, args.relPath);
return await rfs.remove(targetPath);
} catch (error) {
console.error('fs:rmdir remote failed:', error);
return { success: false, error: 'Failed to remove remote directory' };
}
}
// --- Local path ---
const { root, relPath } = args;
if (!root || !fs.existsSync(root)) return { success: false, error: 'Invalid root path' };
if (!relPath) return { success: false, error: 'Invalid path' };
const abs = path.resolve(root, relPath);
const normRoot = path.resolve(root) + path.sep;
if (!abs.startsWith(normRoot)) return { success: false, error: 'Path escapes root' };
if (!fs.existsSync(abs)) return { success: true };
const st = safeStat(abs);
if (!st || !st.isDirectory()) return { success: false, error: 'Not a directory' };
fs.rmSync(abs, { recursive: true, force: true });
return { success: true };
} catch (error) {
console.error('fs:rmdir failed:', error);
return { success: false, error: 'Failed to remove directory' };
}
}
);
}
25 changes: 15 additions & 10 deletions src/renderer/components/EditorMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,10 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr
const result = await window.electronAPI.fsList(taskPath, { includeDirs: true });

if (result.success && result.items) {
// Filter using whitelist approach
const filteredItems = showIgnoredFiles
? result.items
: result.items.filter((item) => shouldIncludeFile(item.path));
// Filter out null/invalid items and apply whitelist approach
const filteredItems = result.items
.filter((item) => item && item.type)
.filter((item) => showIgnoredFiles || shouldIncludeFile(item.path));

// Sort items: directories first, then files, both alphabetically
const sortedItems = filteredItems.sort((a, b) => {
Expand Down Expand Up @@ -329,10 +329,10 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr
const result = await window.electronAPI.fsList(fullPath, { includeDirs: true });

if (result.success && result.items) {
// Filter using whitelist approach
const filteredItems = showIgnoredFiles
? result.items
: result.items.filter((item) => shouldIncludeFile(item.path));
// Filter out null/invalid items and apply whitelist approach
const filteredItems = result.items
.filter((item) => item && item.type)
.filter((item) => showIgnoredFiles || shouldIncludeFile(item.path));

// Sort items: directories first, then files, both alphabetically
const sortedItems = filteredItems.sort((a, b) => {
Expand Down Expand Up @@ -412,7 +412,8 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr
};

// Render file tree recursively
const renderFileTree = (node: FileNode, level: number = 0) => {
const renderFileTree = (node: FileNode | null, level: number = 0) => {
if (!node) return null;
const isExpanded = expandedDirs.has(node.path);
const isSelected = selectedFile === node.path;

Expand Down Expand Up @@ -449,7 +450,11 @@ export default function EditorMode({ taskPath, taskName, onClose }: EditorModePr
<span className="truncate text-sm">{node.name}</span>
</div>
{node.type === 'directory' && isExpanded && node.children && (
<div>{node.children.map((child) => renderFileTree(child, level + 1))}</div>
<div>
{node.children
.filter((child) => child)
.map((child) => renderFileTree(child, level + 1))}
</div>
)}
</div>
);
Expand Down
52 changes: 52 additions & 0 deletions src/renderer/components/FileExplorer/FileExplorerToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ChevronDown, CopyMinus, FilePlus, FolderPlus, Search } from 'lucide-react';
import { Button } from '../ui/button';

interface FileExplorerToolbarProps {
projectName: string;
onSearch: () => void;
onNewFile: () => void;
onNewFolder: () => void;
onCollapse: () => void;
}

export const FileExplorerToolbar: React.FC<FileExplorerToolbarProps> = ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing React import

React.FC is referenced on this line but React is never imported. Unlike JSX usage, type-level references to React.FC require React to be explicitly in scope. This will cause a TypeScript compilation error.

Suggested change
export const FileExplorerToolbar: React.FC<FileExplorerToolbarProps> = ({
import React from 'react';
export const FileExplorerToolbar: React.FC<FileExplorerToolbarProps> = ({

Or add the import at the top of the file alongside the existing imports:

Suggested change
export const FileExplorerToolbar: React.FC<FileExplorerToolbarProps> = ({
import React from 'react';
import { ChevronDown, CopyMinus, FilePlus, FolderPlus, Search } from 'lucide-react';
import { Button } from '../ui/button';

projectName,
onSearch,
onNewFile,
onNewFolder,
onCollapse,
}) => {
return (
<div className="flex h-8 items-center justify-between border-b border-border bg-muted/10 px-2">
<div className="flex items-center gap-1">
{/* search button */}

<Button variant="ghost" size="sm" onClick={onSearch} className="gap-1 text-xs">
<Search className="h-3.5 w-3.5" />
<span>Search</span>
</Button>

{/* file actions */}

<div className="flex items-center gap-1">
{/* new file */}
<Button variant="ghost" size="sm" onClick={onNewFile} className="gap-1 text-xs">
<FilePlus className="h-3.5 w-3.5" />
</Button>

{/* new folder */}

<Button variant="ghost" size="sm" onClick={onNewFolder} className="gap-1 text-xs">
<FolderPlus className="h-3.5 w-3.5" />
</Button>

{/* collapsable */}

<Button variant="ghost" size="sm" onClick={onCollapse} className="gap-1 text-xs">
<CopyMinus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
);
};
Loading
Loading