From 8e22e761668b38687802c85c1eb4546267e95f5a Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 25 Sep 2025 09:04:59 +0800 Subject: [PATCH] FIX: Complete CodeMirror integration with native search, replace, and keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace custom search/replace implementation with native CodeMirror extensions - Add proper keyboard shortcut support: Ctrl+F, Ctrl+H, Ctrl+/, Ctrl+Space, etc. - Fix browser shortcut conflicts by preventing defaults only when editor is focused - Integrate autocompletion and comment toggle functionality - Fix file name truncation in file manager grid to use text wrapping - Add comprehensive keyboard shortcuts help panel for users - Update i18n translations for editor buttons (Download, Replace, Replace All) - Unify text and code file editing under single CodeMirror instance - Add proper SSH HMAC algorithms for better compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package-lock.json | 63 +++ package.json | 3 + src/backend/ssh/file-manager.ts | 2 +- src/backend/ssh/terminal.ts | 2 +- src/backend/ssh/tunnel.ts | 4 +- src/locales/en/translation.json | 3 + src/locales/zh/translation.json | 3 + .../Apps/File Manager/FileManagerGrid.tsx | 8 +- .../File Manager/components/FileViewer.tsx | 532 +++++++----------- 9 files changed, 274 insertions(+), 346 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9becbf77..013c6bfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "termix", "version": "1.6.0", "dependencies": { + "@codemirror/autocomplete": "^6.18.7", + "@codemirror/comment": "^0.19.1", + "@codemirror/search": "^6.5.11", "@hookform/resolvers": "^5.1.1", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", @@ -149,6 +152,40 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/comment": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@codemirror/comment/-/comment-0.19.1.tgz", + "integrity": "sha512-uGKteBuVWAC6fW+Yt8u27DOnXMT/xV4Ekk2Z5mRsiADCZDqYvryrJd6PLL5+8t64BVyocwQwNfz1UswYS2CtFQ==", + "deprecated": "As of 0.20.0, this package has been merged into @codemirror/commands", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^0.19.9", + "@codemirror/text": "^0.19.0", + "@codemirror/view": "^0.19.0" + } + }, + "node_modules/@codemirror/comment/node_modules/@codemirror/state": { + "version": "0.19.9", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.19.9.tgz", + "integrity": "sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw==", + "license": "MIT", + "dependencies": { + "@codemirror/text": "^0.19.0" + } + }, + "node_modules/@codemirror/comment/node_modules/@codemirror/view": { + "version": "0.19.48", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.19.48.tgz", + "integrity": "sha512-0eg7D2Nz4S8/caetCTz61rK0tkHI17V/d15Jy0kLOT8dTLGGNJUponDnW28h2B6bERmPlVHKh8MJIr5OCp1nGw==", + "license": "MIT", + "dependencies": { + "@codemirror/rangeset": "^0.19.5", + "@codemirror/state": "^0.19.3", + "@codemirror/text": "^0.19.0", + "style-mod": "^4.0.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@codemirror/lang-angular": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", @@ -477,6 +514,25 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/rangeset": { + "version": "0.19.9", + "resolved": "https://registry.npmjs.org/@codemirror/rangeset/-/rangeset-0.19.9.tgz", + "integrity": "sha512-V8YUuOvK+ew87Xem+71nKcqu1SXd5QROMRLMS/ljT5/3MCxtgrRie1Cvild0G/Z2f1fpWxzX78V0U4jjXBorBQ==", + "deprecated": "As of 0.20.0, this package has been merged into @codemirror/state", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^0.19.0" + } + }, + "node_modules/@codemirror/rangeset/node_modules/@codemirror/state": { + "version": "0.19.9", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.19.9.tgz", + "integrity": "sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw==", + "license": "MIT", + "dependencies": { + "@codemirror/text": "^0.19.0" + } + }, "node_modules/@codemirror/search": { "version": "6.5.11", "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", @@ -497,6 +553,13 @@ "@marijn/find-cluster-break": "^1.0.0" } }, + "node_modules/@codemirror/text": { + "version": "0.19.6", + "resolved": "https://registry.npmjs.org/@codemirror/text/-/text-0.19.6.tgz", + "integrity": "sha512-T9jnREMIygx+TPC1bOuepz18maGq/92q2a+n4qTqObKwvNMg+8cMTslb8yxeEDEq7S3kpgGWxgO1UWbQRij0dA==", + "deprecated": "As of 0.20.0, this package has been merged into @codemirror/state", + "license": "MIT" + }, "node_modules/@codemirror/theme-one-dark": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", diff --git a/package.json b/package.json index 3c753059..cda74fcf 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "migrate:encryption": "tsc -p tsconfig.node.json && node ./dist/backend/backend/utils/encryption-migration.js" }, "dependencies": { + "@codemirror/autocomplete": "^6.18.7", + "@codemirror/comment": "^0.19.1", + "@codemirror/search": "^6.5.11", "@hookform/resolvers": "^5.1.1", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 13a9dd1f..b16dc2b3 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -224,7 +224,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { "aes256-cbc", "3des-cbc", ], - hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "umac-128-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], compress: ["none", "zlib@openssh.com", "zlib"], }, }; diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index ce27e9a4..7917dc7a 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -636,7 +636,7 @@ wss.on("connection", async (ws: WebSocket, req) => { "aes256-cbc", "3des-cbc", ], - hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "umac-128-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], compress: ["none", "zlib@openssh.com", "zlib"], }, }; diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 18dec475..24481695 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -873,7 +873,7 @@ async function connectSSHTunnel( "aes256-cbc", "3des-cbc", ], - hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "umac-128-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], compress: ["none", "zlib@openssh.com", "zlib"], }, }; @@ -1017,7 +1017,7 @@ async function killRemoteTunnelByMarker( "aes256-cbc", "3des-cbc", ], - hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], + hmac: ["hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", "umac-128-etm@openssh.com", "hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"], compress: ["none", "zlib@openssh.com", "zlib"], }, }; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6f0cfe58..6f5383ce 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -854,6 +854,9 @@ "folderName": "Folder name", "find": "Find...", "replaceWith": "Replace with...", + "replace": "Replace", + "replaceAll": "Replace All", + "downloadInstead": "Download Instead", "startTyping": "Start typing...", "unknownSize": "Unknown size", "fileIsEmpty": "File is empty", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index a79490f2..59ce438d 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -877,6 +877,9 @@ "folderName": "文件夹名", "find": "查找...", "replaceWith": "替换为...", + "replace": "替换", + "replaceAll": "全部替换", + "downloadInstead": "下载文件", "startTyping": "开始输入...", "fileSavedSuccessfully": "文件保存成功", "autoSaveFailed": "自动保存失败", diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx index 41ed9b85..4c869b0d 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx @@ -1190,7 +1190,7 @@ export function FileManagerGrid({ /> ) : (

{file.name} @@ -1205,7 +1205,7 @@ export function FileManagerGrid({ )} {file.type === "link" && file.linkTarget && (

→ {file.linkTarget} @@ -1283,7 +1283,7 @@ export function FileManagerGrid({ /> ) : (

{file.name} @@ -1291,7 +1291,7 @@ export function FileManagerGrid({ )} {file.type === "link" && file.linkTarget && (

→ {file.linkTarget} diff --git a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx index a3dc3531..c81407f6 100644 --- a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx @@ -12,11 +12,7 @@ import { Download, Save, RotateCcw, - Search, - X, - ChevronUp, - ChevronDown, - Replace, + Keyboard, } from "lucide-react"; import { SiJavascript, @@ -47,11 +43,13 @@ import { SiDocker, } from "react-icons/si"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; -import { languages, loadLanguage } from "@uiw/codemirror-extensions-langs"; -import { EditorView } from "@codemirror/view"; +import { loadLanguage } from "@uiw/codemirror-extensions-langs"; +import { EditorView, keymap } from "@codemirror/view"; +import { searchKeymap, search } from "@codemirror/search"; +import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands"; +import { autocompletion, completionKeymap } from "@codemirror/autocomplete"; interface FileItem { name: string; @@ -286,14 +284,8 @@ export function FileViewer({ const [hasChanges, setHasChanges] = useState(false); const [showLargeFileWarning, setShowLargeFileWarning] = useState(false); const [forceShowAsText, setForceShowAsText] = useState(false); - const [showSearchPanel, setShowSearchPanel] = useState(false); - const [searchText, setSearchText] = useState(""); - const [replaceText, setReplaceText] = useState(""); - const [showReplacePanel, setShowReplacePanel] = useState(false); - const [searchMatches, setSearchMatches] = useState< - { start: number; end: number }[] - >([]); - const [currentMatchIndex, setCurrentMatchIndex] = useState(-1); + const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false); + const [editorFocused, setEditorFocused] = useState(false); const fileTypeInfo = getFileType(file.name); @@ -349,126 +341,30 @@ export function FileViewer({ onContentChange?.(originalContent); }; - // Search matching functionality - const findMatches = (text: string) => { - if (!text) { - setSearchMatches([]); - setCurrentMatchIndex(-1); - return; - } + // Handle save shortcut specifically + useEffect(() => { + if (!editorFocused || !isEditable) return; - const matches: { start: number; end: number }[] = []; - const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); - let match; - - while ((match = regex.exec(editedContent)) !== null) { - matches.push({ - start: match.index, - end: match.index + match[0].length, - }); - // Avoid infinite loop - if (match.index === regex.lastIndex) regex.lastIndex++; - } - - setSearchMatches(matches); - setCurrentMatchIndex(matches.length > 0 ? 0 : -1); - }; - - // Search navigation - const goToNextMatch = () => { - if (searchMatches.length === 0) return; - setCurrentMatchIndex((prev) => (prev + 1) % searchMatches.length); - }; - - const goToPrevMatch = () => { - if (searchMatches.length === 0) return; - setCurrentMatchIndex( - (prev) => (prev - 1 + searchMatches.length) % searchMatches.length, - ); - }; - - // Replace functionality - const handleFindReplace = ( - findText: string, - replaceWithText: string, - replaceAll: boolean = false, - ) => { - if (!findText) return; - - let newContent = editedContent; - if (replaceAll) { - newContent = newContent.replace( - new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), - replaceWithText, - ); - } else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) { - // Replace current match - const match = searchMatches[currentMatchIndex]; - newContent = - editedContent.substring(0, match.start) + - replaceWithText + - editedContent.substring(match.end); - } - - setEditedContent(newContent); - setHasChanges(newContent !== originalContent); - onContentChange?.(newContent); - - // Re-search to update matches - setTimeout(() => findMatches(findText), 0); - }; - - const handleFind = () => { - setShowSearchPanel(true); - setShowReplacePanel(false); - }; - - const handleReplace = () => { - setShowSearchPanel(true); - setShowReplacePanel(true); - }; - - // Render highlighted text - const renderHighlightedText = (text: string) => { - if (!searchText || searchMatches.length === 0) { - return text; - } - - const parts: React.ReactNode[] = []; - let lastIndex = 0; - - searchMatches.forEach((match, index) => { - // Add text before match - if (match.start > lastIndex) { - parts.push(text.substring(lastIndex, match.start)); + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle Ctrl+S for custom save, let CodeMirror handle everything else + const isCtrl = e.ctrlKey || e.metaKey; + if (isCtrl && e.key.toLowerCase() === 's') { + e.preventDefault(); + e.stopPropagation(); + handleSave(); } + }; - // Add highlighted match text - const isCurrentMatch = index === currentMatchIndex; - parts.push( - - {text.substring(match.start, match.end)} - , - ); + // Add event listener with capture for save shortcut only + document.addEventListener('keydown', handleKeyDown, true); + + return () => { + document.removeEventListener('keydown', handleKeyDown, true); + }; + }, [editorFocused, isEditable, handleSave]); - lastIndex = match.end; - }); - // Add final text - if (lastIndex < text.length) { - parts.push(text.substring(lastIndex)); - } - return parts; - }; // Handle user confirmation to open large file const handleConfirmOpenAsText = () => { @@ -520,26 +416,17 @@ export function FileViewer({

- {/* Edit toolbar - display directly, no toggle needed */} + {/* Keyboard shortcuts help */} {isEditable && ( - <> - - - + )} {hasChanges && ( <> @@ -571,92 +458,109 @@ export function FileViewer({ className="flex items-center gap-2" > - Download + {t("fileManager.download")} )}
- {/* Search and replace panel */} - {showSearchPanel && ( -
-
- { - setSearchText(e.target.value); - findMatches(e.target.value); - }} - className="w-48 h-8" - /> -
- - - - {searchMatches.length > 0 - ? `${currentMatchIndex + 1}/${searchMatches.length}` - : searchText - ? "0/0" - : ""} - -
+ {/* Keyboard shortcuts help panel */} + {showKeyboardShortcuts && isEditable && ( +
+
+

Keyboard Shortcuts

- {showReplacePanel && ( -
- setReplaceText(e.target.value)} - className="w-48 h-8" - /> - - +
+
+

Search & Replace

+
+
+ Search + Ctrl+F +
+
+ Replace + Ctrl+H +
+
+ Find Next + F3 +
+
+ Find Previous + Shift+F3 +
+
- )} +
+

Editing

+
+
+ Save + Ctrl+S +
+
+ Select All + Ctrl+A +
+
+ Undo + Ctrl+Z +
+
+ Redo + Ctrl+Y +
+
+
+
+

Navigation

+
+
+ Go to Line + Ctrl+G +
+
+ Move Line Up + Alt+↑ +
+
+ Move Line Down + Alt+↓ +
+
+
+
+

Code

+
+
+ Toggle Comment + Ctrl+/ +
+
+ Indent + Tab +
+
+ Outdent + Shift+Tab +
+
+ Auto Complete + Ctrl+Space +
+
+
+
)} @@ -710,7 +614,7 @@ export function FileViewer({ className="flex items-center gap-2" > - Download Instead + {t("fileManager.downloadInstead")} )}