feat: v1.2.0 搜索替换 + 文件树侧边栏
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
|
<img src="https://img.shields.io/badge/Platform-Windows%20x64-blue?style=flat-square&logo=windows" alt="Platform">
|
||||||
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
|
<img src="https://img.shields.io/badge/Electron-28-47848F?style=flat-square&logo=electron" alt="Electron">
|
||||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
|
||||||
<img src="https://img.shields.io/badge/Version-1.1.0-orange?style=flat-square" alt="Version">
|
<img src="https://img.shields.io/badge/Version-1.2.0-orange?style=flat-square" alt="Version">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -305,6 +305,88 @@ ipcMain.handle('file:reload', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== File Tree (Sidebar) =====
|
||||||
|
const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', 'dist', 'out', '.next', '.nuxt', '__pycache__', '.DS_Store']);
|
||||||
|
const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.txt']);
|
||||||
|
|
||||||
|
async function buildDirTree(dirPath) {
|
||||||
|
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||||
|
// Sort: directories first, then files, alphabetically
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||||
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const children = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (SKIP_DIRS.has(entry.name)) continue;
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
|
||||||
|
const childPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subChildren = await buildDirTree(childPath);
|
||||||
|
if (subChildren.length > 0) {
|
||||||
|
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const ext = path.extname(entry.name).toLowerCase();
|
||||||
|
if (MARKDOWN_EXTS.has(ext)) {
|
||||||
|
children.push({ name: entry.name, path: childPath, type: 'file' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('dir:readTree', async (event, dirPath) => {
|
||||||
|
try {
|
||||||
|
const tree = await buildDirTree(dirPath);
|
||||||
|
return { success: true, tree, rootPath: dirPath };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let sidebarWatcher = null;
|
||||||
|
let sidebarWatchPath = null;
|
||||||
|
|
||||||
|
function startSidebarWatching(dirPath) {
|
||||||
|
stopSidebarWatching();
|
||||||
|
if (!dirPath) return;
|
||||||
|
try {
|
||||||
|
sidebarWatchPath = dirPath;
|
||||||
|
sidebarWatcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('sidebar:dirChanged');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSidebarWatching() {
|
||||||
|
if (sidebarWatcher) { sidebarWatcher.close(); sidebarWatcher = null; }
|
||||||
|
sidebarWatchPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('dir:watch', (event, dirPath) => {
|
||||||
|
startSidebarWatching(dirPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dir:unwatch', () => {
|
||||||
|
stopSidebarWatching();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('dir:openDialog', async () => {
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory']
|
||||||
|
});
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
return result.filePaths[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
// Tab switched - update active file tracking
|
// Tab switched - update active file tracking
|
||||||
ipcMain.handle('tab:switched', (event, filePath) => {
|
ipcMain.handle('tab:switched', (event, filePath) => {
|
||||||
switchActiveFile(filePath);
|
switchActiveFile(filePath);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marklite",
|
"name": "marklite",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "Lightweight Markdown Reader for Windows",
|
"description": "Lightweight Markdown Reader for Windows",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// Shell
|
// Shell
|
||||||
openExternal: (url) => shell.openExternal(url),
|
openExternal: (url) => shell.openExternal(url),
|
||||||
|
|
||||||
|
// File Tree (Sidebar)
|
||||||
|
readDirTree: (dirPath) => ipcRenderer.invoke('dir:readTree', dirPath),
|
||||||
|
openFolderDialog: () => ipcRenderer.invoke('dir:openDialog'),
|
||||||
|
watchDir: (dirPath) => ipcRenderer.invoke('dir:watch', dirPath),
|
||||||
|
unwatchDir: () => ipcRenderer.invoke('dir:unwatch'),
|
||||||
|
|
||||||
// Events from main process
|
// Events from main process
|
||||||
onFileOpenInTab: (callback) => ipcRenderer.on('file:openInTab', (event, data) => callback(data)),
|
onFileOpenInTab: (callback) => ipcRenderer.on('file:openInTab', (event, data) => callback(data)),
|
||||||
onMenuSave: (callback) => ipcRenderer.on('menu:save', () => callback()),
|
onMenuSave: (callback) => ipcRenderer.on('menu:save', () => callback()),
|
||||||
onMenuSaveAs: (callback) => ipcRenderer.on('menu:saveAs', () => callback()),
|
onMenuSaveAs: (callback) => ipcRenderer.on('menu:saveAs', () => callback()),
|
||||||
onViewModeChange: (callback) => ipcRenderer.on('menu:viewMode', (event, mode) => callback(mode)),
|
onViewModeChange: (callback) => ipcRenderer.on('menu:viewMode', (event, mode) => callback(mode)),
|
||||||
onExternalModification: (callback) => ipcRenderer.on('file:externallyModified', (event, filePath) => callback(filePath)),
|
onExternalModification: (callback) => ipcRenderer.on('file:externallyModified', (event, filePath) => callback(filePath)),
|
||||||
|
onDirChanged: (callback) => ipcRenderer.on('sidebar:dirChanged', () => callback()),
|
||||||
onConfirmClose: (callback) => ipcRenderer.on('window:confirmClose', () => callback()),
|
onConfirmClose: (callback) => ipcRenderer.on('window:confirmClose', () => callback()),
|
||||||
|
|
||||||
// Remove listeners
|
// Remove listeners
|
||||||
|
|||||||
@@ -91,10 +91,44 @@
|
|||||||
<button id="btn-dismiss" class="banner-btn">忽略</button>
|
<button id="btn-dismiss" class="banner-btn">忽略</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div id="sidebar">
|
||||||
|
<div id="sidebar-header">
|
||||||
|
<span id="sidebar-title">资源管理器</span>
|
||||||
|
<button id="btn-open-folder" class="sidebar-header-btn" title="打开文件夹">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-tree"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main content area -->
|
<!-- Main content area -->
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
<!-- Editor panel -->
|
<!-- Editor panel -->
|
||||||
<div id="editor-panel">
|
<div id="editor-panel">
|
||||||
|
<!-- Search & Replace Bar -->
|
||||||
|
<div id="search-bar" class="hidden">
|
||||||
|
<div class="search-row">
|
||||||
|
<input type="text" id="search-input" placeholder="查找..." />
|
||||||
|
<span id="search-count"></span>
|
||||||
|
<button id="btn-case" class="search-opt-btn" title="区分大小写 (Alt+C)">Aa</button>
|
||||||
|
<button id="btn-regex" class="search-opt-btn" title="正则表达式 (Alt+R)">.*</button>
|
||||||
|
<button id="btn-prev" class="search-nav-btn" title="上一个 (Shift+Enter)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="18 15 12 9 6 15"></polyline></svg>
|
||||||
|
</button>
|
||||||
|
<button id="btn-next" class="search-nav-btn" title="下一个 (Enter)">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||||
|
</button>
|
||||||
|
<button id="btn-search-close" class="search-nav-btn" title="关闭 (Escape)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="replace-row" class="hidden">
|
||||||
|
<input type="text" id="replace-input" placeholder="替换..." />
|
||||||
|
<button id="btn-replace" class="replace-btn" title="替换 (Ctrl+Shift+G)">替换</button>
|
||||||
|
<button id="btn-replace-all" class="replace-btn" title="全部替换 (Ctrl+Shift+H)">全部</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="editor-wrapper">
|
<div id="editor-wrapper">
|
||||||
<div id="line-numbers"></div>
|
<div id="line-numbers"></div>
|
||||||
<textarea id="editor" spellcheck="false" placeholder="在此输入 Markdown 内容,或拖拽 .md 文件到窗口打开..."></textarea>
|
<textarea id="editor" spellcheck="false" placeholder="在此输入 Markdown 内容,或拖拽 .md 文件到窗口打开..."></textarea>
|
||||||
|
|||||||
@@ -19,6 +19,25 @@
|
|||||||
const mainContent = document.getElementById('main-content');
|
const mainContent = document.getElementById('main-content');
|
||||||
const tabList = document.getElementById('tab-list');
|
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
|
// Buttons
|
||||||
const btnOpen = document.getElementById('btn-open');
|
const btnOpen = document.getElementById('btn-open');
|
||||||
const btnSave = document.getElementById('btn-save');
|
const btnSave = document.getElementById('btn-save');
|
||||||
@@ -41,6 +60,13 @@
|
|||||||
let viewMode = 'split';
|
let viewMode = 'split';
|
||||||
let updateTimer = null;
|
let updateTimer = null;
|
||||||
let lineCountCache = 0;
|
let lineCountCache = 0;
|
||||||
|
let currentRootPath = null;
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
let searchMatches = [];
|
||||||
|
let searchIndex = -1;
|
||||||
|
let searchCaseSensitive = false;
|
||||||
|
let searchUseRegex = false;
|
||||||
|
|
||||||
// ===== Tab Object =====
|
// ===== Tab Object =====
|
||||||
function createTab(filePath, content) {
|
function createTab(filePath, content) {
|
||||||
@@ -889,6 +915,18 @@
|
|||||||
switchToTab(tabs[next].id);
|
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 =====
|
// ===== Initialize =====
|
||||||
function init() {
|
function init() {
|
||||||
initMarked();
|
initMarked();
|
||||||
@@ -1039,6 +1439,8 @@
|
|||||||
initEventListeners();
|
initEventListeners();
|
||||||
initFileWatch();
|
initFileWatch();
|
||||||
initUnsavedWarning();
|
initUnsavedWarning();
|
||||||
|
initSidebar();
|
||||||
|
initSearchBar();
|
||||||
|
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
setViewMode(settings.viewMode || 'split');
|
setViewMode(settings.viewMode || 'split');
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
--toolbar-height: 44px;
|
--toolbar-height: 44px;
|
||||||
--statusbar-height: 28px;
|
--statusbar-height: 28px;
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--sidebar-bg: var(--bg-secondary);
|
||||||
|
--sidebar-border: var(--border);
|
||||||
|
--sidebar-hover: var(--bg-tertiary);
|
||||||
|
--sidebar-active: var(--primary-light);
|
||||||
|
--search-bg: var(--bg-secondary);
|
||||||
|
--search-border: var(--border);
|
||||||
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
|
--font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
|
||||||
}
|
}
|
||||||
@@ -713,6 +720,269 @@ html, body {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Sidebar ===== */
|
||||||
|
#sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 500px;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
border-right: 1px solid var(--sidebar-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--sidebar-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header-btn:hover {
|
||||||
|
background: var(--sidebar-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-tree {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 8px 3px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item:hover {
|
||||||
|
background: var(--sidebar-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.active {
|
||||||
|
background: var(--sidebar-active);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-indent {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-arrow.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Search & Replace Bar ===== */
|
||||||
|
#search-bar {
|
||||||
|
background: var(--search-bg);
|
||||||
|
border-bottom: 1px solid var(--search-border);
|
||||||
|
padding: 6px 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-bar.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row,
|
||||||
|
#replace-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#replace-row {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#replace-row.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input,
|
||||||
|
#replace-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input:focus,
|
||||||
|
#replace-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-opt-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-opt-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-opt-btn.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-nav-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replace-btn {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replace-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Search Highlight Overlays ===== */
|
||||||
|
.search-hl {
|
||||||
|
background: rgba(255, 220, 0, 0.35);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hl.current {
|
||||||
|
background: rgba(255, 150, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .search-hl {
|
||||||
|
background: rgba(255, 220, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .search-hl.current {
|
||||||
|
background: rgba(255, 150, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== View Modes ===== */
|
/* ===== View Modes ===== */
|
||||||
#app.mode-editor #preview-panel,
|
#app.mode-editor #preview-panel,
|
||||||
#app.mode-editor #resizer {
|
#app.mode-editor #resizer {
|
||||||
|
|||||||
Reference in New Issue
Block a user