Compare commits

...

56 Commits

Author SHA1 Message Date
bincxz
131553128a Removes initial path prop and improves SFTP start logic
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Simplifies SFTP modal initialization by eliminating the initial
directory path prop and streamlining logic for selecting the
start path. Now always defaults to the user's home directory
when possible, with a fallback to root if inaccessible.
Enhances reliability and user experience by verifying
directory accessibility before loading contents.
2026-01-08 01:21:26 +08:00
bincxz
4aae4b19fc Improves loading overlay visibility when navigating
Adjusts the loading overlay to ensure it fully covers the SFTP pane and appears above other elements during directory navigation. Increases loader icon size and layering for better user feedback while loading files.
2026-01-08 01:13:05 +08:00
bincxz
7b5fb46fd7 Moves loading overlay from pane component to parent view
Centralizes the loading overlay logic by removing it from the individual pane component and handling it at the parent view level instead. Improves overlay rendering, ensures correct z-index stacking, and maintains clearer separation of concerns between view and pane responsibilities.
2026-01-08 01:10:47 +08:00
bincxz
5bfb1f01c2 Improves code readability by fixing indentation
Standardizes indentation and spacing for consistency and clarity,
making future maintenance easier and reducing potential confusion.
No logic or functional changes are introduced.
2026-01-08 01:02:45 +08:00
bincxz
12188e11ef Adds loading indicators for text editor file loading
Improves user feedback by displaying loading spinners when opening files in the SFTP and text editor modals.
Clarifies loading state to prevent confusion during slow file reads.
2026-01-08 01:02:28 +08:00
bincxz
c0756e9981 Suppresses monaco-editor source map warnings
Introduces a custom plugin to prevent noisy source map
warnings from monaco-editor during development server runs,
improving console clarity and developer experience.
2026-01-08 00:55:01 +08:00
bincxz
b600aedc6f Improves context menu icons for directory and file actions
Adds distinct icons for "open" and "download" actions in the context menu to enhance user recognition and clarify available operations for directories and files.
2026-01-08 00:53:11 +08:00
bincxz
9fe915c65e Improves SFTP home directory detection for root user
Ensures the home path resolves to /root for the root user
instead of /home/root, preventing navigation errors on SFTP.
Adds fallback logic for non-root users to check both their
home and /root directories for accessibility.
2026-01-08 00:50:02 +08:00
陈大猫
1aa634a6c2 Merge pull request #44 from binaricat/copilot/add-sftp-double-click-option
Add configurable SFTP double-click behavior setting
2026-01-08 00:47:12 +08:00
copilot-swe-agent[bot]
bfbab88ac2 Optimize: Cache isNavigableDirectory result to avoid repeated calls
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:40:47 +00:00
copilot-swe-agent[bot]
faa7fd6dad Clean up: Remove debug console.log and fix isDirectory check
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:39:29 +00:00
copilot-swe-agent[bot]
9c6c653931 Fix: SFTPModal should open files on double-click (not download)
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:37:20 +00:00
copilot-swe-agent[bot]
d46b63398e Fix: Use custom radio buttons instead of non-existent RadioGroup component
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:36:05 +00:00
copilot-swe-agent[bot]
72bc03573c Add SFTP double-click behavior setting (open vs transfer)
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 16:33:00 +00:00
copilot-swe-agent[bot]
66c543cb97 Initial plan 2026-01-07 16:23:42 +00:00
bincxz
fc61647c34 Improve code formatting for file opener logic
Updates spacing and indentation to enhance readability
and maintain consistency in file opener-related code blocks.
No functional changes introduced.
2026-01-08 00:19:55 +08:00
bincxz
b2390da5b6 Prevents stale file opener associations in callbacks
Uses a ref to always access the latest file opener association in file operation callbacks, avoiding issues caused by stale closures when opener associations change.

Adds logging for debugging opener selection logic and ensures dialogs are shown if opener data is invalid or missing.

Improves reliability of file opening behavior in SFTP view.
2026-01-08 00:19:50 +08:00
bincxz
a3e0d4d5c1 Replaces language selector with combobox and improves UI truncation
Switches the language selection component in the editor modal to a combobox for better usability and UX consistency.
Improves truncation and overflow handling on dialogs and combobox triggers to prevent layout issues and enhance readability, especially for long file names and options.
Removes redundant "only system app" info for non-editable files to streamline the dialog.
2026-01-08 00:13:37 +08:00
bincxz
45af36fd28 Removes unused SFTP binary read function from props
Cleans up the component props by eliminating an unused function,
improving maintainability and reducing potential confusion.
No functional changes introduced.
2026-01-07 23:51:53 +08:00
bincxz
00784a6b0e Removes built-in image preview functionality
Drops support for the built-in image viewer, including related UI, state, and file association logic. Simplifies file opener options and context menus to support only text editing and external system applications.

Streamlines codebase by eliminating redundant image preview code and reduces maintenance overhead for this feature.
2026-01-07 23:50:37 +08:00
bincxz
de6acf0347 Add 'Open With' option to SFTP file context menu
Enables users to open files with a chosen application via a new
'Open With' context menu action, which always prompts for an
opener selection. Improves flexibility for file handling and
association management in the SFTP view.
2026-01-07 23:36:47 +08:00
bincxz
7c067964ee Normalize whitespace and formatting in components
Cleans up inconsistent whitespace and formatting in multiple component files
to improve readability and maintain code style consistency. No functional
logic is changed.
2026-01-07 23:29:08 +08:00
bincxz
6b4cecf94f Adds "Open With" system application support for SFTP files
Enables users to open SFTP files using any system application via a new "Open With" dialog. Remembers associations per file extension, supports editing/removing these in settings, and synchronizes changes across components. Updates backend and Electron bridge to handle selecting and launching external applications on all platforms. Improves localization and user feedback for the new feature.
2026-01-07 23:28:51 +08:00
bincxz
6b83f6c494 Remove extraneous whitespace in path join logic
Cleans up unnecessary blank line to improve code readability
and maintain consistent formatting. No functional changes made.
2026-01-07 22:06:14 +08:00
bincxz
d2b58e69b0 Adds file preview and edit actions to SFTP UI
Enables text and image file viewing and editing directly from the SFTP interface with context menu actions and modals. Improves file type detection to distinguish between text, image, and known binary files, preventing inappropriate editing options. Updates editor modal with find support and refines build config to reduce dev warnings.

Enhances user workflow by reducing friction in managing remote files.
2026-01-07 22:06:04 +08:00
LAPTOP-O016UC3M\Qi Chen
7ffc9d427e Removes unnecessary whitespace for code style consistency
Cleans up extra blank lines and trailing spaces across multiple
components to improve code readability and maintain consistent
formatting. No logic or functional changes are introduced.
2026-01-07 21:09:25 +08:00
LAPTOP-O016UC3M\Qi Chen
d6db6c5db1 Adds Monaco-based syntax highlighting editor and OSC 7 CWD sync
Integrates Monaco Editor for enhanced syntax highlighting in the text editor modal, improving editing experience for code and config files.

Tracks and synchronizes the terminal's current working directory via OSC 7 escape sequences, enabling SFTP dialogs to open at the detected shell path even after user or directory changes.

Introduces IPC and backend support for querying the current shell directory from active SSH sessions, with fallback and path validation logic for remote SFTP browsing.

Improves file type detection by analyzing file content in addition to extensions.

Updates i18n strings and UI elements for clarity and future system application integration.

Adds necessary dependencies for Monaco Editor and updates content security policy for web workers.
2026-01-07 21:09:07 +08:00
陈大猫
a528ade563 Merge pull request #43 from binaricat/copilot/add-sftp-open-file-functionality
Add SFTP file opener with built-in text editor and image preview
2026-01-07 20:18:02 +08:00
copilot-swe-agent[bot]
3e2edbec5e Fix code review issues: remove pyc from text extensions, use readSftpBinary for images
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:30:21 +00:00
copilot-swe-agent[bot]
f7464f1d45 Add Settings tab for SFTP file associations management
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:27:34 +00:00
copilot-swe-agent[bot]
3bbe5f5fc4 Fix lint warnings and TypeScript errors in SFTP file opener
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:27:23 +00:00
copilot-swe-agent[bot]
e515e3d981 Add SFTP file opener feature with text editor and image preview
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 11:22:28 +00:00
copilot-swe-agent[bot]
41ecef675c Initial plan 2026-01-07 11:00:55 +00:00
LAPTOP-O016UC3M\Qi Chen
ac6f61b8cf Refreshes file list and selection on directory change
Ensures file list and selection state reset when switching directories or hosts, preventing stale data display. Also improves table header rendering by always displaying column headers for consistency and better UX.
2026-01-07 18:01:01 +08:00
LAPTOP-O016UC3M\Qi Chen
0990c26cb2 Improves symlink UI and refactors SSH IPC error handling
Enhances the UI for symbolic links by displaying a link icon overlay
and removing the arrow indicator, making symlinks more visually
distinct. Refactors SSH IPC message sending to use a new safe send
function, preventing errors when web contents are destroyed during
shutdown, and simplifies event handling for better reliability.
2026-01-07 17:31:12 +08:00
LAPTOP-O016UC3M\Qi Chen
753ce0480c Improves symlink label spacing for file rows
Adds right padding to symlink file labels for better visual separation,
enhancing readability and UI consistency when displaying symbolic links.
2026-01-07 17:15:55 +08:00
LAPTOP-O016UC3M\Qi Chen
974506415e Improves formatting and code consistency in SFTP components
Standardizes whitespace and indentation for better readability
and maintainability. Refactors markup in file list rendering to
enhance clarity and structure without changing functionality.
2026-01-07 17:01:51 +08:00
LAPTOP-O016UC3M\Qi Chen
51330c0443 Adds SFTP file rename and permission editing support
Enables renaming and permission editing for remote SFTP files, including new dialogs and backend parsing of octal/symbolic permission formats.
Improves file list with parent directory entry and virtual scrolling for large directories.
Suppresses noisy SSH authentication error traces for cleaner UI fallback.
Enhances translation coverage for updated features.
2026-01-07 17:01:37 +08:00
陈大猫
ba761004e0 Merge pull request #41 from binaricat:copilot/fix-windows-file-system-navigation
Fix Windows drive path navigation in SFTP file browser
2026-01-07 15:07:17 +08:00
copilot-swe-agent[bot]
4278188292 Fix Windows path handling in SFTP navigation bar and breadcrumb
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 06:56:45 +00:00
copilot-swe-agent[bot]
cccb17c919 Initial plan 2026-01-07 06:50:28 +00:00
LAPTOP-O016UC3M\Qi Chen
ab0e5e95b3 Improves formatting and JSX structure in SFTP components
Cleans up whitespace and indentation in SFTP-related files for better readability and consistency.
Adjusts JSX hierarchy in the SFTP view to ensure dialog and transfer components are properly nested, reducing risk of rendering issues and improving maintainability.
No logic or behavioral changes introduced.
2026-01-07 14:44:56 +08:00
LAPTOP-O016UC3M\Qi Chen
4102a45810 Improves SFTP tab performance and cross-pane tab drag
Optimizes SFTP view rendering by isolating tab activation state, using context stores and memoization to prevent unnecessary re-renders on tab switch. Introduces cross-pane tab drag-and-drop, allowing users to move tabs between panes via the tab bar, and ensures stable callback references throughout. Refactors UI components for memoization and efficient prop updates, and wraps key dialogs with React.memo for better performance.

Enhances overall responsiveness, especially when switching or managing tabs in complex SFTP sessions.
2026-01-07 14:44:50 +08:00
LAPTOP-O016UC3M\Qi Chen
dc14255983 Improves add tab button hover feedback
Enhances the visual feedback of the add tab button by adding a gradient background and smooth transition on hover, making it more noticeable and interactive.
2026-01-07 13:31:00 +08:00
LAPTOP-O016UC3M\Qi Chen
771eef0af9 Improves formatting for readability and consistency
Refactors code style in several UI rendering sections to maintain
consistent indentation and formatting. Enhances readability and
reduces visual noise, making future maintenance easier. No functional
logic is changed.
2026-01-07 13:27:00 +08:00
LAPTOP-O016UC3M\Qi Chen
45e9960d6b Enables virtualized SFTP file lists with tab-aware panes
Improves performance and responsiveness of SFTP panes by introducing virtualization for large file lists, rendering only visible rows and reducing DOM overhead. Refactors pane rendering to be tab-aware, allowing seamless switching between multiple connections. Enhances user experience with better selection logic, drag-and-drop stability, and in-place UI feedback for file operations. Also adds extensive logging for easier debugging and transitions filter/sort updates to React's concurrent mode for increased UI responsiveness.
2026-01-07 13:26:32 +08:00
LAPTOP-O016UC3M\Qi Chen
8ced017474 Prevents extra tab creation and refines pane headers
Ensures only one tab is created on initial connect by avoiding unnecessary tab addition. Updates pane header logic to allow hiding the header for empty right panes and improves tab bar active state styling for consistency.
2026-01-07 11:20:48 +08:00
陈大猫
4a07c00a71 Merge pull request #38 from binaricat/copilot/add-multiple-tabs-to-sftpview
Add multi-tab support for SFTP view
2026-01-07 11:07:12 +08:00
copilot-swe-agent[bot]
33cacfcd3d Hide tab bar and header when no tabs, add border-bottom to tab container, fix accent color
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 03:01:04 +00:00
copilot-swe-agent[bot]
35b72b0992 Refactor connect function to avoid race condition with tab creation
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:45:23 +00:00
copilot-swe-agent[bot]
fd77431847 Update tab bar styling to rectangular design with border-bottom, remove Change button, fix host selection bug
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:41:20 +00:00
copilot-swe-agent[bot]
c5f7540c6e Address code review feedback: improve comments, use constants, fix i18n keys
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:19:04 +00:00
copilot-swe-agent[bot]
b7428d0cbb Add multi-tab support for SFTP view with tab bar, drag-reorder, and close functionality
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-07 02:15:03 +00:00
copilot-swe-agent[bot]
02c4d97934 Initial plan 2026-01-07 02:00:15 +00:00
bincxz
986f552779 Closes settings window with main window exit
Some checks failed
build-packages / build-windows-latest (push) Has been cancelled
build-packages / build-macos-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Ensures the settings window closes automatically when the main window is closed, preventing orphaned settings windows and improving overall window lifecycle management.
2026-01-07 00:35:01 +08:00
bincxz
42647e3572 Removes parent window assignment for settings window
Avoids rendering issues on macOS and prevents unintended main window closure on Windows by not assigning a parent window when opening the settings window.
2026-01-07 00:29:50 +08:00
40 changed files with 5803 additions and 973 deletions

View File

@@ -419,6 +419,7 @@ const en: Messages = {
'sftp.error.uploadFailed': 'Upload failed',
'sftp.error.deleteFailed': 'Delete failed',
'sftp.error.createFolderFailed': 'Failed to create folder',
'sftp.error.renameFailed': 'Failed to rename',
'sftp.picker.title': 'Select Host',
'sftp.picker.desc': 'Pick a host for the {side} pane',
'sftp.picker.searchPlaceholder': 'Search hosts...',
@@ -432,11 +433,16 @@ const en: Messages = {
'sftp.permissions.others': 'Others',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Symbolic',
'sftp.permissions.success': 'Permissions updated successfully',
'sftp.permissions.failed': 'Failed to update permissions',
'sftp.pane.local': 'Local',
'sftp.pane.remote': 'Remote',
'sftp.pane.selectHost': 'Select host',
'sftp.pane.selectHostToStart': 'Select a host to start',
'sftp.pane.chooseFilesystem': 'Choose a local or remote filesystem to browse',
'sftp.tabs.addTab': 'Add new tab',
'sftp.tabs.closeTab': 'Close tab',
'sftp.tabs.newTab': 'New Tab',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
@@ -449,6 +455,59 @@ const en: Messages = {
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.replace': 'Replace',
// SFTP File Opener
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
'sftp.opener.title': 'Open with',
'sftp.opener.desc': 'Choose an application to open this file',
'sftp.opener.builtInEditor': 'Built-in Editor',
'sftp.opener.editDescription': 'Edit text files',
'sftp.opener.builtInImageViewer': 'Built-in Image Viewer',
'sftp.opener.previewDescription': 'Preview images',
'sftp.opener.systemApp': 'Choose Application...',
'sftp.opener.systemAppDescription': 'Select an application from your computer',
'sftp.opener.onlySystemApp': 'This file can only be opened with an external application',
'sftp.opener.noAppsAvailable': 'No applications available',
'sftp.opener.noExtension': 'files without extension',
'sftp.opener.setDefault': 'Always use this for {ext} files',
'sftp.opener.confirmTitle': 'Set as Default?',
'sftp.opener.confirmDescription': 'Do you want to always use {app} for {ext} files?',
'sftp.opener.yesRemember': 'Yes, remember this choice',
'sftp.opener.justOnce': 'Just this once',
'sftp.opener.confirm.title': 'Set Default Application',
'sftp.opener.confirm.desc': 'Do you want to always open .{ext} files with this application?',
'sftp.editor.title': 'Text Editor',
'sftp.editor.save': 'Save to Remote',
'sftp.editor.saving': 'Saving...',
'sftp.editor.saved': 'Saved successfully',
'sftp.editor.saveFailed': 'Failed to save file',
'sftp.editor.unsavedChanges': 'You have unsaved changes. Close anyway?',
'sftp.editor.syntaxHighlight': 'Syntax Highlighting',
'sftp.preview.title': 'Image Preview',
'sftp.preview.zoomIn': 'Zoom In',
'sftp.preview.zoomOut': 'Zoom Out',
'sftp.preview.resetZoom': 'Reset Zoom',
'sftp.preview.fitToWindow': 'Fit to Window',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftpFileAssociations.title': 'SFTP File Associations',
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
'settings.sftpFileAssociations.extension': 'Extension',
'settings.sftpFileAssociations.application': 'Application',
'settings.sftpFileAssociations.noAssociations': 'No file associations configured',
'settings.sftpFileAssociations.remove': 'Remove',
'settings.sftpFileAssociations.removeConfirm': 'Remove association for .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Double-click behavior',
'settings.sftp.doubleClickBehavior.desc': 'Choose the action when double-clicking a file in SFTP View',
'settings.sftp.doubleClickBehavior.open': 'Open file',
'settings.sftp.doubleClickBehavior.transfer': 'Transfer to other pane',
'settings.sftp.doubleClickBehavior.openDesc': 'Open the file in the default application',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',

View File

@@ -293,6 +293,7 @@ const zhCN: Messages = {
'sftp.error.uploadFailed': '上传失败',
'sftp.error.deleteFailed': '删除失败',
'sftp.error.createFolderFailed': '创建文件夹失败',
'sftp.error.renameFailed': '重命名失败',
'sftp.picker.title': '选择主机',
'sftp.picker.desc': '为{side}窗格选择主机',
'sftp.picker.searchPlaceholder': '搜索主机...',
@@ -301,11 +302,13 @@ const zhCN: Messages = {
'sftp.picker.local.badge': '本地',
'sftp.picker.noMatch': '没有匹配的主机',
'sftp.permissions.title': '编辑权限',
'sftp.permissions.owner': 'Owner',
'sftp.permissions.group': 'Group',
'sftp.permissions.others': 'Others',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Symbolic',
'sftp.permissions.owner': '所有者',
'sftp.permissions.group': '群组',
'sftp.permissions.others': '其他',
'sftp.permissions.octal': '八进制',
'sftp.permissions.symbolic': '符号',
'sftp.permissions.success': '权限已更新',
'sftp.permissions.failed': '权限更新失败',
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
@@ -676,6 +679,9 @@ const zhCN: Messages = {
'sftp.pane.selectHost': '选择主机',
'sftp.pane.selectHostToStart': '先选择一个主机',
'sftp.pane.chooseFilesystem': '选择要浏览的本地或远端文件系统',
'sftp.tabs.addTab': '新建标签页',
'sftp.tabs.closeTab': '关闭标签页',
'sftp.tabs.newTab': '新标签页',
'sftp.conflict.title': '文件冲突',
'sftp.conflict.desc': '目标位置已存在同名文件',
'sftp.conflict.alreadyExistsSuffix': '已存在',
@@ -688,6 +694,59 @@ const zhCN: Messages = {
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.replace': '替换',
// SFTP File Opener
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
'sftp.context.preview': '预览',
'sftp.opener.title': '打开方式',
'sftp.opener.desc': '选择一个应用程序来打开此文件',
'sftp.opener.builtInEditor': '内置编辑器',
'sftp.opener.editDescription': '编辑文本文件',
'sftp.opener.builtInImageViewer': '内置图片预览',
'sftp.opener.previewDescription': '预览图片',
'sftp.opener.systemApp': '选择应用程序...',
'sftp.opener.systemAppDescription': '从本地选择一个应用程序',
'sftp.opener.onlySystemApp': '此文件只能用外部应用程序打开',
'sftp.opener.noAppsAvailable': '无可用应用程序',
'sftp.opener.noExtension': '无扩展名文件',
'sftp.opener.setDefault': '始终使用此方式打开 {ext} 文件',
'sftp.opener.confirmTitle': '设为默认?',
'sftp.opener.confirmDescription': '是否始终使用 {app} 打开 {ext} 文件?',
'sftp.opener.yesRemember': '是,记住此选择',
'sftp.opener.justOnce': '仅此一次',
'sftp.opener.confirm.title': '设置默认应用程序',
'sftp.opener.confirm.desc': '是否始终使用此应用程序打开 .{ext} 文件?',
'sftp.editor.title': '文本编辑器',
'sftp.editor.save': '保存到远程',
'sftp.editor.saving': '保存中...',
'sftp.editor.saved': '保存成功',
'sftp.editor.saveFailed': '保存文件失败',
'sftp.editor.unsavedChanges': '您有未保存的更改。确定要关闭吗?',
'sftp.editor.syntaxHighlight': '语法高亮',
'sftp.preview.title': '图片预览',
'sftp.preview.zoomIn': '放大',
'sftp.preview.zoomOut': '缩小',
'sftp.preview.resetZoom': '重置缩放',
'sftp.preview.fitToWindow': '适应窗口',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
'settings.sftpFileAssociations.extension': '扩展名',
'settings.sftpFileAssociations.application': '应用程序',
'settings.sftpFileAssociations.noAssociations': '未配置文件关联',
'settings.sftpFileAssociations.remove': '移除',
'settings.sftpFileAssociations.removeConfirm': '确定移除 .{ext} 的关联吗?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': '双击行为',
'settings.sftp.doubleClickBehavior.desc': '选择在 SFTP 视图中双击文件时的操作',
'settings.sftp.doubleClickBehavior.open': '打开文件',
'settings.sftp.doubleClickBehavior.transfer': '传输到另一侧',
'settings.sftp.doubleClickBehavior.openDesc': '使用默认应用程序打开文件',
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.section.font': '字体',

View File

@@ -16,6 +16,7 @@ STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -36,6 +37,7 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
? 'mac'
: 'pc';
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -153,6 +155,10 @@ export const useSettingsState = () => {
const [customCSS, setCustomCSS] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS) || ''
);
const [sftpDoubleClickBehavior, setSftpDoubleClickBehavior] = useState<'open' | 'transfer'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
return (stored === 'open' || stored === 'transfer') ? stored : DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -371,11 +377,17 @@ export const useSettingsState = () => {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -426,6 +438,12 @@ export const useSettingsState = () => {
styleEl.textContent = customCSS;
}, [customCSS, notifySettingsChanged]);
// Persist SFTP double-click behavior
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -532,5 +550,7 @@ export const useSettingsState = () => {
setIsHotkeyRecording,
customCSS,
setCustomCSS,
sftpDoubleClickBehavior,
setSftpDoubleClickBehavior,
};
};

View File

@@ -174,6 +174,28 @@ export const useSftpBackend = () => {
return bridge.onTransferProgress(transferId, cb);
}, []);
const selectApplication = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) return undefined;
return bridge.selectApplication();
}, []);
const downloadSftpToTempAndOpen = useCallback(async (
sftpId: string,
remotePath: string,
fileName: string,
appPath: string
) => {
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
throw new Error("Download to temp / open with unavailable");
}
// Download the file to temp
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
// Open with the selected application
await bridge.openWithApplication(tempPath, appPath);
}, []);
return {
openSftp,
closeSftp,
@@ -201,6 +223,8 @@ export const useSftpBackend = () => {
startStreamTransfer,
cancelTransfer,
onTransferProgress,
selectApplication,
downloadSftpToTempAndOpen,
};
};

View File

@@ -0,0 +1,149 @@
/**
* useSftpFileAssociations - Hook for managing SFTP file opener associations
* Uses a shared state pattern to sync across components
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
import { getFileExtension } from '../../lib/sftpFileUtils';
export interface FileAssociationEntry {
openerType: FileOpenerType;
systemApp?: SystemAppInfo;
}
export interface FileAssociationsMap {
[extension: string]: FileAssociationEntry;
}
// Shared state and subscribers for cross-component synchronization
const subscribers = new Set<() => void>();
// Use a wrapper object so we can update the reference for useSyncExternalStore
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
function loadFromStorage(): FileAssociationsMap {
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Loading from storage:', stored);
if (stored) {
const migrated: FileAssociationsMap = {};
for (const [ext, value] of Object.entries(stored)) {
if (typeof value === 'string') {
migrated[ext] = { openerType: value as FileOpenerType };
} else {
migrated[ext] = value as FileAssociationEntry;
}
}
console.log('[SftpFileAssociations] Migrated associations:', migrated);
return migrated;
}
return {};
}
// Initialize from storage
snapshotRef = { associations: loadFromStorage() };
function saveToStorage(associations: FileAssociationsMap) {
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
// Verify it was saved
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Verification read from storage:', verify);
}
function updateAssociations(newAssociations: FileAssociationsMap) {
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
// Create new reference so useSyncExternalStore detects change
snapshotRef = { associations: newAssociations };
saveToStorage(newAssociations);
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
subscribers.forEach(callback => callback());
}
function subscribe(callback: () => void) {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
function getSnapshot() {
return snapshotRef;
}
export function useSftpFileAssociations() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const associations = snapshot.associations;
// Listen for storage events from other tabs/windows
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
updateAssociations(loadFromStorage());
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
/**
* Get the opener entry for a file based on its extension
*/
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
const ext = getFileExtension(fileName);
return associations[ext] || null;
}, [associations]);
/**
* Set the opener type for a specific extension
*/
const setOpenerForExtension = useCallback((
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo
) => {
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
updateAssociations({
...snapshotRef.associations,
[extension.toLowerCase()]: { openerType, systemApp },
});
}, []);
/**
* Remove the association for a specific extension
*/
const removeAssociation = useCallback((extension: string) => {
const next = { ...snapshotRef.associations };
delete next[extension.toLowerCase()];
updateAssociations(next);
}, []);
/**
* Get all associations as an array
*/
const getAllAssociations = useCallback((): FileAssociation[] => {
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
extension,
openerType: entry.openerType,
systemApp: entry.systemApp,
}));
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
return result;
}, [associations]);
/**
* Clear all associations
*/
const clearAllAssociations = useCallback(() => {
updateAssociations({});
}, []);
return {
associations,
getOpenerForFile,
setOpenerForExtension,
removeAssociation,
getAllAssociations,
clearAllAssociations,
};
}

View File

@@ -0,0 +1,184 @@
/**
* useSftpFileOperations - Shared file operations for SFTP components
*
* This hook provides common file operations like open, edit, preview
* that can be shared between SFTPModal and SftpView components.
*/
import { useCallback, useState } from "react";
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
import { toast } from "../../components/ui/toast";
import { useI18n } from "../i18n/I18nProvider";
import { useSftpFileAssociations } from "./useSftpFileAssociations";
export interface FileOperationsState {
// Text editor state
showTextEditor: boolean;
textEditorTarget: { name: string; fullPath: string } | null;
textEditorContent: string;
loadingTextContent: boolean;
// File opener dialog state
showFileOpenerDialog: boolean;
fileOpenerTarget: { name: string; fullPath: string } | null;
}
export interface FileOperationsActions {
// Open file based on type/association
openFile: (fileName: string, fullPath: string) => void;
// Edit text file
editFile: (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => Promise<void>;
// Save text file
saveTextFile: (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => Promise<void>;
// Handle file opener selection
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
readImageData: () => Promise<ArrayBuffer>
) => Promise<void>;
// Close modals
closeTextEditor: () => void;
closeFileOpenerDialog: () => void;
// Check if file can be edited
canEditFile: (fileName: string) => boolean;
}
export interface UseSftpFileOperationsResult {
state: FileOperationsState;
actions: FileOperationsActions;
}
export function useSftpFileOperations(): UseSftpFileOperationsResult {
const { t } = useI18n();
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
// Text editor state
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
// File opener dialog state
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
const canEditFile = useCallback((fileName: string) => {
return isTextFile(fileName);
}, []);
const closeTextEditor = useCallback(() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, []);
const closeFileOpenerDialog = useCallback(() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}, []);
const editFile = useCallback(async (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => {
try {
setLoadingTextContent(true);
setTextEditorTarget({ name: fileName, fullPath });
const content = await readContent();
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [t]);
const saveTextFile = useCallback(async (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => {
if (!textEditorTarget) return;
await writeContent(textEditorTarget.fullPath, content);
}, [textEditorTarget]);
const openFile = useCallback((fileName: string, fullPath: string) => {
const savedOpener = getOpenerForFile(fileName);
if (savedOpener) {
// User has saved an opener for this file type
// We'll just set the target and let the caller handle it
setFileOpenerTarget({ name: fileName, fullPath });
// Return the opener type so caller knows which operation to perform
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
// Don't show dialog, caller should call editFile
return 'edit' as const;
}
}
// No saved opener, show the dialog
setFileOpenerTarget({ name: fileName, fullPath });
setShowFileOpenerDialog(true);
return 'dialog' as const;
}, [getOpenerForFile, canEditFile]);
const handleFileOpenerSelect = useCallback(async (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
_readImageData: () => Promise<ArrayBuffer>
) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
if (ext !== 'file') {
setOpenerForExtension(ext, openerType);
}
}
setShowFileOpenerDialog(false);
if (openerType === 'builtin-editor') {
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
}
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
return {
state: {
showTextEditor,
textEditorTarget,
textEditorContent,
loadingTextContent,
showFileOpenerDialog,
fileOpenerTarget,
},
actions: {
openFile,
editFile,
saveTextFile,
handleFileOpenerSelect,
closeTextEditor,
closeFileOpenerDialog,
canEditFile,
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -116,6 +116,12 @@ export const useTerminalBackend = () => {
return bridge.listSerialPorts();
}, []);
const getSessionPwd = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
return bridge.getSessionPwd(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
@@ -131,6 +137,7 @@ export const useTerminalBackend = () => {
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
writeToSession,
resizeSession,
closeSession,

View File

@@ -0,0 +1,132 @@
/**
* FileOpenerDialog - Dialog for choosing how to open a file
*/
import { Edit2, FolderOpen } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
interface FileOpenerDialogProps {
open: boolean;
onClose: () => void;
fileName: string;
onSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
open,
onClose,
fileName,
onSelect,
onSelectSystemApp,
}) => {
const { t } = useI18n();
const [isSelectingApp, setIsSelectingApp] = useState(false);
const [rememberChoice, setRememberChoice] = useState(true);
const extension = getFileExtension(fileName);
// Show edit option for files that are not known binary formats
const canEdit = !isKnownBinaryFile(fileName);
// For files without extension, we use 'file' as virtual extension
// So we always allow setting default (hasExtension is always true)
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {
onSelect(openerType, rememberChoice);
onClose();
}, [rememberChoice, onSelect, onClose]);
const handleSelectSystemApp = useCallback(async () => {
setIsSelectingApp(true);
try {
const result = await onSelectSystemApp();
if (result) {
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
onSelect('system-app', rememberChoice, result);
onClose();
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setIsSelectingApp(false);
}
}, [onSelectSystemApp, rememberChoice, onSelect, onClose]);
return (
<Dialog open={open} onOpenChange={(isOpen) => {
// Don't close while selecting system app
if (!isOpen && !isSelectingApp) {
onClose();
}
}}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader className="min-w-0">
<DialogTitle>{t('sftp.opener.title')}</DialogTitle>
<DialogDescription className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
{fileName}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-2">
{canEdit && (
<Button
variant="outline"
className="w-full justify-start gap-3 h-12"
onClick={() => handleSelectBuiltIn('builtin-editor')}
>
<Edit2 size={18} className="text-primary" />
<div className="text-left">
<div className="font-medium text-sm">{t('sftp.opener.builtInEditor')}</div>
<div className="text-xs text-muted-foreground">{t('sftp.opener.editDescription')}</div>
</div>
</Button>
)}
{/* System application option */}
<Button
variant="outline"
className="w-full justify-start gap-3 h-12"
onClick={handleSelectSystemApp}
disabled={isSelectingApp}
>
<FolderOpen size={18} className="text-primary" />
<div className="text-left">
<div className="font-medium text-sm">{t('sftp.opener.systemApp')}</div>
<div className="text-xs text-muted-foreground">{t('sftp.opener.systemAppDescription')}</div>
</div>
</Button>
</div>
{/* Remember choice checkbox - always show, use 'file' for no extension */}
<div className="flex items-center gap-2 pb-2">
<input
type="checkbox"
id="remember-choice"
checked={rememberChoice}
onChange={(e) => setRememberChoice(e.target.checked)}
className="rounded border-border h-4 w-4 accent-primary"
/>
<label
htmlFor="remember-choice"
className="text-sm text-muted-foreground cursor-pointer select-none"
>
{t('sftp.opener.setDefault', { ext: displayExtension })}
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default FileOpenerDialog;

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import { AppWindow, Cloud, FileType, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useVaultState } from "../application/state/useVaultState";
@@ -10,6 +10,7 @@ import { useWindowControls } from "../application/state/useWindowControls";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
@@ -117,6 +118,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
>
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
</TabsTrigger>
<TabsTrigger
value="file-associations"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
>
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
</TabsTrigger>
<TabsTrigger
value="sync"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
@@ -173,6 +180,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
/>
)}
{mountedTabs.has("file-associations") && (
<SettingsFileAssociationsTab />
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault />

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
@@ -927,7 +929,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
ref={containerRef}
className="absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "40px",
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,
backgroundColor: effectiveTheme.colors.background,
}}

View File

@@ -0,0 +1,283 @@
/**
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
*/
import {
CloudUpload,
Loader2,
Search,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
loader.config({ paths: { vs: './node_modules/monaco-editor/min/vs' } });
import { useI18n } from '../application/i18n/I18nProvider';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Combobox } from './ui/combobox';
import { toast } from './ui/toast';
interface TextEditorModalProps {
open: boolean;
onClose: () => void;
fileName: string;
initialContent: string;
onSave: (content: string) => Promise<void>;
}
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
'python': 'python',
'shell': 'shell',
'batch': 'bat',
'powershell': 'powershell',
'c': 'c',
'cpp': 'cpp',
'java': 'java',
'kotlin': 'kotlin',
'go': 'go',
'rust': 'rust',
'ruby': 'ruby',
'php': 'php',
'perl': 'perl',
'lua': 'lua',
'r': 'r',
'swift': 'swift',
'dart': 'dart',
'csharp': 'csharp',
'fsharp': 'fsharp',
'vb': 'vb',
'html': 'html',
'css': 'css',
'scss': 'scss',
'sass': 'sass',
'less': 'less',
'json': 'json',
'jsonc': 'json',
'json5': 'json',
'xml': 'xml',
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
'plaintext': 'plaintext',
'vue': 'html',
'svelte': 'html',
'dockerfile': 'dockerfile',
'makefile': 'makefile',
'diff': 'diff',
};
return mapping[langId] || 'plaintext';
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,
initialContent,
onSave,
}) => {
const { t } = useI18n();
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// Reset content when file changes
useEffect(() => {
setContent(initialContent);
setHasChanges(false);
setLanguageId(getLanguageId(fileName));
}, [initialContent, fileName]);
// Track changes
useEffect(() => {
setHasChanges(content !== initialContent);
}, [content, initialContent]);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
try {
await onSave(content);
setHasChanges(false);
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
toast.error(
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
'SFTP'
);
} finally {
setSaving(false);
}
}, [content, onSave, saving, t]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
if (!confirmed) return;
}
onClose();
}, [hasChanges, onClose, t]);
const handleEditorChange = useCallback((value: string | undefined) => {
setContent(value || '');
}, []);
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
// Add save shortcut
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSave();
});
// Add find shortcut (Ctrl+F / Cmd+F)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
}, [handleSave]);
// Trigger search dialog
const handleSearch = useCallback(() => {
if (editorRef.current) {
editorRef.current.trigger('keyboard', 'actions.find', null);
editorRef.current.focus();
}
}, []);
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
);
const handleLanguageChange = useCallback((nextValue: string) => {
setLanguageId(nextValue || 'plaintext');
}, []);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
{/* Header */}
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold truncate">
{fileName}
{hasChanges && <span className="text-primary ml-1">*</span>}
</DialogTitle>
</div>
<div className="flex items-center gap-2 min-w-0">
{/* Search button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
title={t('common.search')}
>
<Search size={14} />
</Button>
{/* Language selector */}
<Combobox
options={languageOptions}
value={languageId}
onValueChange={handleLanguageChange}
placeholder={t('sftp.editor.syntaxHighlight')}
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
/>
{/* Save button */}
<Button
variant="default"
size="sm"
className="h-7"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<CloudUpload size={14} className="mr-1.5" />
)}
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
</Button>
{/* Close button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleClose}
>
<X size={14} />
</Button>
</div>
</div>
</DialogHeader>
{/* Monaco Editor */}
<div className="flex-1 min-h-0 relative">
<Editor
height="100%"
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme="vs-dark"
loading={
<div className="flex items-center justify-center h-full bg-[#1e1e1e]">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
</div>
}
options={{
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
roundedSelection: false,
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
wordWrap: 'off',
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: 'never',
seedSearchStringFromSelection: 'selection',
},
}}
/>
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{getLanguageName(languageId)}
</span>
<span>
{content.split('\n').length} lines {content.length} characters
</span>
</div>
</DialogContent>
</Dialog>
);
};
export default TextEditorModal;

View File

@@ -0,0 +1,210 @@
/**
* SettingsFileAssociationsTab - Manage SFTP file opener associations and behavior
*/
import { FileType, Pencil, Trash2 } from "lucide-react";
import React, { useCallback, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { useSftpFileAssociations } from "../../../application/state/useSftpFileAssociations";
import { useSettingsState } from "../../../application/state/useSettingsState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Label } from "../../ui/label";
import { SectionHeader, SettingsTabContent } from "../settings-ui";
const getOpenerLabel = (
openerType: FileOpenerType,
systemApp: SystemAppInfo | undefined,
t: (key: string) => string
): string => {
if (openerType === 'builtin-editor') {
return t('sftp.opener.builtInEditor');
} else if (openerType === 'system-app' && systemApp) {
return systemApp.name;
}
return openerType;
};
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
// Debug log for Settings page
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
}
}, [removeAssociation, t]);
const handleEdit = useCallback(async (extension: string) => {
setEditingExtension(extension);
try {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) {
return;
}
const result = await bridge.selectApplication();
if (result) {
setOpenerForExtension(extension, 'system-app', { path: result.path, name: result.name });
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setEditingExtension(null);
}
}, [setOpenerForExtension]);
return (
<SettingsTabContent value="file-associations">
<div className="space-y-8">
{/* Double-click behavior section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.doubleClickBehavior')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => setSftpDoubleClickBehavior('open')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDoubleClickBehavior === 'open'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDoubleClickBehavior === 'open'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDoubleClickBehavior === 'open' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.doubleClickBehavior.open')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.openDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setSftpDoubleClickBehavior('transfer')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDoubleClickBehavior === 'transfer'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDoubleClickBehavior === 'transfer'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDoubleClickBehavior === 'transfer' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.doubleClickBehavior.transfer')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.doubleClickBehavior.transferDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftpFileAssociations.desc')}
</p>
{associations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<FileType size={48} strokeWidth={1} className="mb-4 opacity-50" />
<p className="text-sm">{t('settings.sftpFileAssociations.noAssociations')}</p>
</div>
) : (
<div className="border border-border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.extension')}
</th>
<th className="text-left px-4 py-2 font-medium">
{t('settings.sftpFileAssociations.application')}
</th>
<th className="text-right px-4 py-2 font-medium w-28">
{/* Actions */}
</th>
</tr>
</thead>
<tbody>
{associations.map(({ extension, openerType, systemApp }) => (
<tr key={extension} className="border-b border-border last:border-b-0 hover:bg-muted/30">
<td className="px-4 py-3">
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`}
</code>
</td>
<td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? (
<span title={systemApp.path}>{systemApp.name}</span>
) : (
getOpenerLabel(openerType, systemApp, t)
)}
</td>
<td className="px-4 py-3 text-right space-x-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
title={t('common.edit')}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
title={t('settings.sftpFileAssociations.remove')}
>
<Trash2 size={14} />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</SettingsTabContent>
);
}

View File

@@ -31,7 +31,12 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
// For Windows, first part might be drive letter like "C:"
const buildPath = (index: number) => {
if (isWindowsPath) {
return parts.slice(0, index + 1).join('\\');
const builtPath = parts.slice(0, index + 1).join('\\');
// If this is just a drive letter (e.g., "C:"), add trailing backslash
if (/^[A-Za-z]:$/.test(builtPath)) {
return builtPath + '\\';
}
return builtPath;
}
return '/' + parts.slice(0, index + 1).join('/');
};

View File

@@ -3,10 +3,10 @@
*/
import { AlertCircle } from 'lucide-react';
import React,{ useState } from 'react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle } from '../ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
interface ConflictItem {
transferId: string;
@@ -25,7 +25,7 @@ interface SftpConflictDialogProps {
formatFileSize: (size: number) => string;
}
export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const { t } = useI18n();
const [applyToAll, setApplyToAll] = useState(false);
const conflict = conflicts[0]; // Handle first conflict
@@ -135,3 +135,6 @@ export const SftpConflictDialog: React.FC<SftpConflictDialogProps> = ({ conflict
</Dialog>
);
};
export const SftpConflictDialog = memo(SftpConflictDialogInner);
SftpConflictDialog.displayName = 'SftpConflictDialog';

View File

@@ -0,0 +1,158 @@
/**
* SftpContext - Provides stable callback references to SFTP components
*
* This context eliminates props drilling of callback functions through
* the component tree, significantly reducing re-renders caused by
* callback reference changes.
*/
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { Host, SftpFileEntry } from "../../types";
// Types for the context
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onOpenEntry: (entry: SftpFileEntry) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;
onClearSelection: () => void;
onSetFilter: (filter: string) => void;
onCreateDirectory: (name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onEditPermissions?: (file: SftpFileEntry) => void;
// File operations
onEditFile?: (entry: SftpFileEntry) => void;
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
}
export interface SftpDragCallbacks {
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
onDragEnd: () => void;
}
// Store for activeTabId - allows subscription without re-rendering parent
type ActiveTabStore = {
left: string | null;
right: string | null;
};
type ActiveTabListener = () => void;
let activeTabState: ActiveTabStore = { left: null, right: null };
const activeTabListeners = new Set<ActiveTabListener>();
export const activeTabStore = {
getSnapshot: () => activeTabState,
getLeftActiveTabId: () => activeTabState.left,
getRightActiveTabId: () => activeTabState.right,
setActiveTabId: (side: "left" | "right", tabId: string | null) => {
if (activeTabState[side] !== tabId) {
activeTabState = { ...activeTabState, [side]: tabId };
activeTabListeners.forEach((listener) => listener());
}
},
subscribe: (listener: ActiveTabListener) => {
activeTabListeners.add(listener);
return () => activeTabListeners.delete(listener);
},
};
// Hook to subscribe to active tab changes for a specific side
export const useActiveTabId = (side: "left" | "right"): string | null => {
return useSyncExternalStore(
activeTabStore.subscribe,
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
() => (side === "left" ? activeTabStore.getLeftActiveTabId() : activeTabStore.getRightActiveTabId()),
);
};
// Hook to check if a specific pane is active (for CSS control)
export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean => {
const activeTabId = useActiveTabId(side);
return activeTabId === paneId || (activeTabId === null && paneId !== null);
};
export interface SftpContextValue {
// Hosts list for connection picker
hosts: Host[];
// Drag state (shared between panes)
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
}
const SftpContext = createContext<SftpContextValue | null>(null);
export const useSftpContext = () => {
const context = useContext(SftpContext);
if (!context) {
throw new Error("useSftpContext must be used within SftpContextProvider");
}
return context;
};
// Hook to get callbacks for a specific side
export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks => {
const context = useSftpContext();
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
};
// Hook to get drag-related values
export const useSftpDrag = () => {
const context = useSftpContext();
return {
draggedFiles: context.draggedFiles,
...context.dragCallbacks,
};
};
// Hook to get hosts
export const useSftpHosts = () => {
const context = useSftpContext();
return context.hosts;
};
interface SftpContextProviderProps {
hosts: Host[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
children: React.ReactNode;
}
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
hosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
// Note: The callbacks objects should be stable (created with useMemo in parent)
const value = useMemo<SftpContextValue>(
() => ({
hosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
}),
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
};

View File

@@ -3,27 +3,29 @@
*/
import { Folder, Link } from 'lucide-react';
import React,{ memo } from 'react';
import React, { memo, useCallback } from 'react';
import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types';
import { ColumnWidths,formatBytes,formatDate,getFileIcon,isNavigableDirectory } from './utils';
import { ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
interface SftpFileRowProps {
entry: SftpFileEntry;
index: number;
isSelected: boolean;
isDragOver: boolean;
columnWidths: ColumnWidths;
onSelect: (e: React.MouseEvent) => void;
onOpen: () => void;
onDragStart: (e: React.DragEvent) => void;
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
onOpen: (entry: SftpFileEntry) => void;
onDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent) => void;
onDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
}
const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
entry,
index,
isSelected,
isDragOver,
columnWidths,
@@ -39,17 +41,36 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
// A symlink pointing to a directory behaves like a directory (navigable, accepts drops)
const isNavDir = isNavigableDirectory(entry);
const isSymlinkToDirectory = entry.type === 'symlink' && entry.linkTarget === 'directory';
const modifiedLabel = entry.lastModifiedFormatted || formatDate(entry.lastModified);
const sizeLabel = entry.sizeFormatted || formatBytes(entry.size);
const handleSelect = useCallback((e: React.MouseEvent) => {
onSelect(entry, index, e);
}, [entry, index, onSelect]);
const handleOpen = useCallback(() => {
console.log("[SftpFileRow] handleOpen called", { entryName: entry.name, entryType: entry.type });
onOpen(entry);
}, [entry, onOpen]);
const handleDragStart = useCallback((e: React.DragEvent) => {
onDragStart(entry, e);
}, [entry, onDragStart]);
const handleDragOver = useCallback((e: React.DragEvent) => {
onDragOver(entry, e);
}, [entry, onDragOver]);
const handleDrop = useCallback((e: React.DragEvent) => {
onDrop(entry, e);
}, [entry, onDrop]);
return (
<div
data-sftp-row="true"
draggable={!isParentDir}
onDragStart={onDragStart}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragOver={handleDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={onSelect}
onDoubleClick={onOpen}
onDrop={handleDrop}
onClick={handleSelect}
onDoubleClick={handleOpen}
className={cn(
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
@@ -68,14 +89,14 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
)}
</div>
<span className={cn("truncate", entry.type === 'symlink' && "italic")}>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
</div>
<span className="text-xs text-muted-foreground truncate">{formatDate(entry.lastModified)}</span>
<span className="text-xs text-muted-foreground truncate">{modifiedLabel}</span>
<span className="text-xs text-muted-foreground truncate text-right">
{isNavDir ? '--' : formatBytes(entry.size)}
{isNavDir ? '--' : sizeLabel}
</span>
<span className="text-xs text-muted-foreground truncate capitalize text-right">
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
@@ -84,5 +105,29 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
);
};
export const SftpFileRow = memo(SftpFileRowInner);
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
if (prev.index !== next.index) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.columnWidths.name !== next.columnWidths.name) return false;
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;
if (prev.columnWidths.size !== next.columnWidths.size) return false;
if (prev.columnWidths.type !== next.columnWidths.type) return false;
// Compare callbacks - important for ".." entry which has static properties
if (prev.onOpen !== next.onOpen) return false;
if (prev.onSelect !== next.onSelect) return false;
const prevEntry = prev.entry;
const nextEntry = next.entry;
return (
prevEntry.name === nextEntry.name &&
prevEntry.type === nextEntry.type &&
prevEntry.size === nextEntry.size &&
prevEntry.lastModified === nextEntry.lastModified &&
prevEntry.linkTarget === nextEntry.linkTarget &&
prevEntry.sizeFormatted === nextEntry.sizeFormatted &&
prevEntry.lastModifiedFormatted === nextEntry.lastModifiedFormatted
);
};
export const SftpFileRow = memo(SftpFileRowInner, areEqual);
SftpFileRow.displayName = 'SftpFileRow';

View File

@@ -3,7 +3,7 @@
*/
import { Monitor, Search } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
@@ -22,7 +22,7 @@ interface SftpHostPickerProps {
onSelectHost: (host: Host) => void;
}
export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
open,
onOpenChange,
hosts,
@@ -178,3 +178,6 @@ export const SftpHostPicker: React.FC<SftpHostPickerProps> = ({
</Dialog>
);
};
export const SftpHostPicker = memo(SftpHostPickerInner);
SftpHostPicker.displayName = 'SftpHostPicker';

View File

@@ -2,11 +2,11 @@
* SFTP Permissions Editor Dialog
*/
import React,{ useEffect,useState } from 'react';
import React, { memo, useEffect, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SftpFileEntry } from '../../types';
import { Button } from '../ui/button';
import { Dialog,DialogContent,DialogDescription,DialogFooter,DialogHeader,DialogTitle } from '../ui/dialog';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
interface SftpPermissionsDialogProps {
open: boolean;
@@ -15,7 +15,7 @@ interface SftpPermissionsDialogProps {
onSave: (file: SftpFileEntry, permissions: string) => void;
}
export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ open, onOpenChange, file, onSave }) => {
const SftpPermissionsDialogInner: React.FC<SftpPermissionsDialogProps> = ({ open, onOpenChange, file, onSave }) => {
const { t } = useI18n();
const [permissions, setPermissions] = useState({
owner: { read: false, write: false, execute: false },
@@ -24,10 +24,38 @@ export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ op
});
// Parse permissions from file
// Supports both symbolic format (rwxr-xr-x) and octal format (755)
useEffect(() => {
if (file?.permissions) {
const perms = file.permissions;
// Parse rwxrwxrwx format (skip first char for type)
// Check if it's octal format (e.g., "755", "644")
if (/^[0-7]{3,4}$/.test(perms)) {
const octal = perms.length === 4 ? perms.slice(1) : perms;
const ownerBits = parseInt(octal[0], 10);
const groupBits = parseInt(octal[1], 10);
const othersBits = parseInt(octal[2], 10);
setPermissions({
owner: {
read: (ownerBits & 4) !== 0,
write: (ownerBits & 2) !== 0,
execute: (ownerBits & 1) !== 0,
},
group: {
read: (groupBits & 4) !== 0,
write: (groupBits & 2) !== 0,
execute: (groupBits & 1) !== 0,
},
others: {
read: (othersBits & 4) !== 0,
write: (othersBits & 2) !== 0,
execute: (othersBits & 1) !== 0,
},
});
return;
}
// Parse symbolic rwxrwxrwx format (skip first char for type)
const pStr = perms.length === 10 ? perms.slice(1) : perms;
if (pStr.length >= 9) {
setPermissions({
@@ -139,3 +167,6 @@ export const SftpPermissionsDialog: React.FC<SftpPermissionsDialogProps> = ({ op
</Dialog>
);
};
export const SftpPermissionsDialog = memo(SftpPermissionsDialogInner);
SftpPermissionsDialog.displayName = 'SftpPermissionsDialog';

View File

@@ -0,0 +1,420 @@
/**
* SFTP Tab Bar Component
*
* A tab bar for managing multiple SFTP connections in a single pane.
* Features:
* - Tab items with close button
* - Add button (+) to open HostSelectModal
* - Scrollable when many tabs are open
* - Drag-and-drop reordering of tabs
*/
import { HardDrive, Monitor, Plus, X } from "lucide-react";
import React, {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { cn } from "../../lib/utils";
import { useActiveTabId } from "./SftpContext";
export interface SftpTab {
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
}
interface SftpTabBarProps {
tabs: SftpTab[];
side: "left" | "right";
onSelectTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onAddTab: () => void;
onReorderTabs: (
draggedId: string,
targetId: string,
position: "before" | "after",
) => void;
/** Called when a tab is dragged to the other side */
onMoveTabToOtherSide?: (tabId: string) => void;
}
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
tabs,
side,
onSelectTab,
onCloseTab,
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
}) => {
// Subscribe to activeTabId from store (isolated subscription)
const activeTabId = useActiveTabId(side);
// 渲染追踪 - 追踪所有 props 包括回调函数
useRenderTracker(`SftpTabBar[${side}]`, {
side,
tabsCount: tabs.length,
activeTabId,
// 追踪回调函数引用是否变化
onSelectTab,
onCloseTab,
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
});
const { t } = useI18n();
// Refs for scrollable tab container
const tabsContainerRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Drag state
const [dropIndicator, setDropIndicator] = useState<{
tabId: string;
position: "before" | "after";
} | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isCrossPaneDragOver, setIsCrossPaneDragOver] = useState(false);
const draggedTabIdRef = useRef<string | null>(null);
// Global dragend listener to ensure state is reset even if the dragged element is removed
useEffect(() => {
const handleGlobalDragEnd = () => {
if (draggedTabIdRef.current) {
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
setIsCrossPaneDragOver(false);
}
};
document.addEventListener("dragend", handleGlobalDragEnd);
return () => document.removeEventListener("dragend", handleGlobalDragEnd);
}, []);
// Check scroll state
const updateScrollState = useCallback(() => {
const container = tabsContainerRef.current;
if (container) {
setCanScrollLeft(container.scrollLeft > 0);
setCanScrollRight(
container.scrollLeft < container.scrollWidth - container.clientWidth - 1,
);
}
}, []);
// Update scroll state on mount and resize
useEffect(() => {
updateScrollState();
const container = tabsContainerRef.current;
if (container) {
container.addEventListener("scroll", updateScrollState);
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", updateScrollState);
resizeObserver.disconnect();
};
}
}, [updateScrollState, tabs]);
// Scroll to active tab when it changes
useLayoutEffect(() => {
if (!activeTabId) return;
const container = tabsContainerRef.current;
if (!container) return;
const activeTabElement = container.querySelector(
`[data-tab-id="${activeTabId}"]`,
) as HTMLElement | null;
if (activeTabElement) {
const containerRect = container.getBoundingClientRect();
const tabRect = activeTabElement.getBoundingClientRect();
if (tabRect.left < containerRect.left) {
container.scrollLeft -= containerRect.left - tabRect.left + 8;
} else if (tabRect.right > containerRect.right) {
container.scrollLeft += tabRect.right - containerRect.right + 8;
}
}
setTimeout(updateScrollState, 100);
}, [activeTabId, updateScrollState]);
// Drag handlers
const handleTabDragStart = useCallback(
(e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("sftp-tab-id", tabId);
e.dataTransfer.setData("sftp-tab-side", side);
draggedTabIdRef.current = tabId;
setTimeout(() => {
setIsDragging(true);
}, 0);
},
[side],
);
const handleTabDragEnd = useCallback(() => {
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
}, []);
const handleTabDragOver = useCallback(
(e: React.DragEvent, tabId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const position: "before" | "after" =
e.clientX < midpoint ? "before" : "after";
setDropIndicator({ tabId, position });
},
[],
);
const handleTabDrop = useCallback(
(e: React.DragEvent, targetTabId: string) => {
e.preventDefault();
const draggedId =
e.dataTransfer.getData("sftp-tab-id") || draggedTabIdRef.current;
if (draggedId && draggedId !== targetTabId && dropIndicator) {
onReorderTabs(draggedId, targetTabId, dropIndicator.position);
}
setDropIndicator(null);
setIsDragging(false);
},
[dropIndicator, onReorderTabs],
);
const handleCloseTab = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onCloseTab(tabId);
},
[onCloseTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
const draggedFromSide = e.dataTransfer.types.includes("sftp-tab-side");
if (!draggedFromSide) return;
// Check if this is from the other side (we can't read the data during dragover due to browser security)
// We'll set the indicator and validate on drop
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsCrossPaneDragOver(true);
},
[],
);
const handleCrossPaneDragLeave = useCallback(() => {
setIsCrossPaneDragOver(false);
}, []);
const handleCrossPaneDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsCrossPaneDragOver(false);
const draggedId = e.dataTransfer.getData("sftp-tab-id");
const draggedFromSide = e.dataTransfer.getData("sftp-tab-side");
// Only accept drops from the other side
if (draggedId && draggedFromSide && draggedFromSide !== side && onMoveTabToOtherSide) {
logger.info("[SftpTabBar] Cross-pane drop", {
tabId: draggedId,
fromSide: draggedFromSide,
toSide: side,
});
onMoveTabToOtherSide(draggedId);
}
// Always reset drag state on drop
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDragging(false);
},
[side, onMoveTabToOtherSide],
);
return (
<div
className={cn(
"flex items-stretch h-8 bg-secondary/30 border-b border-border/40 transition-colors",
isCrossPaneDragOver && "bg-primary/10 ring-1 ring-inset ring-primary/40",
)}
onDragOver={handleCrossPaneDragOver}
onDragLeave={handleCrossPaneDragLeave}
onDrop={handleCrossPaneDrop}
>
{/* Scrollable tabs container */}
<div className="relative flex-1 min-w-0 flex">
{/* Left fade mask */}
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-6 pointer-events-none z-10"
style={{
background:
"linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)",
}}
/>
)}
<div
ref={tabsContainerRef}
className="flex items-stretch overflow-x-auto scrollbar-none max-w-full"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{tabs.map((tab) => {
const isActive = activeTabId === tab.id;
const isBeingDragged =
isDragging && draggedTabIdRef.current === tab.id;
const showDropIndicatorBefore =
dropIndicator?.tabId === tab.id &&
dropIndicator.position === "before";
const showDropIndicatorAfter =
dropIndicator?.tabId === tab.id &&
dropIndicator.position === "after";
return (
<div
key={tab.id}
data-tab-id={tab.id}
onClick={() => onSelectTab(tab.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out",
isActive
? "text-foreground border-b-2"
: "text-muted-foreground hover:text-foreground",
isBeingDragged && "opacity-50",
)}
style={
isActive
? { borderBottomColor: "hsl(var(--accent))" }
: undefined
}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDragging && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDragging && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{tab.isLocal ? (
<Monitor
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
) : (
<HardDrive
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
)}
<span className="truncate">{tab.label}</span>
</div>
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
aria-label={t("common.close")}
>
<X size={12} />
</button>
</div>
);
})}
</div>
{/* Right fade mask */}
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-6 pointer-events-none z-10"
style={{
background:
"linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)",
}}
/>
)}
</div>
{/* Add tab button */}
<button
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
onClick={onAddTab}
title={t("sftp.tabs.addTab")}
>
<Plus size={14} />
</button>
</div>
);
};
// Custom comparison - only re-render when data props change, ignore callback refs
// Note: activeTabId is now subscribed internally, not passed as prop
const sftpTabBarAreEqual = (
prev: SftpTabBarProps,
next: SftpTabBarProps,
): boolean => {
// Compare data props only
if (prev.side !== next.side) return false;
if (prev.tabs.length !== next.tabs.length) return false;
// Deep compare tabs array
for (let i = 0; i < prev.tabs.length; i++) {
const prevTab = prev.tabs[i];
const nextTab = next.tabs[i];
if (
prevTab.id !== nextTab.id ||
prevTab.label !== nextTab.label ||
prevTab.isLocal !== nextTab.isLocal ||
prevTab.hostId !== nextTab.hostId
) {
return false;
}
}
// Ignore callback function refs - they may change but behavior is stable
return true;
};
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
SftpTabBar.displayName = "SftpTabBar";

View File

@@ -11,10 +11,26 @@ formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,type ColumnWidt
type SortOrder
} from './utils';
// Context
export {
SftpContextProvider,
useSftpContext,
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useActiveTabId,
useIsPaneActive,
activeTabStore,
type SftpPaneCallbacks,
type SftpDragCallbacks,
type SftpContextValue,
} from './SftpContext';
// Components
export { SftpBreadcrumb } from './SftpBreadcrumb';
export { SftpConflictDialog } from './SftpConflictDialog';
export { SftpFileRow } from './SftpFileRow';
export { SftpHostPicker } from './SftpHostPicker';
export { SftpPermissionsDialog } from './SftpPermissionsDialog';
export { SftpTabBar, type SftpTab } from './SftpTabBar';
export { SftpTransferItem } from './SftpTransferItem';

View File

@@ -38,6 +38,8 @@ export type XTermRuntime = {
serializeAddon: SerializeAddon;
searchAddon: SearchAddon;
dispose: () => void;
/** Current working directory detected via OSC 7 */
currentCwd: string | undefined;
};
export type CreateXTermRuntimeContext = {
@@ -76,6 +78,9 @@ export type CreateXTermRuntimeContext = {
serialLocalEcho?: boolean;
serialLineMode?: boolean;
serialLineBufferRef?: RefObject<string>;
// Callback when shell reports CWD change via OSC 7
onCwdChange?: (cwd: string) => void;
};
const detectPlatform = (): XTermPlatform => {
@@ -485,6 +490,36 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
});
// Track current working directory via OSC 7 escape sequences
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
let currentCwd: string | undefined = undefined;
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
term.parser.registerOscHandler(7, (data) => {
try {
// data is the content after "7;" - typically "file://hostname/path"
if (data.startsWith('file://')) {
// Extract path from file:// URL
const url = new URL(data);
const path = decodeURIComponent(url.pathname);
if (path && path.length > 0) {
currentCwd = path;
ctx.onCwdChange?.(path);
logger.debug('[XTerm] OSC 7 CWD update:', path);
}
} else if (data.startsWith('/')) {
// Some shells send just the path without file:// prefix
currentCwd = data;
ctx.onCwdChange?.(data);
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
}
} catch (err) {
logger.warn('[XTerm] Failed to parse OSC 7:', err);
}
return true; // Indicate we handled the sequence
});
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => {
@@ -530,5 +565,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
logger.warn("[XTerm] webglAddon dispose failed", err);
}
},
get currentCwd() {
return currentCwd;
},
};
};

View File

@@ -115,7 +115,7 @@ export function Combobox({
<PopoverTrigger asChild disabled={disabled}>
<div
className={cn(
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm",
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm min-w-0 overflow-hidden",
"hover:bg-secondary/50 transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
triggerClassName
@@ -129,7 +129,7 @@ export function Combobox({
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={placeholder}
className="flex-1 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
className="flex-1 min-w-0 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
disabled={disabled}
/>
{inputValue && (

View File

@@ -30,8 +30,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
>(({ className, children, hideCloseButton, ...props }, ref) => {
const { t } = useI18n()
return (
@@ -47,10 +47,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)

View File

@@ -468,6 +468,7 @@ export interface RemoteFile {
size: string;
lastModified: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
permissions?: string; // rwx format for owner/group/others e.g. "rwxr-xr-x"
}
export type WorkspaceNode =

View File

@@ -161,6 +161,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
const conn = new SSHClient();
// Increase max listeners to prevent Node.js warning
// Set to 0 (unlimited) since complex operations add many temp listeners
conn.setMaxListeners(0);
// Build connection options
const connOpts = {
@@ -362,6 +365,14 @@ async function openSftp(event, options) {
try {
await client.connect(connectOpts);
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
if (client.client && typeof client.client.setMaxListeners === 'function') {
client.client.setMaxListeners(0); // 0 means unlimited
}
sftpClients.set(connId, client);
// Store jump connections for cleanup when SFTP is closed
@@ -422,12 +433,26 @@ async function listSftp(event, payload) {
type = "file";
}
// Extract permissions from longname or rights
let permissions = undefined;
if (item.rights) {
// ssh2-sftp-client returns rights object with user/group/other
permissions = `${item.rights.user || '---'}${item.rights.group || '---'}${item.rights.other || '---'}`;
} else if (item.longname) {
// Fallback: parse from longname (e.g., "-rwxr-xr-x 1 root root ...")
const match = item.longname.match(/^[dlsbc-]([rwxsStT-]{9})/);
if (match) {
permissions = match[1];
}
}
return {
name: item.name,
type,
linkTarget,
size: `${item.size} bytes`,
lastModified: new Date(item.modifyTime || Date.now()).toISOString(),
permissions,
};
}));
@@ -626,9 +651,17 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
}
/**
* Get the SFTP clients map (for external access)
*/
function getSftpClients() {
return sftpClients;
}
module.exports = {
init,
registerHandlers,
getSftpClients,
openSftp,
listSftp,
readSftp,

View File

@@ -32,6 +32,15 @@ function resolveLangFromCharset(charset) {
return trimmed;
}
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
/**
* Initialize the SSH bridge with dependencies
*/
@@ -365,6 +374,8 @@ async function startSSHSession(event, options) {
hasCertificate,
keySource: options.keySource,
hasPublicKey: !!options.publicKey,
hasPrivateKey: !!options.privateKey,
hasPassword: !!options.password,
hasEffectivePassphrase: !!effectivePassphrase,
});
@@ -372,6 +383,7 @@ async function startSSHSession(event, options) {
hasCertificate,
keySource: options.keySource,
hasPublicKey: !!options.publicKey,
hasPrivateKey: !!options.privateKey,
});
let authAgent = null;
@@ -448,8 +460,9 @@ async function startSSHSession(event, options) {
}
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
conn.on("ready", () => {
console.log(`[Chain] Final target ${options.hostname} ready`);
console.log(`${logPrefix} ${options.hostname} ready`);
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
@@ -493,8 +506,8 @@ async function startSSHSession(event, options) {
const flushBuffer = () => {
if (dataBuffer.length > 0) {
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:data", { sessionId, data: dataBuffer });
const contents = event.sender;
safeSend(contents, "netcatty:data", { sessionId, data: dataBuffer });
dataBuffer = '';
}
flushTimeout = null;
@@ -529,8 +542,8 @@ async function startSSHSession(event, options) {
clearTimeout(flushTimeout);
}
flushBuffer();
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
conn.end();
for (const c of chainConnections) {
@@ -551,23 +564,26 @@ async function startSSHSession(event, options) {
});
conn.on("error", (err) => {
console.error(`[Chain] Final target ${options.hostname} error:`, err.message);
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
const contents = event.sender;
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
// Use log instead of error for auth failures (normal fallback scenario)
if (isAuthError) {
contents?.send("netcatty:auth:failed", {
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
safeSend(contents, "netcatty:auth:failed", {
sessionId,
error: err.message,
hostname: options.hostname
});
} else {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch {}
@@ -576,10 +592,10 @@ async function startSSHSession(event, options) {
});
conn.on("timeout", () => {
console.error(`[Chain] Final target ${options.hostname} connection timeout`);
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch {}
@@ -588,21 +604,21 @@ async function startSSHSession(event, options) {
});
conn.on("close", () => {
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
sessions.delete(sessionId);
for (const c of chainConnections) {
try { c.end(); } catch {}
}
});
console.log(`[Chain] Connecting to final target ${options.hostname}...`);
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
conn.connect(connectOpts);
});
} catch (err) {
console.error("[Chain] SSH chain connection error:", err.message);
const contents = electronModule.BrowserWindow.fromWebContents(event.sender)?.webContents;
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
throw err;
}
}
@@ -754,12 +770,86 @@ async function generateKeyPair(event, options) {
}
}
/**
* Wrapper for SSH session handler to suppress noisy auth error stack traces
* Auth failures are expected when fallback to password is available
*/
async function startSSHSessionWrapper(event, options) {
try {
return await startSSHSession(event, options);
} catch (err) {
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
err.message?.toLowerCase().includes('auth') ||
err.level === 'client-authentication';
if (isAuthError) {
// Re-throw with a clean error to avoid Electron printing full stack trace
// The frontend will handle this as a normal auth failure for fallback
const authError = new Error(err.message);
authError.level = 'client-authentication';
authError.isAuthError = true;
throw authError;
}
throw err;
}
}
/**
* Get current working directory from an active SSH session
* This sends 'pwd' to the shell and captures the output
*/
async function getSessionPwd(event, payload) {
const { sessionId } = payload;
const session = sessions.get(sessionId);
if (!session || !session.stream || !session.conn) {
return { success: false, error: 'Session not found or not connected' };
}
return new Promise((resolve) => {
const conn = session.conn;
const timeout = setTimeout(() => {
resolve({ success: false, error: 'Timeout getting pwd' });
}, 3000);
// Use exec on the existing connection to run pwd
conn.exec('pwd', (err, stream) => {
if (err) {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
return;
}
let stdout = '';
stream.on('data', (data) => {
stdout += data.toString();
});
stream.on('close', () => {
clearTimeout(timeout);
const cwd = stdout.trim().split(/\r?\n/).pop()?.trim();
if (cwd && cwd.startsWith('/')) {
resolve({ success: true, cwd });
} else {
resolve({ success: false, error: 'Invalid pwd output' });
}
});
stream.on('error', (err) => {
clearTimeout(timeout);
resolve({ success: false, error: err.message });
});
});
});
}
/**
* Register IPC handlers for SSH operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:start", startSSHSession);
ipcMain.handle("netcatty:start", startSSHSessionWrapper);
ipcMain.handle("netcatty:ssh:exec", execCommand);
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
ipcMain.handle("netcatty:key:generate", generateKeyPair);
}
@@ -769,5 +859,6 @@ module.exports = {
createProxySocket,
startSSHSession,
execCommand,
getSessionPwd,
generateKeyPair,
};

View File

@@ -615,6 +615,8 @@ async function createWindow(electronModule, options) {
if (saveStateTimer) clearTimeout(saveStateTimer);
const state = getWindowBoundsState(win, lastNormalBounds);
if (state) saveWindowState(state);
// Close settings window when main window closes
closeSettingsWindow();
});
win.on("enter-full-screen", () => {
@@ -731,9 +733,10 @@ async function openSettingsWindow(electronModule, options) {
backgroundColor,
icon: appIcon,
fullscreenable: !isMac,
// NOTE: Do NOT set parent on Windows - it can cause the main window to close
// when the settings window is closed in some edge cases.
parent: isMac ? mainWindow : undefined,
// NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging
// the window to a different screen (the window becomes invisible while still
// appearing in "Show All Windows" in the Dock). On Windows it can cause the
// main window to close when the settings window is closed.
modal: false,
show: false,
frame: isMac,

View File

@@ -432,6 +432,90 @@ const registerBridges = (win) => {
};
});
// Select an application from system file picker
ipcMain.handle("netcatty:selectApplication", async () => {
const { dialog } = electronModule;
let filters = [];
let defaultPath;
if (process.platform === "darwin") {
filters = [{ name: "Applications", extensions: ["app"] }];
defaultPath = "/Applications";
} else if (process.platform === "win32") {
filters = [{ name: "Executables", extensions: ["exe", "com", "bat", "cmd"] }];
defaultPath = "C:\\Program Files";
} else {
// Linux - no specific filter, user can pick any executable
filters = [{ name: "All Files", extensions: ["*"] }];
defaultPath = "/usr/bin";
}
const result = await dialog.showOpenDialog({
title: "Select Application",
defaultPath,
filters,
properties: ["openFile"],
});
if (result.canceled || !result.filePaths.length) {
return null;
}
const appPath = result.filePaths[0];
const appName = path.basename(appPath).replace(/\.[^.]+$/, "");
return { path: appPath, name: appName };
});
// Open a file with a specific application
ipcMain.handle("netcatty:openWithApplication", async (_event, { filePath, appPath }) => {
const { shell, spawn } = electronModule;
const { spawn: cpSpawn } = require("node:child_process");
if (process.platform === "darwin") {
// On macOS, use 'open' command with -a flag for specific app
cpSpawn("open", ["-a", appPath, filePath], { detached: true, stdio: "ignore" }).unref();
} else if (process.platform === "win32") {
// On Windows, just spawn the exe with the file as argument
cpSpawn(appPath, [filePath], { detached: true, stdio: "ignore", shell: true }).unref();
} else {
// On Linux, spawn the app with the file
cpSpawn(appPath, [filePath], { detached: true, stdio: "ignore" }).unref();
}
return true;
});
// Download SFTP file to temp and return local path
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName }) => {
const client = require("./bridges/sftpBridge.cjs");
const tempDir = os.tmpdir();
const tempFileName = `netcatty_${Date.now()}_${fileName}`;
const localPath = path.join(tempDir, tempFileName);
// Get the sftp client and download file
const sftpClients = client.getSftpClients ? client.getSftpClients() : null;
if (!sftpClients) {
// Fallback: use readSftp and write to temp file
const content = await client.readSftp(null, { sftpId, path: remotePath });
if (typeof content === "string") {
await fs.promises.writeFile(localPath, content, "utf-8");
} else {
await fs.promises.writeFile(localPath, content);
}
return localPath;
}
const sftpClient = sftpClients.get(sftpId);
if (!sftpClient) {
throw new Error("SFTP session not found");
}
await sftpClient.fastGet(remotePath, localPath);
return localPath;
});
console.log('[Main] All bridges registered successfully');
};

View File

@@ -234,6 +234,9 @@ const api = {
execCommand: async (options) => {
return ipcRenderer.invoke("netcatty:ssh:exec", options);
},
getSessionPwd: async (sessionId) => {
return ipcRenderer.invoke("netcatty:ssh:pwd", { sessionId });
},
generateKeyPair: async (options) => {
return ipcRenderer.invoke("netcatty:key:generate", options);
},
@@ -501,6 +504,14 @@ const api = {
ipcRenderer.invoke("netcatty:onedrive:drive:downloadSyncFile", options),
onedriveDeleteSyncFile: (options) =>
ipcRenderer.invoke("netcatty:onedrive:drive:deleteSyncFile", options),
// File opener helpers (for "Open With" feature)
selectApplication: () =>
ipcRenderer.invoke("netcatty:selectApplication"),
openWithApplication: (filePath, appPath) =>
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
downloadSftpToTemp: (sftpId, remotePath, fileName) =>
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName }),
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload

7
global.d.ts vendored
View File

@@ -168,6 +168,8 @@ interface NetcattyBridge {
command: string;
timeout?: number;
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
/** Get current working directory from an active SSH session */
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
@@ -408,6 +410,11 @@ interface NetcattyBridge {
onedriveUploadSyncFile?(options: { accessToken: string; fileName?: string; syncedFile: unknown }): Promise<{ fileId: string | null }>;
onedriveDownloadSyncFile?(options: { accessToken: string; fileId?: string; fileName?: string }): Promise<{ syncedFile: unknown | null }>;
onedriveDeleteSyncFile?(options: { accessToken: string; fileId: string }): Promise<{ ok: true }>;
// File opener helpers (for "Open With" feature)
selectApplication?(): Promise<{ path: string; name: string } | null>;
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string): Promise<string>;
}
interface Window {

View File

@@ -5,7 +5,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' data: blob: ws: wss: https:; img-src 'self' data: https:;" />
<title>netcatty SSH</title>
<style>
/* Load extended Unicode ranges for terminal box drawing characters */
@@ -206,4 +206,4 @@
<script type="module" src="/index.tsx"></script>
</body>
</html>
</html>

View File

@@ -35,5 +35,11 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
// SFTP Settings
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';

426
lib/sftpFileUtils.ts Normal file
View File

@@ -0,0 +1,426 @@
/**
* SFTP File Utilities
* Helper functions for file type detection and extension handling
*/
// Common text file extensions
const TEXT_EXTENSIONS = new Set([
// Code/Scripts
'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'vue', 'svelte',
'py', 'pyw', 'pyi',
'sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1', 'psm1',
'c', 'cpp', 'h', 'hpp', 'cc', 'cxx', 'hh', 'hxx',
'java', 'scala', 'kt', 'kts', 'groovy', 'gradle',
'go', 'rs', 'rb', 'php', 'pl', 'pm', 'lua', 'r', 'R',
'swift', 'dart', 'cs', 'fs', 'vb',
'ex', 'exs', 'erl', 'hrl', 'clj', 'cljs', 'cljc',
'hs', 'lhs', 'elm', 'ml', 'mli', 'nim',
// Web
'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl',
// Config/Data
'json', 'json5', 'jsonc', 'xml', 'xsl', 'xslt', 'xsd',
'yml', 'yaml', 'toml', 'ini', 'conf', 'cfg', 'config', 'properties',
'env', 'gitignore', 'gitattributes', 'editorconfig', 'eslintrc', 'prettierrc',
'sql', 'graphql', 'gql',
// Text/Docs
'md', 'markdown', 'mdx', 'txt', 'text', 'log', 'rst', 'adoc', 'asciidoc',
'tex', 'latex', 'bib',
// Data formats
'csv', 'tsv', 'psv',
// System
'rc', 'bashrc', 'zshrc', 'profile', 'vimrc', 'tmux', 'nanorc',
'dockerfile', 'containerfile', 'makefile', 'cmake', 'mak',
// Version control & Git
'gitconfig', 'gitmodules', 'gitkeep',
// Other common text formats
'diff', 'patch', 'htaccess', 'lock', 'sum',
// Service/System files
'service', 'socket', 'timer', 'mount', 'automount', 'target',
// Shell history and data
'history', 'zsh_history', 'bash_history',
]);
// Additional filenames (no extension) that are always text
const TEXT_FILENAMES = new Set([
'readme', 'license', 'licence', 'changelog', 'authors', 'contributors',
'copying', 'install', 'news', 'todo', 'history', 'makefile', 'dockerfile',
'gemfile', 'rakefile', 'brewfile', 'procfile', 'vagrantfile',
'cmakelists.txt', 'cmakelists',
]);
// Common image file extensions
const IMAGE_EXTENSIONS = new Set([
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg',
'ico', 'tiff', 'tif', 'heic', 'heif', 'avif', 'jfif',
]);
// Known binary file extensions - files that should never be opened as text
const BINARY_EXTENSIONS = new Set([
// Images
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'ico', 'tiff', 'tif',
'heic', 'heif', 'avif', 'jfif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef',
// Audio
'mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a', 'aiff', 'opus',
// Video
'mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'mpeg', 'mpg',
// Archives
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'lz', 'lzma', 'zst',
'tgz', 'tbz2', 'txz', 'cab', 'iso', 'dmg',
// Executables
'exe', 'dll', 'so', 'dylib', 'bin', 'app', 'msi', 'deb', 'rpm',
'apk', 'ipa', 'jar', 'war', 'ear',
// Documents (binary formats)
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
// Fonts
'ttf', 'otf', 'woff', 'woff2', 'eot',
// Database
'db', 'sqlite', 'sqlite3', 'mdb', 'accdb',
// Object files
'o', 'obj', 'pyc', 'pyo', 'class', 'beam',
// Other binary
'swf', 'fla', 'blend', 'unity3d', 'unitypackage',
]);
// MIME types for images (for creating blob URLs)
const IMAGE_MIME_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
jfif: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
bmp: 'image/bmp',
webp: 'image/webp',
svg: 'image/svg+xml',
ico: 'image/x-icon',
tiff: 'image/tiff',
tif: 'image/tiff',
heic: 'image/heic',
heif: 'image/heif',
avif: 'image/avif',
};
// Language IDs for syntax highlighting
const EXTENSION_TO_LANGUAGE: Record<string, string> = {
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
cjs: 'javascript',
ts: 'typescript',
tsx: 'typescript',
py: 'python',
pyw: 'python',
pyi: 'python',
sh: 'shell',
bash: 'shell',
zsh: 'shell',
fish: 'shell',
bat: 'batch',
cmd: 'batch',
ps1: 'powershell',
psm1: 'powershell',
c: 'c',
cpp: 'cpp',
h: 'c',
hpp: 'cpp',
cc: 'cpp',
cxx: 'cpp',
java: 'java',
kt: 'kotlin',
kts: 'kotlin',
go: 'go',
rs: 'rust',
rb: 'ruby',
php: 'php',
pl: 'perl',
lua: 'lua',
r: 'r',
R: 'r',
swift: 'swift',
dart: 'dart',
cs: 'csharp',
fs: 'fsharp',
vb: 'vb',
html: 'html',
htm: 'html',
xhtml: 'html',
css: 'css',
scss: 'scss',
sass: 'sass',
less: 'less',
json: 'json',
jsonc: 'jsonc',
json5: 'json5',
xml: 'xml',
xsl: 'xml',
xslt: 'xml',
yml: 'yaml',
yaml: 'yaml',
toml: 'toml',
ini: 'ini',
conf: 'ini',
cfg: 'ini',
sql: 'sql',
graphql: 'graphql',
gql: 'graphql',
md: 'markdown',
markdown: 'markdown',
mdx: 'markdown',
txt: 'plaintext',
log: 'plaintext',
vue: 'vue',
svelte: 'svelte',
dockerfile: 'dockerfile',
makefile: 'makefile',
diff: 'diff',
patch: 'diff',
};
/**
* Get the file extension from a filename
* For files without extension, returns 'file'
*/
export function getFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot === -1 || lastDot === 0) {
return 'file'; // No extension or hidden file without extension
}
return fileName.slice(lastDot + 1).toLowerCase();
}
/**
* Check if a file is a text file based on its extension and name
*/
export function isTextFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
// Check known text extensions
if (TEXT_EXTENSIONS.has(ext)) {
return true;
}
// Check common filenames that are text but have no extension
const baseName = fileName.toLowerCase().split('/').pop() || '';
const nameWithoutExt = baseName.replace(/\.[^.]+$/, '');
// Check exact filename matches
if (TEXT_FILENAMES.has(baseName) || TEXT_FILENAMES.has(nameWithoutExt)) {
return true;
}
// Check dot-files that are typically text config files
if (baseName.startsWith('.')) {
const dotConfigPatterns = [
/^\.(git|npm|yarn|docker|eslint|prettier|babel|env)/,
/^\.(nvmrc|ruby-version|python-version|node-version)$/,
/rc$/, // Files ending with 'rc' like .bashrc, .vimrc
];
if (dotConfigPatterns.some(pattern => pattern.test(baseName))) {
return true;
}
}
return false;
}
/**
* Check if binary data appears to be text by analyzing byte patterns
* This provides a more accurate detection than extension-only checking
*
* @param data - First chunk of file data (ArrayBuffer or Uint8Array)
* @param maxBytes - Maximum bytes to check (default 512)
* @returns true if data appears to be text
*/
export function isTextData(data: ArrayBuffer | Uint8Array, maxBytes: number = 512): boolean {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
const checkLength = Math.min(bytes.length, maxBytes);
if (checkLength === 0) return true; // Empty file is considered text
let controlChars = 0;
let nullBytes = 0;
let highBytes = 0;
let totalBytes = 0;
for (let i = 0; i < checkLength; i++) {
const byte = bytes[i];
totalBytes++;
// Null bytes are strong indicators of binary files
if (byte === 0) {
nullBytes++;
if (nullBytes > 0) return false; // Even one null byte suggests binary
}
// Control characters (except common ones like \t, \n, \r)
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
controlChars++;
}
// High-bit characters (non-ASCII) - some are OK for UTF-8
if (byte > 127) {
highBytes++;
}
}
// If more than 30% are control chars or more than 95% are high-bit chars, likely binary
const controlRatio = controlChars / totalBytes;
const highRatio = highBytes / totalBytes;
if (controlRatio > 0.3) return false;
if (highRatio > 0.95) return false;
return true;
}
/**
* Enhanced text file detection combining extension and content analysis
* Use this when you have access to file data for better accuracy
*/
export function isTextFileEnhanced(fileName: string, data?: ArrayBuffer | Uint8Array): boolean {
// First check by extension
const extCheck = isTextFile(fileName);
// If we have data, verify it's actually text
if (data && data.byteLength > 0) {
return extCheck && isTextData(data);
}
// Fall back to extension-only check
return extCheck;
}
/**
* Check if a file is definitely a binary file based on its extension.
* Used to exclude files from "Edit" option in context menu.
*/
export function isKnownBinaryFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
return BINARY_EXTENSIONS.has(ext);
}
/**
* Check if a file could potentially be opened as text.
* This is more permissive than isTextFile - it returns true for any file
* that is not a known binary file. Used for showing "Edit" in context menu.
* Actual text detection should be done by reading file content.
*/
export function couldBeTextFile(fileName: string): boolean {
// If it's a known binary file, definitely not text
if (isKnownBinaryFile(fileName)) {
return false;
}
// Otherwise, it could be text - we'll verify when actually opening
return true;
}
/**
* Check if a file is an image file based on its extension
*/
export function isImageFile(fileName: string): boolean {
const ext = getFileExtension(fileName);
return IMAGE_EXTENSIONS.has(ext);
}
/**
* Get MIME type for an image file
*/
export function getImageMimeType(fileName: string): string {
const ext = getFileExtension(fileName);
return IMAGE_MIME_TYPES[ext] || 'application/octet-stream';
}
/**
* Get language ID for syntax highlighting
*/
export function getLanguageId(fileName: string): string {
const ext = getFileExtension(fileName);
return EXTENSION_TO_LANGUAGE[ext] || 'plaintext';
}
/**
* Get a user-friendly name for a language
*/
export function getLanguageName(languageId: string): string {
const names: Record<string, string> = {
javascript: 'JavaScript',
typescript: 'TypeScript',
python: 'Python',
shell: 'Shell',
batch: 'Batch',
powershell: 'PowerShell',
c: 'C',
cpp: 'C++',
java: 'Java',
kotlin: 'Kotlin',
go: 'Go',
rust: 'Rust',
ruby: 'Ruby',
php: 'PHP',
perl: 'Perl',
lua: 'Lua',
r: 'R',
swift: 'Swift',
dart: 'Dart',
csharp: 'C#',
fsharp: 'F#',
vb: 'Visual Basic',
html: 'HTML',
css: 'CSS',
scss: 'SCSS',
sass: 'Sass',
less: 'Less',
json: 'JSON',
jsonc: 'JSON with Comments',
json5: 'JSON5',
xml: 'XML',
yaml: 'YAML',
toml: 'TOML',
ini: 'INI',
sql: 'SQL',
graphql: 'GraphQL',
markdown: 'Markdown',
plaintext: 'Plain Text',
vue: 'Vue',
svelte: 'Svelte',
dockerfile: 'Dockerfile',
makefile: 'Makefile',
diff: 'Diff',
};
return names[languageId] || languageId.charAt(0).toUpperCase() + languageId.slice(1);
}
/**
* File opener application types
* - 'builtin-editor': Built-in text editor (Monaco)
* - 'system-app': External system application (stores path)
*/
export type FileOpenerType = 'builtin-editor' | 'system-app';
/**
* System application info for file associations
*/
export interface SystemAppInfo {
path: string; // Path to the executable/app
name: string; // Display name
}
/**
* File association record
*/
export interface FileAssociation {
extension: string;
openerType: FileOpenerType;
systemApp?: SystemAppInfo; // Only set when openerType is 'system-app'
}
/**
* Get all supported language IDs for syntax highlighting dropdown
*/
export function getSupportedLanguages(): { id: string; name: string }[] {
const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE));
languageIds.add('plaintext');
return Array.from(languageIds)
.map(id => ({ id, name: getLanguageName(id) }))
.sort((a, b) => a.name.localeCompare(b.name));
}

90
lib/useRenderTracker.ts Normal file
View File

@@ -0,0 +1,90 @@
import { useRef } from "react";
import { logger } from "./logger";
/**
* 追踪组件渲染次数和原因
* 在开发环境下帮助识别不必要的重渲染
*
* @param componentName 组件名称
* @param props 当前 props用于比较变化
* @param enabled 是否启用追踪,默认 true
*/
export function useRenderTracker(
componentName: string,
props: Record<string, unknown>,
enabled: boolean = true
): void {
const renderCountRef = useRef(0);
const prevPropsRef = useRef<Record<string, unknown>>({});
renderCountRef.current += 1;
if (!enabled) return;
const renderCount = renderCountRef.current;
const prevProps = prevPropsRef.current;
// 找出变化的 props
const changedProps: string[] = [];
const allKeys = new Set([...Object.keys(props), ...Object.keys(prevProps)]);
for (const key of allKeys) {
if (prevProps[key] !== props[key]) {
changedProps.push(key);
}
}
// 只在有变化时打印(减少日志噪音)
if (renderCount === 1) {
logger.info(`[Render] ${componentName} - 首次渲染`);
} else if (changedProps.length > 0) {
logger.info(`[Render] ${componentName} - 第${renderCount}次渲染`, {
changedProps,
details: changedProps.reduce((acc, key) => {
acc[key] = {
prev: summarizeValue(prevProps[key]),
curr: summarizeValue(props[key]),
};
return acc;
}, {} as Record<string, { prev: string; curr: string }>),
});
}
// 不再打印 "props未变化" 的警告 - 这是正常的 React 行为
// 更新 prevProps
prevPropsRef.current = { ...props };
}
/**
* 简化值的显示,避免日志过长
*/
function summarizeValue(value: unknown): string {
if (value === undefined) return "undefined";
if (value === null) return "null";
if (typeof value === "function") return `fn:${value.name || "anonymous"}`;
if (typeof value === "object") {
if (Array.isArray(value)) return `Array(${value.length})`;
const keys = Object.keys(value);
if (keys.length <= 3) {
return `{${keys.join(", ")}}`;
}
return `Object(${keys.length} keys)`;
}
if (typeof value === "string" && value.length > 30) {
return `"${value.slice(0, 30)}..."`;
}
return String(value);
}
/**
* 简单的渲染计数器,只记录渲染次数不做详细分析
*/
export function useRenderCount(componentName: string): number {
const renderCountRef = useRef(0);
renderCountRef.current += 1;
// 每次渲染都打印
logger.info(`[Render] ${componentName} - 第${renderCountRef.current}次渲染`);
return renderCountRef.current;
}

69
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@google/genai": "1.33.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
@@ -30,6 +31,7 @@
"@xterm/xterm": "^5.5.0",
"clsx": "2.1.1",
"lucide-react": "0.560.0",
"monaco-editor": "^0.55.1",
"node-pty": "1.1.0-beta19",
"react": "^19.2.1",
"react-dom": "^19.2.1",
@@ -2879,6 +2881,29 @@
"node": ">= 10.0.0"
}
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
"license": "MIT",
"dependencies": {
"state-local": "^1.0.6"
}
},
"node_modules/@monaco-editor/react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
"license": "MIT",
"dependencies": {
"@monaco-editor/loader": "^1.5.0"
},
"peerDependencies": {
"monaco-editor": ">= 0.25.0 < 1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@npmcli/fs": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz",
@@ -5598,6 +5623,13 @@
"@types/node": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -7421,6 +7453,15 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -9861,6 +9902,18 @@
"node": ">=12"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@@ -10101,6 +10154,16 @@
"node": ">=10"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -11406,6 +11469,12 @@
"node": ">= 6"
}
},
"node_modules/state-local": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",

View File

@@ -28,6 +28,7 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@google/genai": "1.33.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
@@ -44,6 +45,7 @@
"@xterm/xterm": "^5.5.0",
"clsx": "2.1.1",
"lucide-react": "0.560.0",
"monaco-editor": "^0.55.1",
"node-pty": "1.1.0-beta19",
"react": "^19.2.1",
"react-dom": "^19.2.1",

View File

@@ -3,6 +3,21 @@ import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vite';
// Custom plugin to suppress monaco-editor source map warnings
const suppressMonacoSourcemapWarning = () => ({
name: 'suppress-monaco-sourcemap-warning',
apply: 'serve' as const,
configResolved(config: { logger: { warn: (msg: string, options?: { timestamp?: boolean }) => void } }) {
const originalWarn = config.logger.warn;
config.logger.warn = (msg: string, options?: { timestamp?: boolean }) => {
// Suppress monaco-editor source map warnings
if (msg.includes('monaco-editor') && msg.includes('source map')) return;
if (msg.includes('loader.js.map')) return;
originalWarn(msg, options);
};
},
});
export default defineConfig(() => {
return {
base: "./",
@@ -16,10 +31,14 @@ export default defineConfig(() => {
// while still enabling crossOriginIsolated.
'Cross-Origin-Embedder-Policy': 'credentialless',
},
hmr: {
overlay: true,
},
},
build: {
chunkSizeWarningLimit: 3000,
target: 'esnext', // Required for top-level await in WASM modules
sourcemap: false, // Disable source maps to avoid missing map file warnings
// Optimize chunk splitting for faster initial load
rollupOptions: {
output: {
@@ -48,7 +67,7 @@ export default defineConfig(() => {
},
},
},
plugins: [tailwindcss(), react()],
plugins: [suppressMonacoSourcemapWarning(), tailwindcss(), react()],
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),