FIX: Complete CodeMirror integration with native search, replace, and keyboard shortcuts

- 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 <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-25 09:04:59 +08:00
parent 7ee4b81f97
commit 8e22e76166
9 changed files with 274 additions and 346 deletions

63
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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"],
},
};

View File

@@ -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"],
},
};

View File

@@ -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"],
},
};

View File

@@ -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",

View File

@@ -877,6 +877,9 @@
"folderName": "文件夹名",
"find": "查找...",
"replaceWith": "替换为...",
"replace": "替换",
"replaceAll": "全部替换",
"downloadInstead": "下载文件",
"startTyping": "开始输入...",
"fileSavedSuccessfully": "文件保存成功",
"autoSaveFailed": "自动保存失败",

View File

@@ -1190,7 +1190,7 @@ export function FileManagerGrid({
/>
) : (
<p
className="text-xs text-foreground truncate px-1 py-0.5 rounded w-fit max-w-full text-center"
className="text-xs text-foreground break-words px-1 py-0.5 rounded text-center leading-tight w-full"
title={file.name}
>
{file.name}
@@ -1205,7 +1205,7 @@ export function FileManagerGrid({
)}
{file.type === "link" && file.linkTarget && (
<p
className="text-xs text-primary mt-1 truncate max-w-full"
className="text-xs text-primary mt-1 break-words w-full leading-tight"
title={file.linkTarget}
>
{file.linkTarget}
@@ -1283,7 +1283,7 @@ export function FileManagerGrid({
/>
) : (
<p
className="text-sm text-foreground truncate px-1 py-0.5 rounded w-fit max-w-full"
className="text-sm text-foreground break-words px-1 py-0.5 rounded leading-tight"
title={file.name}
>
{file.name}
@@ -1291,7 +1291,7 @@ export function FileManagerGrid({
)}
{file.type === "link" && file.linkTarget && (
<p
className="text-xs text-primary truncate"
className="text-xs text-primary break-words leading-tight"
title={file.linkTarget}
>
{file.linkTarget}

View File

@@ -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(
<span
key={`match-${index}`}
className={cn(
"font-bold",
isCurrentMatch
? "text-red-600 bg-yellow-200"
: "text-blue-800 bg-blue-100",
)}
>
{text.substring(match.start, match.end)}
</span>,
);
// 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({
</div>
<div className="flex items-center gap-2">
{/* Edit toolbar - display directly, no toggle needed */}
{/* Keyboard shortcuts help */}
{isEditable && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleFind}
className="flex items-center gap-2"
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReplace}
className="flex items-center gap-2"
>
<Replace className="w-4 h-4" />
</Button>
</>
<Button
variant="ghost"
size="sm"
onClick={() => setShowKeyboardShortcuts(!showKeyboardShortcuts)}
className="flex items-center gap-2"
title="Show keyboard shortcuts"
>
<Keyboard className="w-4 h-4" />
</Button>
)}
{hasChanges && (
<>
@@ -571,92 +458,109 @@ export function FileViewer({
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
Download
{t("fileManager.download")}
</Button>
)}
</div>
</div>
</div>
{/* Search and replace panel */}
{showSearchPanel && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-3">
<div className="flex items-center gap-2 mb-2">
<Input
placeholder={t("fileManager.find")}
value={searchText}
onChange={(e) => {
setSearchText(e.target.value);
findMatches(e.target.value);
}}
className="w-48 h-8"
/>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={goToPrevMatch}
disabled={searchMatches.length === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNextMatch}
disabled={searchMatches.length === 0}
>
<ChevronDown className="w-4 h-4" />
</Button>
<span className="text-xs text-muted-foreground min-w-[3rem]">
{searchMatches.length > 0
? `${currentMatchIndex + 1}/${searchMatches.length}`
: searchText
? "0/0"
: ""}
</span>
</div>
{/* Keyboard shortcuts help panel */}
{showKeyboardShortcuts && isEditable && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">Keyboard Shortcuts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowSearchPanel(false);
setSearchText("");
setSearchMatches([]);
setCurrentMatchIndex(-1);
}}
onClick={() => setShowKeyboardShortcuts(false)}
className="h-6 w-6 p-0"
>
<X className="w-4 h-4" />
×
</Button>
</div>
{showReplacePanel && (
<div className="flex items-center gap-2 mb-2">
<Input
placeholder={t("fileManager.replaceWith")}
value={replaceText}
onChange={(e) => setReplaceText(e.target.value)}
className="w-48 h-8"
/>
<Button
variant="outline"
size="sm"
onClick={() =>
handleFindReplace(searchText, replaceText, false)
}
disabled={!searchText}
>
Replace
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleFindReplace(searchText, replaceText, true)}
disabled={!searchText}
>
Replace All
</Button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">Search & Replace</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>Search</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+F</kbd>
</div>
<div className="flex justify-between">
<span>Replace</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+H</kbd>
</div>
<div className="flex justify-between">
<span>Find Next</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">F3</kbd>
</div>
<div className="flex justify-between">
<span>Find Previous</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+F3</kbd>
</div>
</div>
</div>
)}
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">Editing</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>Save</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+S</kbd>
</div>
<div className="flex justify-between">
<span>Select All</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+A</kbd>
</div>
<div className="flex justify-between">
<span>Undo</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Z</kbd>
</div>
<div className="flex justify-between">
<span>Redo</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">Navigation</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>Go to Line</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+G</kbd>
</div>
<div className="flex justify-between">
<span>Move Line Up</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+</kbd>
</div>
<div className="flex justify-between">
<span>Move Line Down</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+</kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">Code</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>Toggle Comment</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
</div>
<div className="flex justify-between">
<span>Indent</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Tab</kbd>
</div>
<div className="flex justify-between">
<span>Outdent</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+Tab</kbd>
</div>
<div className="flex justify-between">
<span>Auto Complete</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
</div>
</div>
</div>
</div>
</div>
)}
@@ -710,7 +614,7 @@ export function FileViewer({
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
Download Instead
{t("fileManager.downloadInstead")}
</Button>
<Button
variant="outline"
@@ -739,123 +643,75 @@ export function FileViewer({
</div>
)}
{/* Text and code file preview */}
{/* Unified text and code file editor */}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{fileTypeInfo.type === "code" ? (
// Code files use CodeMirror
<div className="h-full">
{searchText && searchMatches.length > 0 ? (
// When there are search results, show read-only highlighted text (with line numbers)
<div className="h-full flex bg-muted">
{/* Line number column */}
<div className="flex-shrink-0 bg-muted border-r border-border px-2 py-4 text-xs text-muted-foreground font-mono select-none">
{editedContent.split("\n").map((_, index) => (
<div
key={index + 1}
className="text-right leading-5 min-w-[2rem]"
>
{index + 1}
</div>
))}
</div>
{/* Code content */}
<div className="flex-1 p-4 font-mono text-sm whitespace-pre-wrap overflow-auto text-foreground">
{renderHighlightedText(editedContent)}
</div>
</div>
) : (
// Show CodeMirror editor when no search
<CodeMirror
value={editedContent}
onChange={(value) => handleContentChange(value)}
extensions={[
...(getLanguageExtension(file.name)
? [getLanguageExtension(file.name)!]
: []),
EditorView.theme({
"&": {
height: "100%",
},
".cm-scroller": {
overflow: "auto",
},
".cm-editor": {
height: "100%",
},
}),
]}
theme="dark"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
scrollPastEnd: false,
}}
className="h-full overflow-auto"
readOnly={!isEditable}
/>
)}
</div>
{isEditable ? (
// Unified CodeMirror editor for all text-based files
<CodeMirror
value={editedContent}
onChange={(value) => handleContentChange(value)}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
extensions={[
...(getLanguageExtension(file.name)
? [getLanguageExtension(file.name)!]
: []),
history(),
search(),
autocompletion(),
keymap.of([
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
// Custom keybindings
{
key: "Mod-/",
run: toggleComment,
preventDefault: true
},
{
key: "Mod-h",
run: () => {
// Let CodeMirror search handle this, just prevent browser default
return false; // Return false to let search keymap handle it
},
preventDefault: true
}
]),
EditorView.theme({
"&": {
height: "100%",
},
".cm-scroller": {
overflow: "auto",
},
".cm-editor": {
height: "100%",
},
}),
]}
theme={oneDark}
placeholder={t("fileManager.startTyping")}
className="h-full"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
scrollPastEnd: false,
}}
/>
) : (
// Plain text files
<div className="h-full">
{isEditable ? (
<div className="h-full">
{searchText && searchMatches.length > 0 ? (
// When there are search results, show read-only highlighted text
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
{renderHighlightedText(editedContent)}
</div>
) : (
// Use CodeMirror for all text files (unified editor experience)
<CodeMirror
value={editedContent}
onChange={(value) => handleContentChange(value)}
extensions={[
...(getLanguageExtension(file.name)
? [getLanguageExtension(file.name)!]
: []),
EditorView.theme({
"&": {
height: "100%",
},
".cm-scroller": {
overflow: "auto",
},
".cm-editor": {
height: "100%",
},
}),
]}
theme={oneDark}
editable={isEditable}
placeholder={t("fileManager.startTyping")}
className="h-full text-sm"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: true,
scrollPastEnd: false,
}}
/>
)}
</div>
) : (
// Only show as read-only for non-editable files (media files)
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
)}
// Read-only view for non-editable files
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
)}
</div>
@@ -918,7 +774,7 @@ export function FileViewer({
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
Download File
{t("fileManager.downloadFile")}
</Button>
)}
</div>