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
22 changes: 21 additions & 1 deletion src/apps/desktop/src/api/path_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,13 @@ pub async fn create_empty_file(
) -> Result<(), String> {
match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? {
DesktopPathTarget::Local { resolved_path, .. } => {
let options = FileOperationOptions::default();
if resolved_path.exists() {
return Err("Path already exists".to_string());
}
let options = FileOperationOptions {
backup_on_overwrite: false,
..FileOperationOptions::default()
};
app_state
.filesystem_service
.write_file_with_options(&resolved_path.to_string_lossy(), "", options)
Expand All @@ -606,6 +612,13 @@ pub async fn create_empty_file(
.get_remote_file_service_async()
.await
.map_err(|e| format!("Remote file service not available: {}", e))?;
if remote_fs
.exists(&entry.connection_id, &requested_path)
.await
.map_err(|e| format!("Failed to check remote path: {}", e))?
{
return Err("Path already exists".to_string());
}
remote_fs
.write_file(&entry.connection_id, &requested_path, b"")
.await
Expand Down Expand Up @@ -633,6 +646,13 @@ pub async fn create_directory(
.get_remote_file_service_async()
.await
.map_err(|e| format!("Remote file service not available: {}", e))?;
if remote_fs
.exists(&entry.connection_id, &requested_path)
.await
.map_err(|e| format!("Failed to check remote path: {}", e))?
{
return Err("Path already exists".to_string());
}
remote_fs
.create_dir_all(&entry.connection_id, &requested_path)
.await
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ impl FileOperationService {

self.validate_file_access(path, true).await?;

fs::create_dir_all(path)
fs::create_dir(path)
.await
.map_err(|e| FileSystemError::service(format!("Failed to create directory: {}", e)))?;

Expand Down
91 changes: 70 additions & 21 deletions src/web-ui/src/app/components/panels/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,26 @@ const FOCUS_REFRESH_THROTTLE_MS = 1000;
const REMOTE_REFRESH_POLL_MS = 15000;
const LARGE_FILE_THRESHOLD_BYTES = 2 * 1024 * 1024;

function getChildNames(nodes: FileSystemNode[], parentPath: string): string[] {
for (const node of nodes) {
if (pathsEquivalentFs(node.path, parentPath)) {
return (node.children ?? []).map((child) => child.name);
}
if (node.children) {
const childNames = getChildNames(node.children, parentPath);
if (childNames.length > 0) {
return childNames;
}
}
}
return [];
}

function isAlreadyExistsError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /already exists|file exists|os error 17|os error 183|EEXIST/i.test(message);
}

/** Format a byte-per-second speed value for display, e.g. "1.4 MB/s". */
function formatSpeed(bytesPerSec: number): string {
return `${formatBytes(bytesPerSec)}/s`;
Expand Down Expand Up @@ -262,6 +282,7 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
expandFolder,
expandFolderLazy,
expandFolderEnsure,
collapseAll,
removePath,
} = useFileSystem({
rootPath: workspacePath,
Expand Down Expand Up @@ -358,15 +379,23 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
});
}, []);

const handleInputDialogClose = useCallback(() => {
setInputDialog({
isOpen: false,
type: null,
parentPath: '',
const focusFileTree = useCallback(() => {
window.requestAnimationFrame(() => {
panelRef.current
?.querySelector<HTMLElement>('[data-shortcut-scope="filetree"]')
?.focus();
});
}, []);

const handleConfirmNewFile = useCallback(async (fileName: string) => {
const handleInputDialogClose = useCallback(() => {
setInputDialog((current) => ({
...current,
isOpen: false,
}));
focusFileTree();
}, [focusFileTree]);

const handleConfirmNewFile = useCallback(async (fileName: string): Promise<boolean> => {
const filePath = joinWorkspaceTargetPath(
inputDialog.parentPath,
fileName,
Expand All @@ -376,13 +405,17 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
try {
await workspaceAPI.createFile(filePath, currentWorkspace?.connectionId);
log.info('File created', { path: filePath });
handleInputDialogClose();
loadFileTree(workspacePath || '', true);
void loadFileTree(workspacePath || '', true);
return true;
} catch (error) {
log.error('Failed to create file', error);
notification.error(t('notifications.createFileFailed', { error: String(error) }));
const messageKey = isAlreadyExistsError(error)
? 'notifications.createFileAlreadyExists'
: 'notifications.createFileFailed';
notification.error(t(messageKey));
return false;
}
}, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose, currentWorkspace]);
}, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, currentWorkspace]);

const handleNewFolder = useCallback((data: { parentPath: string }) => {
setInputDialog({
Expand All @@ -392,7 +425,7 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
});
}, []);

const handleConfirmNewFolder = useCallback(async (folderName: string) => {
const handleConfirmNewFolder = useCallback(async (folderName: string): Promise<boolean> => {
const folderPath = joinWorkspaceTargetPath(
inputDialog.parentPath,
folderName,
Expand All @@ -402,20 +435,26 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
try {
await workspaceAPI.createDirectory(folderPath, currentWorkspace?.connectionId);
log.info('Directory created', { path: folderPath });
handleInputDialogClose();
loadFileTree(workspacePath || '', true);
void loadFileTree(workspacePath || '', true);
return true;
} catch (error) {
log.error('Failed to create directory', error);
notification.error(t('notifications.createFolderFailed', { error: String(error) }));
const messageKey = isAlreadyExistsError(error)
? 'notifications.createFolderAlreadyExists'
: 'notifications.createFolderFailed';
notification.error(t(messageKey));
return false;
}
}, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose, currentWorkspace]);
}, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, currentWorkspace]);

const handleInputDialogConfirm = useCallback((value: string) => {
const handleInputDialogConfirm = useCallback((value: string): Promise<boolean> | boolean => {
if (inputDialog.type === 'newFile') {
handleConfirmNewFile(value);
} else if (inputDialog.type === 'newFolder') {
handleConfirmNewFolder(value);
return handleConfirmNewFile(value);
}
if (inputDialog.type === 'newFolder') {
return handleConfirmNewFolder(value);
}
return false;
}, [inputDialog.type, handleConfirmNewFile, handleConfirmNewFolder]);

const handleStartRename = useCallback((data: { path: string; name: string }) => {
Expand Down Expand Up @@ -750,6 +789,12 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
() => handlePaste(),
{ enabled: Boolean(workspacePath) }
);
useShortcut(
'filetree.collapseAll',
{ key: '[', ctrl: true, shift: true, scope: 'filetree' },
collapseAll,
{ enabled: Boolean(workspacePath) && viewMode === 'tree' }
);

// macOS bridge: the native menu bar intercepts Cmd+V before the DOM sees a
// keydown event, so ShortcutManager never fires. In "System" edit-menu mode
Expand Down Expand Up @@ -1321,8 +1366,12 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
confirmText={inputDialog.type === 'newFile' ? t('dialog.newFile.confirm') : t('dialog.newFolder.confirm')}
cancelText={inputDialog.type === 'newFile' ? t('dialog.newFile.cancel') : t('dialog.newFolder.cancel')}
validator={(value) => {
const errorKey = validateFileName(value, { isRemote: isRemoteCurrentWorkspace });
return errorKey ? t(errorKey) : null;
const siblingNames = getChildNames(fileTree, inputDialog.parentPath);
const errorKey = validateFileName(value, {
isRemote: isRemoteCurrentWorkspace,
siblings: siblingNames,
});
return errorKey ? t(errorKey, { name: value.trim() }) : null;
}}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const FileViewerNav: React.FC = () => {
}, []);

return (
<div className="bitfun-file-viewer-nav">
<div className="bitfun-file-viewer-nav" data-shortcut-scope="filetree">
<div className="bitfun-file-viewer-nav__header">
<span className="bitfun-file-viewer-nav__icon" aria-hidden="true">
<Folder size={15} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import './InputDialog.scss';
export interface InputDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (value: string) => void;
onConfirm: (value: string) => void | boolean | Promise<void | boolean>;
title: string;
description?: string;
placeholder?: string;
Expand Down Expand Up @@ -79,9 +79,13 @@ export const InputDialog: React.FC<InputDialogProps> = ({
return true;
};

const handleConfirm = () => {
if (validateInput(value)) {
onConfirm(value.trim());
const handleConfirm = async () => {
if (!validateInput(value)) {
return;
}

const shouldClose = await onConfirm(value.trim());
if (shouldClose !== false) {
onClose();
}
};
Expand Down
41 changes: 41 additions & 0 deletions src/web-ui/src/infrastructure/services/ShortcutManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,47 @@ describe('ShortcutManager platform primary modifier', () => {
expect(callback).not.toHaveBeenCalled();
});

it('keeps the replacement registration when the previous cleanup runs', () => {
setPlatform('Win32');
const previousCallback = vi.fn();
const replacementCallback = vi.fn();
const cleanupPrevious = shortcutManager.register(
'filetree.refresh',
{ key: 'r', ctrl: true, scope: 'filetree' },
previousCallback
);
shortcutManager.register(
'filetree.refresh',
{ key: 'r', ctrl: true, scope: 'filetree' },
replacementCallback
);

cleanupPrevious();
dispatchScopedKey('filetree', { key: 'r', code: 'KeyR', ctrlKey: true });

expect(previousCallback).not.toHaveBeenCalled();
expect(replacementCallback).toHaveBeenCalledTimes(1);
});

it('matches shifted bracket shortcuts by their physical key code', () => {
setPlatform('Win32');
const callback = vi.fn();
shortcutManager.register(
'filetree.collapseAll',
{ key: '[', ctrl: true, shift: true, scope: 'filetree' },
callback
);

dispatchScopedKey('filetree', {
key: '{',
code: 'BracketLeft',
ctrlKey: true,
shiftKey: true,
});

expect(callback).toHaveBeenCalledTimes(1);
});

it('does not inherit canvas shortcuts when focus is inside terminal scope', () => {
const canvasCallback = vi.fn();
const terminalCallback = vi.fn();
Expand Down
13 changes: 12 additions & 1 deletion src/web-ui/src/infrastructure/services/ShortcutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ function eventKeyForLookup(event: KeyboardEvent): string {
if (digit) return digit[1];
const numpad = /^Numpad([1-9])$/.exec(code);
if (numpad) return numpad[1];
// Shift changes event.key for bracket keys to "{" / "}". Use the physical
// bracket key so configurable shortcuts such as Ctrl+Shift+[ remain stable.
if (code === 'BracketLeft') return '[';
if (code === 'BracketRight') return ']';
// Keep in sync with makeMapKey: always lower-case logical key (Tab, escape, w, etc.)
return event.key.toLowerCase();
}
Expand Down Expand Up @@ -211,7 +215,14 @@ export class ShortcutManager {
this.addToLookupMap(registration);
this.notifyRegistrationListeners();

return () => this.unregister(id);
return () => this.unregisterRegistration(registration);
}

private unregisterRegistration(registration: ShortcutRegistration): boolean {
if (this.registrations.get(registration.id) !== registration) {
return false;
}
return this.unregister(registration.id);
}

public unregister(id: string): boolean {
Expand Down
6 changes: 4 additions & 2 deletions src/web-ui/src/locales/en-US/panels/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,11 @@
},
"notifications": {
"createFileSuccess": "File created successfully",
"createFileFailed": "Failed to create file: {{error}}",
"createFileFailed": "Failed to create file",
"createFileAlreadyExists": "Cannot create file: an item with the same name already exists",
"createFolderSuccess": "Folder created successfully",
"createFolderFailed": "Failed to create folder: {{error}}",
"createFolderFailed": "Failed to create folder",
"createFolderAlreadyExists": "Cannot create folder: an item with the same name already exists",
"renameSuccess": "Renamed successfully",
"renameFailed": "Failed to rename: {{error}}",
"deleteSuccess": "Deleted successfully",
Expand Down
6 changes: 4 additions & 2 deletions src/web-ui/src/locales/zh-CN/panels/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,11 @@
},
"notifications": {
"createFileSuccess": "文件创建成功",
"createFileFailed": "创建文件失败: {{error}}",
"createFileFailed": "创建文件失败",
"createFileAlreadyExists": "无法创建文件:同名文件或文件夹已存在",
"createFolderSuccess": "文件夹创建成功",
"createFolderFailed": "创建文件夹失败: {{error}}",
"createFolderFailed": "创建文件夹失败",
"createFolderAlreadyExists": "无法创建文件夹:同名文件或文件夹已存在",
"renameSuccess": "重命名成功",
"renameFailed": "重命名失败: {{error}}",
"deleteSuccess": "删除成功",
Expand Down
12 changes: 9 additions & 3 deletions src/web-ui/src/locales/zh-TW/panels/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@
}
},
"validation": {
"invalidFilename": "檔案名包含非法字符"
"invalidFilename": "檔案名包含非法字符",
"emptyName": "請輸入名稱",
"duplicateName": "名為 \"{{name}}\" 的檔案或資料夾已存在",
"reservedName": "該名稱是系統保留名",
"nameTooLong": "名稱過長"
},
"transfer": {
"downloading": "正在下載",
Expand All @@ -129,9 +133,11 @@
},
"notifications": {
"createFileSuccess": "檔案建立成功",
"createFileFailed": "建立檔案失敗: {{error}}",
"createFileFailed": "建立檔案失敗",
"createFileAlreadyExists": "無法建立檔案:同名檔案或資料夾已存在",
"createFolderSuccess": "資料夾建立成功",
"createFolderFailed": "建立資料夾失敗: {{error}}",
"createFolderFailed": "建立資料夾失敗",
"createFolderAlreadyExists": "無法建立資料夾:同名檔案或資料夾已存在",
"renameSuccess": "重新命名成功",
"renameFailed": "重新命名失敗: {{error}}",
"deleteSuccess": "刪除成功",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ export class ExplorerController {
}
}

collapseAll(): void {
this.model.collapseAll();
this.emit();
this.syncWatchers();
}

async expandFolderLazy(folderPath: string): Promise<void> {
const currentExpanded = expandedFoldersContains(this.model.getExpandedFolders(), folderPath);
if (currentExpanded) {
Expand Down
Loading
Loading