From b3a769ea147533be30ad62b8cfc37df97cd7664b Mon Sep 17 00:00:00 2001 From: moilstack Date: Tue, 14 Jul 2026 09:50:21 +0530 Subject: [PATCH 01/14] Add frontmatter tags UI, tag search, undo fixes Introduce tag support and editor/UX improvements: add Tag modal (editor context) to read/write YAML frontmatter tags; make main process strip BOM and parse frontmatter-only tags; add tag-search syntax (#tag or tag:name) and surface tag matches in search results; snapshot editor state before toolbar/context-menu range edits so Ctrl+Z undoes them; add copy/paste context actions, Tags action, and Ctrl+Shift+N to create a file in the Explorer; show tags with tooltip in the file tree; CSS tweaks and table-builder undo snapshot. --- src/main/ipc.js | 89 +++++++++++++------- src/renderer/editorCore.js | 38 ++++++++- src/renderer/fileOperations.js | 7 +- src/renderer/fileTreeManager.js | 3 +- src/renderer/hamburgerMenu.js | 5 ++ src/renderer/index.html | 63 +++++++++++--- src/renderer/index.js | 10 ++- src/renderer/styles.css | 24 +++++- src/renderer/tableBuilder.js | 1 + src/renderer/tagModal.js | 141 ++++++++++++++++++++++++++++++++ 10 files changed, 330 insertions(+), 51 deletions(-) create mode 100644 src/renderer/tagModal.js diff --git a/src/main/ipc.js b/src/main/ipc.js index 2c9bd97..2e1aced 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -139,12 +139,22 @@ function fetchJson(urlStr) { }) } +/** + * Strip a leading UTF-8 BOM (U+FEFF), if present. + * fs.readFile(..., 'utf8') does not strip it, and several common Windows + * editors (Notepad, some Git configs) write one by default — without this, + * `content.startsWith('---')` silently fails for otherwise-valid frontmatter. + */ +function _stripBOM(content) { + return content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content +} + /** * Extract a plain-text first line from file content. * Skips YAML frontmatter and strips basic Markdown syntax. */ function _extractFirstLine(content) { - let text = content + let text = _stripBOM(content) if (text.startsWith('---')) { const fmEnd = text.indexOf('\n---', 3) if (fmEnd !== -1) text = text.slice(fmEnd + 4) @@ -163,40 +173,39 @@ function _extractFirstLine(content) { } /** - * Extract up to 5 tags from file content. - * Checks YAML frontmatter `tags:` first, then inline `#hashtag` patterns. + * Extract up to 5 tags from a file's YAML frontmatter `tags:` field. + * Recognized shapes only — inline array `tags: [a, b]` or a YAML list: + * tags: + * - a + * - b + * No inline #hashtag fallback: scanning file prose for stray `#word` text + * produced false positives (URLs, anchors, code) with no way to tell a real + * tag from a coincidental match, so tags are only ever what the user + * explicitly declared in frontmatter. */ function _extractTags(content) { + content = _stripBOM(content) + if (!content.startsWith('---')) return [] + const fmEnd = content.indexOf('\n---', 3) + if (fmEnd === -1) return [] + const tags = new Set() - const sample = content.slice(0, 2000) - - if (sample.startsWith('---')) { - const fmEnd = sample.indexOf('\n---', 3) - if (fmEnd !== -1) { - const fm = sample.slice(3, fmEnd) - const inlineMatch = fm.match(/^tags:\s*\[([^\]]+)\]/m) - if (inlineMatch) { - for (const t of inlineMatch[1].split(',')) { - const tag = t.trim().replace(/^['"]|['"]$/g, '') - if (tag) tags.add(tag) - } - } - if (tags.size === 0) { - const listMatch = fm.match(/^tags:\s*\n((?:[ \t]*-[ \t]+.+\n?)+)/m) - if (listMatch) { - for (const line of listMatch[1].split('\n')) { - const tag = line.replace(/^[ \t]*-[ \t]+/, '').replace(/^['"]|['"]$/g, '').trim() - if (tag) tags.add(tag) - } - } - } + const fm = content.slice(3, fmEnd) + const inlineMatch = fm.match(/^tags:\s*\[([^\]]+)\]/m) + if (inlineMatch) { + for (const t of inlineMatch[1].split(',')) { + const tag = t.trim().replace(/^['"]|['"]$/g, '') + if (tag) tags.add(tag) } } - if (tags.size === 0) { - const re = /(?<=\s)#([a-zA-Z][a-zA-Z0-9_-]{1,20})/g - let m - while ((m = re.exec(sample)) !== null && tags.size < 5) tags.add(m[1]) + const listMatch = fm.match(/^tags:\s*\n((?:[ \t]*-[ \t]+.+\n?)+)/m) + if (listMatch) { + for (const line of listMatch[1].split('\n')) { + const tag = line.replace(/^[ \t]*-[ \t]+/, '').replace(/^['"]|['"]$/g, '').trim() + if (tag) tags.add(tag) + } + } } return [...tags].slice(0, 5) @@ -596,11 +605,26 @@ function registerIpcHandlers() { * Matches a single file against a lowercased query, returning a * SearchResult ({ filePath, fileName, snippet, matchType }) or null. * Shared by search:files (folder walk) and search:recent-files (explicit list). + * + * Query syntax: a leading `#` or `tag:` (e.g. `#project`, `tag:project`) + * searches only the file's frontmatter tags (see _extractTags) instead of + * filename/content — lets a user look up files by tag specifically rather + * than relying on the tag word happening to also appear in the text. */ async function _matchFile(fullPath, fileName, q) { - const nameMatch = fileName.toLowerCase().includes(q) let content = '' try { content = await fs.readFile(fullPath, 'utf8') } catch { return null } + + const tagQuery = q.match(/^(?:#|tag:)\s*(.+)$/) + if (tagQuery) { + const wanted = tagQuery[1].trim() + if (!wanted) return null + const tags = _extractTags(content) + if (!tags.some(t => t.toLowerCase().includes(wanted))) return null + return { filePath: fullPath, fileName, snippet: `Tags: ${tags.join(', ')}`, matchType: 'tag' } + } + + const nameMatch = fileName.toLowerCase().includes(q) const contentMatch = content.toLowerCase().includes(q) if (!nameMatch && !contentMatch) return null @@ -620,10 +644,12 @@ function registerIpcHandlers() { /** * search:files — full-text + filename search across a folder tree. + * A query starting with `#` or `tag:` (e.g. `#project`) matches only + * against frontmatter tags instead — see _matchFile. * * Args: { folderPath: string, query: string } * Returns: { results: SearchResult[] } - * SearchResult: { filePath, fileName, snippet, matchType: 'name'|'content' } + * SearchResult: { filePath, fileName, snippet, matchType: 'name'|'content'|'tag' } * Caps at 30 results. Skips hidden files and non-text files. */ ipcMain.handle('search:files', async (_event, { folderPath, query }) => { @@ -658,6 +684,7 @@ function registerIpcHandlers() { * search:recent-files — full-text + filename search over an explicit list * of file paths (used for Custom/no-folder Explorer mode, where there is * no folder tree to walk — only the Recent Files list). + * Same `#tag` / `tag:` query syntax as search:files — see _matchFile. * * Args: { filePaths: string[], query: string } * Returns: { results: SearchResult[] } diff --git a/src/renderer/editorCore.js b/src/renderer/editorCore.js index ac9b2b8..f05419d 100644 --- a/src/renderer/editorCore.js +++ b/src/renderer/editorCore.js @@ -27,6 +27,17 @@ const EditorCore = (() => { const aiUndoStack = []; const AI_UNDO_LIMIT = 20; // keep at most 20 AI-edit snapshots + /** + * Snapshot the editor's current value onto the undo stack before a + * toolbar/context-menu edit. `setRangeText` mutates the textarea directly + * and isn't recorded by the browser's native undo history, so without this + * snapshot Ctrl+Z has nothing to revert for these actions. + */ + function _snapshotUndo(editor) { + aiUndoStack.push(editor.value); + if (aiUndoStack.length > AI_UNDO_LIMIT) aiUndoStack.shift(); + } + /** Heading regex — used by buildHighlight to colour heading lines. */ const HEADING_RE = /^(#{1,6})(\s)/; @@ -452,6 +463,7 @@ const EditorCore = (() => { if (!ta) return; const start = ta.selectionStart, end = ta.selectionEnd; const sel = ta.value.substring(start, end); + _snapshotUndo(ta); ta.focus(); ta.setSelectionRange(start, end); ta.setRangeText(before + sel + after, start, end, 'end'); @@ -468,6 +480,7 @@ const EditorCore = (() => { if (!ta) return; const start = ta.selectionStart; const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; + _snapshotUndo(ta); ta.focus(); ta.setSelectionRange(lineStart, lineStart); ta.setRangeText(prefix, lineStart, lineStart, 'end'); @@ -486,6 +499,7 @@ const EditorCore = (() => { const end = editor.selectionEnd; const selected = editor.value.slice(start, end) || placeholder; const insertion = before + selected + after; + _snapshotUndo(editor); editor.focus(); editor.setSelectionRange(start, end); editor.setRangeText(insertion, start, end, 'end'); @@ -517,6 +531,7 @@ const EditorCore = (() => { : lines.map(l => prefix + l).join('\n'); const realEnd = lineEnd === -1 ? val.length : lineEnd; + _snapshotUndo(editor); editor.focus(); editor.setSelectionRange(lineStart, realEnd); editor.setRangeText(newBlock, lineStart, realEnd, 'end'); @@ -528,12 +543,26 @@ const EditorCore = (() => { /* ── Toolbar actions map ────────────────────────────────────────── */ const TOOLBAR_ACTIONS = { + // ── Clipboard ────────────────────────────────────────────────── + copy: () => { + const editor = _deps.getEditor ? _deps.getEditor() : null; + if (!editor) return; + editor.focus(); + document.execCommand('copy'); + }, + paste: () => { + const editor = _deps.getEditor ? _deps.getEditor() : null; + if (!editor) return; + editor.focus(); + document.execCommand('paste'); + }, + // ── Toolbar-left buttons (insertMd / insertLine) ────────────────── bold: () => insertMd('**', '**'), italic: () => insertMd('*', '*'), inlinecode: () => insertMd('`', '`'), link: () => insertMd('[', '](url)'), - image: () => insertMd('![alt](', ')'), + image: () => insertMd('![', '](url)'), h1: () => insertLine('# '), h2: () => insertLine('## '), list: () => insertLine('- '), @@ -560,6 +589,7 @@ const EditorCore = (() => { ? lines.map((l, i) => l.slice(`${i + 1}. `.length)).join('\n') : lines.map((l, i) => `${i + 1}. ${l}`).join('\n'); const realEnd = lineEnd === -1 ? val.length : lineEnd; + _snapshotUndo(editor); editor.focus(); editor.setSelectionRange(lineStart, realEnd); editor.setRangeText(newBlock, lineStart, realEnd, 'end'); @@ -591,6 +621,7 @@ const EditorCore = (() => { const end = editor.selectionEnd; const selected = editor.value.slice(start, end) || 'code here'; const insertion = '```\n' + selected + '\n```'; + _snapshotUndo(editor); editor.focus(); editor.setSelectionRange(start, end); editor.setRangeText(insertion, start, end, 'end'); @@ -605,12 +636,16 @@ const EditorCore = (() => { const val = editor.value; const before = val[pos - 1] === '\n' || pos === 0 ? '' : '\n'; const after = val[pos] === '\n' ? '' : '\n'; + _snapshotUndo(editor); editor.focus(); editor.setSelectionRange(pos, pos); editor.setRangeText(`${before}---${after}`, pos, pos, 'end'); editor.dispatchEvent(new Event('input', { bubbles: true })); triggerUpdate(); }, + tags: () => { + if (typeof TagModal !== 'undefined') TagModal.show(); + }, }; /* ── Ruler state getters (used by ChatPanel via deps injection) ─── */ @@ -788,6 +823,7 @@ const EditorCore = (() => { renderMarkdown, setEditorContentUndoable, clearAiUndoStack, + snapshotUndo: _snapshotUndo, syncRuler, visualRowsForLine, computeMatches, diff --git a/src/renderer/fileOperations.js b/src/renderer/fileOperations.js index 93e896f..db79186 100644 --- a/src/renderer/fileOperations.js +++ b/src/renderer/fileOperations.js @@ -212,7 +212,7 @@ const FileOperations = (() => { }); } - document.getElementById('btn-new-file')?.addEventListener('click', async () => { + async function triggerExplorerNewFile() { const explorerMode = localStorage.getItem('explorerMode') || 'multi-level'; if (explorerMode === 'custom') { await window.newUntitledFile?.(); @@ -229,7 +229,9 @@ const FileOperations = (() => { showNewFileInput(); } } - }); + } + + document.getElementById('btn-new-file')?.addEventListener('click', triggerExplorerNewFile); return { startInlineRename, @@ -237,6 +239,7 @@ const FileOperations = (() => { showNewFileInput, hideNewFileInput, confirmNewFile, + triggerExplorerNewFile, }; })(); diff --git a/src/renderer/fileTreeManager.js b/src/renderer/fileTreeManager.js index 0ec12f0..10a21dc 100644 --- a/src/renderer/fileTreeManager.js +++ b/src/renderer/fileTreeManager.js @@ -132,6 +132,7 @@ const FileTreeManager = (() => { const tags = file.tags || []; const preview = file.firstLine || ''; const tagsHTML = tags.map(t => `${_escHtml('#' + t)}`).join(''); + const tagsTitle = tags.length ? ` title="${_escHtml(tags.map(t => '#' + t).join(', '))}"` : ''; const labelDot = label ? `` : ''; @@ -143,7 +144,7 @@ const FileTreeManager = (() => { ${_fileIconSVG(file.name)} ${file.name} ${labelDot} - ${tagsHTML ? `
${tagsHTML}
` : ''} + ${tagsHTML ? `
${tagsHTML}
` : ''} +
+ + +
@@ -830,14 +845,6 @@

AI Assistant

- - -
+
+
+ +
@@ -1044,7 +1054,7 @@

File Explorer

Explorer Mode - Root only shows date groups, content previews, and tags. Custom hides folders entirely — Recent Files is the only way to open something. + Root only shows date groups, content previews, and tags — start the file with a frontmatter block (--- ... tags: [work, project] ... ---) to tag it; search with #tag or tag:name. Custom hides folders entirely — Recent Files is the only way to open something.
+
+ +
+
+
+ + + `; - row.addEventListener('click', async () => { + const _open = async () => { _closeDropdown(); await setActiveFolder(item.path); + }; + + row.addEventListener('click', (e) => { + if (e.target.closest('.folder-recent-remove-btn')) return; + _open(); + }); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _open(); } + }); + + row.querySelector('.folder-recent-remove-btn').addEventListener('click', (e) => { + e.stopPropagation(); + StorageManager.removeRecentItem(item.path); + _populateDropdown(); // refresh in place, keep the dropdown open }); dropdown.appendChild(row); diff --git a/src/renderer/hamburgerMenu.js b/src/renderer/hamburgerMenu.js index 0209e46..2453509 100644 --- a/src/renderer/hamburgerMenu.js +++ b/src/renderer/hamburgerMenu.js @@ -105,6 +105,24 @@ const HamburgerMenu = (() => { document.getElementById('btnSettings')?.click(); }); + /* ── "Explorer" label (sidebar header) — jumps to Explorer settings ── */ + function _openExplorerSettings() { + document.getElementById('btnSettings')?.click(); + document.querySelector('.settings-nav-item[data-settings-panel="explorer"]')?.click(); + requestAnimationFrame(() => { + const row = document.getElementById('settingsRow-explorerMode'); + if (!row) return; + row.scrollIntoView({ behavior: 'smooth', block: 'center' }); + row.classList.add('settings-row--flash'); + setTimeout(() => row.classList.remove('settings-row--flash'), 1200); + }); + } + const _sidebarExplorerLabel = document.getElementById('sidebarExplorerLabel'); + _sidebarExplorerLabel?.addEventListener('click', _openExplorerSettings); + _sidebarExplorerLabel?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _openExplorerSettings(); } + }); + /* ── Collapse All button (sidebar header) ─────────────────────────── */ // Disabled in Root folder only mode, since that view has no sub-folders to // collapse (see FileTreeManager.updateFolderToolbarButtons). diff --git a/src/renderer/index.html b/src/renderer/index.html index 062b25e..37c194e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -215,7 +215,16 @@