feat: v1.2.0 搜索替换 + 文件树侧边栏
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user