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
+1 -1
View File
@@ -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">
+82
View File
@@ -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
View File
@@ -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": {
+7
View File
@@ -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
+34
View File
@@ -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>
+402
View File
@@ -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');
+270
View File
@@ -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 {