diff --git a/renderer/renderer.js b/renderer/renderer.js
index 1342170..21175e7 100644
--- a/renderer/renderer.js
+++ b/renderer/renderer.js
@@ -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 = '
';
+ item.appendChild(arrow);
+
+ const icon = document.createElement('span');
+ icon.className = 'tree-icon';
+ icon.innerHTML = '
';
+ 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 = '
';
+ 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');
diff --git a/renderer/style.css b/renderer/style.css
index aa8bac4..0ac9930 100644
--- a/renderer/style.css
+++ b/renderer/style.css
@@ -23,6 +23,13 @@
--radius: 6px;
--toolbar-height: 44px;
--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-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
}
@@ -713,6 +720,269 @@ html, body {
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 ===== */
#app.mode-editor #preview-panel,
#app.mode-editor #resizer {