feat: v1.2.0 搜索替换 + 文件树侧边栏

This commit is contained in:
thzxx
2026-05-21 11:37:02 +08:00
parent c773a0d8e6
commit 543af93802
7 changed files with 797 additions and 2 deletions
+402
View File
@@ -19,6 +19,25 @@
const mainContent = document.getElementById('main-content');
const tabList = document.getElementById('tab-list');
// Sidebar
const sidebar = document.getElementById('sidebar');
const sidebarTree = document.getElementById('sidebar-tree');
const btnOpenFolder = document.getElementById('btn-open-folder');
// Search
const searchBar = document.getElementById('search-bar');
const searchInput = document.getElementById('search-input');
const replaceInput = document.getElementById('replace-input');
const searchCount = document.getElementById('search-count');
const replaceRow = document.getElementById('replace-row');
const btnCase = document.getElementById('btn-case');
const btnRegex = document.getElementById('btn-regex');
const btnNext = document.getElementById('btn-next');
const btnPrev = document.getElementById('btn-prev');
const btnSearchClose = document.getElementById('btn-search-close');
const btnReplace = document.getElementById('btn-replace');
const btnReplaceAll = document.getElementById('btn-replace-all');
// Buttons
const btnOpen = document.getElementById('btn-open');
const btnSave = document.getElementById('btn-save');
@@ -41,6 +60,13 @@
let viewMode = 'split';
let updateTimer = null;
let lineCountCache = 0;
let currentRootPath = null;
// Search state
let searchMatches = [];
let searchIndex = -1;
let searchCaseSensitive = false;
let searchUseRegex = false;
// ===== Tab Object =====
function createTab(filePath, content) {
@@ -889,6 +915,18 @@
switchToTab(tabs[next].id);
}
}
// Search & Replace
if (e.ctrlKey && e.key === 'f') { e.preventDefault(); openSearch(false); }
if (e.ctrlKey && e.key === 'h') { e.preventDefault(); openSearch(true); }
if (e.key === 'Escape' && !searchBar.classList.contains('hidden')) { e.preventDefault(); closeSearch(); }
if (e.key === 'Enter' && document.activeElement === searchInput) {
e.preventDefault();
e.shiftKey ? findPrev() : findNext();
}
if (e.altKey && e.key === 'c') { e.preventDefault(); toggleSearchOption('case'); }
if (e.altKey && e.key === 'r') { e.preventDefault(); toggleSearchOption('regex'); }
if (e.ctrlKey && e.shiftKey && e.key === 'H') { e.preventDefault(); replaceAll(); }
if (e.ctrlKey && e.shiftKey && e.key === 'G') { e.preventDefault(); replaceCurrent(); }
});
}
@@ -1027,6 +1065,368 @@
}
}
// ===== Search & Replace =====
function openSearch(showReplace) {
searchBar.classList.remove('hidden');
replaceRow.classList.toggle('hidden', !showReplace);
searchInput.focus();
// Pre-fill with selected text
const selected = editor.value.substring(editor.selectionStart, editor.selectionEnd);
if (selected && !selected.includes('\n')) {
searchInput.value = selected;
}
doSearch();
}
function closeSearch() {
searchBar.classList.add('hidden');
clearHighlights();
searchMatches = [];
searchIndex = -1;
searchCount.textContent = '';
editor.focus();
}
function toggleSearchOption(type) {
if (type === 'case') {
searchCaseSensitive = !searchCaseSensitive;
btnCase.classList.toggle('active', searchCaseSensitive);
} else {
searchUseRegex = !searchUseRegex;
btnRegex.classList.toggle('active', searchUseRegex);
}
doSearch();
}
function doSearch() {
clearHighlights();
const text = searchInput.value;
if (!text) {
searchMatches = [];
searchIndex = -1;
searchCount.textContent = '';
return;
}
searchMatches = findMatches(text);
if (searchMatches.length > 0) {
// Find the match closest to cursor
const cursor = editor.selectionStart;
searchIndex = searchMatches.findIndex(m => m.start >= cursor);
if (searchIndex === -1) searchIndex = 0;
scrollToMatch(searchIndex);
} else {
searchIndex = -1;
}
searchCount.textContent = searchMatches.length > 0
? `${searchIndex + 1}/${searchMatches.length}`
: '无结果';
highlightMatches();
}
function findMatches(text) {
const content = editor.value;
const matches = [];
if (searchUseRegex) {
try {
const flags = searchCaseSensitive ? 'g' : 'gi';
const regex = new RegExp(text, flags);
let m;
while ((m = regex.exec(content)) !== null) {
matches.push({ start: m.index, end: m.index + m[0].length });
if (m[0].length === 0) regex.lastIndex++;
}
} catch { /* invalid regex */ }
} else {
const haystack = searchCaseSensitive ? content : content.toLowerCase();
const needle = searchCaseSensitive ? text : text.toLowerCase();
let idx = 0;
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
matches.push({ start: idx, end: idx + needle.length });
idx += needle.length;
}
}
return matches;
}
function highlightMatches() {
clearHighlights();
if (searchMatches.length === 0) return;
const content = editor.value;
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const editorStyle = getComputedStyle(editor);
const paddingTop = parseFloat(editorStyle.paddingTop);
const paddingLeft = parseFloat(editorStyle.paddingLeft);
// Compute line starts for fast lookup
const lineStarts = [0];
for (let i = 0; i < content.length; i++) {
if (content.charCodeAt(i) === 10) lineStarts.push(i + 1);
}
function posToXY(pos) {
let line = 0;
for (let i = 0; i < lineStarts.length; i++) {
if (lineStarts[i] > pos) break;
line = i;
}
const col = pos - lineStarts[line];
return {
top: line * lineHeight + paddingTop,
left: col * 8 + paddingLeft
};
}
const overlay = document.createElement('div');
overlay.id = 'search-highlights';
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;overflow:hidden;z-index:1;';
editorWrapper.style.position = 'relative';
editorWrapper.appendChild(overlay);
for (let i = 0; i < searchMatches.length; i++) {
const m = searchMatches[i];
const startPos = posToXY(m.start);
const endPos = posToXY(m.end);
const isCurrent = i === searchIndex;
const div = document.createElement('div');
div.className = 'search-hl' + (isCurrent ? ' current' : '');
div.style.cssText = `position:absolute;top:${startPos.top}px;left:${startPos.left}px;height:${lineHeight}px;width:${Math.max(endPos.left - startPos.left, 8)}px;`;
overlay.appendChild(div);
}
}
function clearHighlights() {
const old = document.getElementById('search-highlights');
if (old) old.remove();
}
function scrollToMatch(index) {
if (index < 0 || index >= searchMatches.length) return;
searchIndex = index;
const m = searchMatches[index];
editor.setSelectionRange(m.start, m.end);
// Scroll the match into view
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 20.8;
const content = editor.value;
let line = 0;
for (let i = 0; i < m.start; i++) {
if (content.charCodeAt(i) === 10) line++;
}
const matchTop = line * lineHeight;
const viewTop = editor.scrollTop;
const viewBottom = viewTop + editor.clientHeight;
if (matchTop < viewTop + 40 || matchTop > viewBottom - 40) {
editor.scrollTop = matchTop - editor.clientHeight / 2;
}
searchCount.textContent = `${index + 1}/${searchMatches.length}`;
highlightMatches();
}
function findNext() {
if (searchMatches.length === 0) return;
scrollToMatch((searchIndex + 1) % searchMatches.length);
}
function findPrev() {
if (searchMatches.length === 0) return;
scrollToMatch((searchIndex - 1 + searchMatches.length) % searchMatches.length);
}
function replaceCurrent() {
if (searchMatches.length === 0 || searchIndex < 0) return;
const m = searchMatches[searchIndex];
const replacement = replaceInput.value;
// Use execCommand to preserve undo history
editor.setSelectionRange(m.start, m.end);
document.execCommand('insertText', false, replacement);
// Re-run search
doSearch();
}
function replaceAll() {
if (searchMatches.length === 0) return;
const replacement = replaceInput.value;
// Replace from end to start to preserve positions
for (let i = searchMatches.length - 1; i >= 0; i--) {
const m = searchMatches[i];
editor.setSelectionRange(m.start, m.end);
document.execCommand('insertText', false, replacement);
}
doSearch();
}
// ===== Sidebar (File Tree) =====
function initSidebar() {
const settings = loadSettings();
if (settings.sidebarCollapsed) sidebar.classList.add('collapsed');
btnOpenFolder.addEventListener('click', openFolder);
if (typeof window.electronAPI !== 'undefined') {
window.electronAPI.onDirChanged(() => {
if (currentRootPath) refreshTree();
});
}
}
async function openFolder() {
if (typeof window.electronAPI === 'undefined') return;
const { dialog } = require('electron');
openFolderDialog();
}
async function openFolderDialog() {
// We'll add a dir:openDialog IPC
if (typeof window.electronAPI !== 'undefined' && window.electronAPI.openFolderDialog) {
const result = await window.electronAPI.openFolderDialog();
if (result) {
loadFolder(result);
}
}
}
async function loadFolder(dirPath) {
if (typeof window.electronAPI === 'undefined') return;
currentRootPath = dirPath;
const result = await window.electronAPI.readDirTree(dirPath);
if (result.success) {
renderTree(result.tree, dirPath);
sidebar.classList.remove('collapsed');
window.electronAPI.watchDir(dirPath);
}
}
async function refreshTree() {
if (!currentRootPath) return;
const result = await window.electronAPI.readDirTree(currentRootPath);
if (result.success) {
renderTree(result.tree, currentRootPath);
}
}
// Track expanded state
const expandedDirs = new Set();
function renderTree(tree, rootPath) {
sidebarTree.innerHTML = '';
const rootNode = { name: path.basename(rootPath) || rootPath, path: rootPath, type: 'dir', children: tree };
expandedDirs.add(rootPath);
const fragment = document.createDocumentFragment();
buildTreeDOM(fragment, rootNode, 0);
sidebarTree.appendChild(fragment);
}
function pathBasename(p) {
return p.split(/[/\\]/).pop();
}
function buildTreeDOM(parent, node, depth) {
const item = document.createElement('div');
item.className = 'tree-item';
item.style.paddingLeft = (8 + depth * 16) + 'px';
if (node.type === 'dir') {
const isExpanded = expandedDirs.has(node.path);
const arrow = document.createElement('span');
arrow.className = 'tree-arrow' + (isExpanded ? ' expanded' : '');
arrow.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"></polyline></svg>';
item.appendChild(arrow);
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>';
item.appendChild(icon);
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = node.name;
item.appendChild(name);
parent.appendChild(item);
if (isExpanded && node.children) {
for (const child of node.children) {
buildTreeDOM(parent, child, depth + 1);
}
}
item.addEventListener('click', (e) => {
e.stopPropagation();
if (expandedDirs.has(node.path)) {
expandedDirs.delete(node.path);
} else {
expandedDirs.add(node.path);
}
renderTree(getTreeData(), currentRootPath);
});
} else {
// Spacer for alignment (no arrow)
const spacer = document.createElement('span');
spacer.style.width = '16px';
spacer.style.flexShrink = '0';
item.appendChild(spacer);
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>';
item.appendChild(icon);
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = node.name;
item.appendChild(name);
// Highlight if this file is open in active tab
const tab = getActiveTab();
if (tab && tab.filePath === node.path) {
item.classList.add('active');
}
parent.appendChild(item);
item.addEventListener('click', async (e) => {
e.stopPropagation();
// Check if already open
const existing = tabs.find(t => t.filePath === node.path);
if (existing) {
switchToTab(existing.id);
} else {
if (typeof window.electronAPI !== 'undefined') {
const result = await window.electronAPI.readFile(node.path);
if (result.success) {
createNewTab(node.path, result.content);
}
}
}
});
}
}
// Cache the last tree data for re-render
let lastTreeData = null;
function getTreeData() {
return lastTreeData || [];
}
// Override renderTree to cache data
const _originalRenderTree = renderTree;
renderTree = function(tree, rootPath) {
lastTreeData = tree;
_originalRenderTree(tree, rootPath);
};
// ===== Search Bar Init =====
function initSearchBar() {
searchInput.addEventListener('input', doSearch);
btnCase.addEventListener('click', () => toggleSearchOption('case'));
btnRegex.addEventListener('click', () => toggleSearchOption('regex'));
btnNext.addEventListener('click', findNext);
btnPrev.addEventListener('click', findPrev);
btnSearchClose.addEventListener('click', closeSearch);
btnReplace.addEventListener('click', replaceCurrent);
btnReplaceAll.addEventListener('click', replaceAll);
}
// ===== Initialize =====
function init() {
initMarked();
@@ -1039,6 +1439,8 @@
initEventListeners();
initFileWatch();
initUnsavedWarning();
initSidebar();
initSearchBar();
const settings = loadSettings();
setViewMode(settings.viewMode || 'split');