Add save actions and cross-platform file manager support (#848)
* files-download-feature * feat: add save option to file directory context menus and viewer * fix: open files in the system file manager
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
RiMore2Fill,
|
RiMore2Fill,
|
||||||
RiRefreshLine,
|
RiRefreshLine,
|
||||||
RiSearchLine,
|
RiSearchLine,
|
||||||
|
RiDownloadLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
|
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
@@ -44,7 +45,7 @@ import { useGitStatus } from '@/stores/useGitStore';
|
|||||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, getRevealLabel } from '@/lib/utils';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||||
@@ -132,6 +133,7 @@ interface FileRowProps {
|
|||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canReveal: boolean;
|
canReveal: boolean;
|
||||||
};
|
};
|
||||||
|
downloadFile?: (path: string) => Promise<void>;
|
||||||
contextMenuPath: string | null;
|
contextMenuPath: string | null;
|
||||||
setContextMenuPath: (path: string | null) => void;
|
setContextMenuPath: (path: string | null) => void;
|
||||||
onSelect: (node: FileNode) => void;
|
onSelect: (node: FileNode) => void;
|
||||||
@@ -147,6 +149,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
status,
|
status,
|
||||||
badge,
|
badge,
|
||||||
permissions,
|
permissions,
|
||||||
|
downloadFile,
|
||||||
contextMenuPath,
|
contextMenuPath,
|
||||||
setContextMenuPath,
|
setContextMenuPath,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -244,9 +247,17 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{!isDir && downloadFile && (
|
||||||
|
<DropdownMenuItem onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void downloadFile(node.path);
|
||||||
|
}}>
|
||||||
|
<RiDownloadLine className="mr-2 h-4 w-4" /> Save
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{canReveal && (
|
{canReveal && (
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||||
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> Reveal in Finder
|
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {getRevealLabel()}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isDir && (canCreateFile || canCreateFolder) && (
|
{isDir && (canCreateFile || canCreateFolder) && (
|
||||||
@@ -749,6 +760,7 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||||
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
|
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
|
||||||
|
downloadFile={files.downloadFile}
|
||||||
contextMenuPath={contextMenuPath}
|
contextMenuPath={contextMenuPath}
|
||||||
setContextMenuPath={setContextMenuPath}
|
setContextMenuPath={setContextMenuPath}
|
||||||
onSelect={handleOpenFile}
|
onSelect={handleOpenFile}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
RiFileTransferLine,
|
RiFileTransferLine,
|
||||||
RiCodeSSlashLine,
|
RiCodeSSlashLine,
|
||||||
RiNodeTree,
|
RiNodeTree,
|
||||||
|
RiDownloadLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
@@ -58,7 +59,7 @@ import {
|
|||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
|
import { cn, getModifierLabel, getRevealLabel, hasModifier } from '@/lib/utils';
|
||||||
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
@@ -292,6 +293,7 @@ interface FileRowProps {
|
|||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canReveal: boolean;
|
canReveal: boolean;
|
||||||
};
|
};
|
||||||
|
downloadFile?: (path: string) => Promise<void>;
|
||||||
contextMenuPath: string | null;
|
contextMenuPath: string | null;
|
||||||
setContextMenuPath: (path: string | null) => void;
|
setContextMenuPath: (path: string | null) => void;
|
||||||
onSelect: (node: FileNode) => void;
|
onSelect: (node: FileNode) => void;
|
||||||
@@ -308,6 +310,7 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
status,
|
status,
|
||||||
badge,
|
badge,
|
||||||
permissions,
|
permissions,
|
||||||
|
downloadFile,
|
||||||
contextMenuPath,
|
contextMenuPath,
|
||||||
setContextMenuPath,
|
setContextMenuPath,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -414,9 +417,17 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{!isDir && downloadFile && (
|
||||||
|
<DropdownMenuItem onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void downloadFile(node.path);
|
||||||
|
}}>
|
||||||
|
<RiDownloadLine className="mr-2 h-4 w-4" /> Save
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{canReveal && (
|
{canReveal && (
|
||||||
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRevealPath(node.path); }}>
|
||||||
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> Reveal in Finder
|
<RiFolderReceivedLine className="mr-2 h-4 w-4" /> {getRevealLabel()}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isDir && (canCreateFile || canCreateFolder) && (
|
{isDir && (canCreateFile || canCreateFolder) && (
|
||||||
@@ -1638,6 +1649,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
status={!isDir ? getFileStatus(node.path) : undefined}
|
status={!isDir ? getFileStatus(node.path) : undefined}
|
||||||
badge={isDir ? getFolderBadge(node.path) : undefined}
|
badge={isDir ? getFolderBadge(node.path) : undefined}
|
||||||
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
|
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete, canReveal }}
|
||||||
|
downloadFile={files.downloadFile}
|
||||||
contextMenuPath={contextMenuPath}
|
contextMenuPath={contextMenuPath}
|
||||||
setContextMenuPath={setContextMenuPath}
|
setContextMenuPath={setContextMenuPath}
|
||||||
onSelect={handleSelectFile}
|
onSelect={handleSelectFile}
|
||||||
@@ -2426,6 +2438,22 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{files.downloadFile && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const fn = files.downloadFile;
|
||||||
|
if (fn) void fn(selectedFile.path);
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="Save file"
|
||||||
|
aria-label="Save file"
|
||||||
|
>
|
||||||
|
<RiDownloadLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{exitFullscreenOnly ? (
|
{exitFullscreenOnly ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ export interface FilesAPI {
|
|||||||
rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
|
rename?(oldPath: string, newPath: string): Promise<{ success: boolean; path: string }>;
|
||||||
revealPath?(path: string): Promise<{ success: boolean }>;
|
revealPath?(path: string): Promise<{ success: boolean }>;
|
||||||
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
||||||
|
downloadFile?(path: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectEntry {
|
export interface ProjectEntry {
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ export const isMacOS = (): boolean => {
|
|||||||
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isWindows = (): boolean => {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
return /Windows/.test(navigator.userAgent || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRevealLabel = (): string => {
|
||||||
|
if (isMacOS()) return 'Reveal in Finder';
|
||||||
|
if (isWindows()) return 'Open in File Explorer';
|
||||||
|
return 'Open in File Manager';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the platform-appropriate modifier key is pressed.
|
* Checks if the platform-appropriate modifier key is pressed.
|
||||||
* On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey).
|
* On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey).
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export const createVSCodeFilesAPI = (): FilesAPI => ({
|
|||||||
|
|
||||||
async execCommands(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }> {
|
async execCommands(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }> {
|
||||||
const targetCwd = normalizePath(cwd);
|
const targetCwd = normalizePath(cwd);
|
||||||
// Use extended timeout for command execution (5 minutes)
|
|
||||||
const data = await sendBridgeMessageWithOptions<{ success: boolean; results?: CommandExecResult[] }>('api:fs:exec', {
|
const data = await sendBridgeMessageWithOptions<{ success: boolean; results?: CommandExecResult[] }>('api:fs:exec', {
|
||||||
commands,
|
commands,
|
||||||
cwd: targetCwd,
|
cwd: targetCwd,
|
||||||
@@ -126,4 +125,15 @@ export const createVSCodeFilesAPI = (): FilesAPI => ({
|
|||||||
results: Array.isArray(data?.results) ? data.results : [],
|
results: Array.isArray(data?.results) ? data.results : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async downloadFile(path: string): Promise<void> {
|
||||||
|
const target = normalizePath(path);
|
||||||
|
const url = `/api/fs/raw?path=${encodeURIComponent(target)}&download=true`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = target.split('/').pop() || 'file';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -428,6 +428,12 @@ export const registerFsRoutes = (app, dependencies) => {
|
|||||||
};
|
};
|
||||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
const download = req.query.download === 'true';
|
||||||
|
if (download) {
|
||||||
|
const fileName = path.basename(canonicalPath);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
}
|
||||||
|
|
||||||
const content = await fsPromises.readFile(canonicalPath);
|
const content = await fsPromises.readFile(canonicalPath);
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
return res.type(mimeType).send(content);
|
return res.type(mimeType).send(content);
|
||||||
@@ -589,7 +595,24 @@ export const registerFsRoutes = (app, dependencies) => {
|
|||||||
spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
|
spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
|
||||||
}
|
}
|
||||||
} else if (platform === 'win32') {
|
} else if (platform === 'win32') {
|
||||||
spawn('explorer', ['/select,', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
|
const stat = await fsPromises.stat(resolved);
|
||||||
|
const escapedPath = resolved.replace(/'/g, "''");
|
||||||
|
const explorerArg = stat.isDirectory() ? escapedPath : `/select,${escapedPath}`;
|
||||||
|
const command = `Start-Process -FilePath explorer.exe -ArgumentList '${explorerArg}'`;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(`Explorer launch failed with code ${code ?? 'unknown'}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
const stat = await fsPromises.stat(resolved);
|
const stat = await fsPromises.stat(resolved);
|
||||||
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
|
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
|
||||||
|
|||||||
@@ -211,4 +211,15 @@ export const createWebFilesAPI = (): FilesAPI => ({
|
|||||||
const result = await response.json().catch(() => ({}));
|
const result = await response.json().catch(() => ({}));
|
||||||
return { success: Boolean((result as { success?: boolean }).success) };
|
return { success: Boolean((result as { success?: boolean }).success) };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async downloadFile(path: string): Promise<void> {
|
||||||
|
const target = normalizePath(path);
|
||||||
|
const url = `/api/fs/raw?path=${encodeURIComponent(target)}&download=true`;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = target.split('/').pop() || 'file';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user