feat(sidebar): daily-notes panel + Q&A deep-link to file offset

Daily notes panel:
- src/sidebar/daily-notes-panel.js — new sidebar panel that lists every
  YYYY-MM-DD.md in the daily-notes dir (newest first), with a Today
  button that creates/opens today's entry. Refresh button reloads.
  Clicking a row calls onOpenFile(path). DOM is built with textContent
  for dynamic fields — no XSS surface from a hostile filename.
- src/main.js — daily-notes:list IPC now returns absolute paths (joined
  with the daily-notes dir) so the renderer can pass them straight to
  open-file-path without re-synthesizing.
- src/renderer.js — registers the panel; icon for the daily-notes
  button (calendar icon).
- src/index.html — calendar SVG icon for the daily-notes sidebar entry.

Q&A deep-link:
- src/main.js — open-file-path accepts either a string (legacy) or an
  object {path, offset}. The offset is forwarded to the renderer via
  file-opened.
- src/renderer.js — file-opened handler scrolls the editor to the
  offset using EditorView.scrollIntoView(y: 'center') so the matching
  passage lands in the middle of the visible area.
- search panel now passes {path, offset} to open-file-path so Q&A
  results with chunk offsets jump straight to the passage.

Tests (12 new, tests/daily-notes-panel.test.js):
  - mount, empty state, list rendering
  - Today button → onOpenFile, 'Created today' / 'Today already exists' status
  - error paths (openToday reject, listExisting reject, listExisting missing)
  - click + Enter/Space on a row open the right path
  - refresh() re-fetches, keeps status when keepStatus:true
  - non-md entries filtered out
  - XSS-safe textContent for dynamic data

Full suite: 73 suites, 873 tests, lint+format clean.

Amit Haridas
This commit is contained in:
2026-09-14 11:53:45 +05:30
parent 0cab0b08c7
commit 2aa72738f4
5 changed files with 385 additions and 5 deletions
+18
View File
@@ -2574,6 +2574,24 @@
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</button> </button>
<!-- Daily notes: list of YYYY-MM-DD.md files + Today button -->
<button class="sidebar-icon" data-panel="daily-notes" title="Daily Notes (Ctrl+Alt+D)">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
</div> </div>
<div class="sidebar-panel" id="sidebar-panel"> <div class="sidebar-panel" id="sidebar-panel">
<div class="sidebar-panel-header"> <div class="sidebar-panel-header">
+16 -3
View File
@@ -5759,7 +5759,10 @@ ipcMain.handle('daily-notes:list', async () => {
const dir = dailyNotesDir(); const dir = dailyNotesDir();
const validation = validatePath(dir); const validation = validatePath(dir);
if (!validation.valid) return []; if (!validation.valid) return [];
return DailyNotes.listExisting({ dir, fs, pathUtil: path }); // Return absolute paths so the renderer can pass them straight to
// open-file-path without re-synthesizing.
const filenames = DailyNotes.listExisting({ dir, fs, pathUtil: path });
return filenames.map((name) => path.join(dir, name));
}); });
// ================================ // ================================
@@ -6172,8 +6175,17 @@ ipcMain.handle('move-path', async (event, payload) => {
}; };
}); });
// Open a file by path (from explorer panel) // Open a file by path (from explorer panel). Accepts an optional `offset`
ipcMain.on('open-file-path', (event, filePath) => { // (char offset in the file) so deep-links from search results / Q&A hits
// can scroll to the matching passage.
ipcMain.on('open-file-path', (event, payload) => {
// Backwards-compatible: callers passing a plain string still work.
let filePath = payload;
let offset = 0;
if (payload && typeof payload === 'object') {
filePath = payload.path;
offset = typeof payload.offset === 'number' && payload.offset >= 0 ? payload.offset : 0;
}
try { try {
// Validate path to prevent traversal attacks // Validate path to prevent traversal attacks
const validation = validatePath(filePath); const validation = validatePath(filePath);
@@ -6191,6 +6203,7 @@ ipcMain.on('open-file-path', (event, filePath) => {
mainWindow.webContents.send('file-opened', { mainWindow.webContents.send('file-opened', {
path: validation.resolved, path: validation.resolved,
content, content,
offset,
}); });
} catch (err) { } catch (err) {
console.error('open-file-path error:', err); console.error('open-file-path error:', err);
+35 -2
View File
@@ -22,6 +22,7 @@ const DOMPurify = createDOMPurify(window);
const hljs = require('highlight.js'); const hljs = require('highlight.js');
const { createEditor } = require('./editor/codemirror-setup'); const { createEditor } = require('./editor/codemirror-setup');
const { undo, redo } = require('@codemirror/commands'); const { undo, redo } = require('@codemirror/commands');
const { EditorView } = require('@codemirror/view');
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog'); const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
const { showPdfBatchDialog } = require('./renderer/pdf-batch-dialog'); const { showPdfBatchDialog } = require('./renderer/pdf-batch-dialog');
const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog'); const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog');
@@ -2241,11 +2242,27 @@ document.addEventListener('DOMContentLoaded', async () => {
const el = document.getElementById('explorer-path'); const el = document.getElementById('explorer-path');
return el ? el.value.trim() || null : null; return el ? el.value.trim() || null : null;
}, },
onOpenFile: (filePath /* , offset */) => { onOpenFile: (filePath, offset) => {
ipcRenderer.send('open-file-path', filePath); ipcRenderer.send('open-file-path', { path: filePath, offset });
}, },
}), }),
}); });
// Daily notes journal browser — lists existing YYYY-MM-DD.md files and
// surfaces a "Today" button that creates/opens today's entry. Renders
// paths as a placeholder string; main rewrites them on click (via the
// file-opened IPC path, which already handles any daily-notes path).
sidebarManager.registerPanel('daily-notes', {
title: 'Daily Notes',
icon:
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>',
render: (container) =>
require('./sidebar/daily-notes-panel').renderDailyNotesPanel(container, {
openToday: ({ date } = {}) => ipcRenderer.invoke('daily-notes:open-today', { date }),
listExisting: () => ipcRenderer.invoke('daily-notes:list'),
onOpenFile: (filePath) => ipcRenderer.send('open-file-path', { path: filePath }),
}),
});
sidebarManager.registerPanel('git', { sidebarManager.registerPanel('git', {
title: 'Git', title: 'Git',
render: (container) => render: (container) =>
@@ -2900,6 +2917,22 @@ ipcRenderer.on('file-opened', (event, data) => {
if (tabManager) { if (tabManager) {
tabManager.openFile(data.path, data.content); tabManager.openFile(data.path, data.content);
// Deep-link from search/Q&A: scroll the editor to the matching offset.
// openFile() loads the doc into the active tab; once it returns the
// CodeMirror view exists and we can dispatch a selection.
if (data && typeof data.offset === 'number' && data.offset > 0) {
const tab = tabManager.tabs.get(tabManager.activeTabId);
const view = tab && tab.editorView;
if (view) {
const docLen = view.state.doc.length;
const pos = Math.min(data.offset, docLen);
view.dispatch({
selection: { anchor: pos, head: pos },
effects: EditorView.scrollIntoView(pos, { y: 'center' }),
});
view.focus();
}
}
} else { } else {
console.error('[RENDERER] tabManager not initialized!'); console.error('[RENDERER] tabManager not initialized!');
} }
+141
View File
@@ -0,0 +1,141 @@
/**
* Sidebar panel: daily notes journal browser.
*
* Lists the YYYY-MM-DD.md files in <userData>/notes/daily/ newest-first,
* lets the user click one to open it, and surfaces a "Today" button that
* creates/opens today's note. Pure DOM module — same pattern as
* snippets-panel.js / search-panel.js.
*
* @param {HTMLElement} container
* @param {object} deps
* @param {() => Promise<Array<{docPath:string, savedAt:number, byteSize:number, appVersion:string}>>} [deps.listPendingRecoveries]
* @param {(args:{date?:string}) => Promise<{path:string, content:string, created:boolean}>} deps.openToday
* @param {() => Promise<string[]>} deps.listExisting
* @param {(filePath:string) => void} deps.onOpenFile
* @param {(filePath:string) => Promise<void>} [deps.deleteEntry] optional — deletes a daily note
*/
function renderDailyNotesPanel(container, deps) {
const { openToday, listExisting, onOpenFile } = deps;
let entries = []; // [{ path, name }]
container.innerHTML = `
<div class="daily-notes-panel">
<div class="daily-notes-toolbar">
<button class="daily-notes-today-btn" id="daily-notes-today">Today</button>
<button class="daily-notes-refresh-btn" id="daily-notes-refresh" title="Reload list">↻</button>
</div>
<div class="daily-notes-status" id="daily-notes-status"></div>
<div class="daily-notes-list" id="daily-notes-list"></div>
</div>
`;
const todayBtn = container.querySelector('#daily-notes-today');
const refreshBtn = container.querySelector('#daily-notes-refresh');
const statusEl = container.querySelector('#daily-notes-status');
const listEl = container.querySelector('#daily-notes-list');
function basename(p) {
return String(p).split(/[/\\]/).pop();
}
function statusOf(name) {
// YYYY-MM-DD.md → "Sun, 13 Sep 2026" (or local equivalent)
const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(name);
if (!m) return name;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (Number.isNaN(d.getTime())) return name;
return d.toLocaleDateString(undefined, {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
function setStatus(text) {
if (statusEl) statusEl.textContent = text || '';
}
function renderList() {
if (!listEl) return;
if (!entries || entries.length === 0) {
listEl.innerHTML =
'<div class="daily-notes-empty">No daily notes yet. Press <b>Today</b> to start.</div>';
return;
}
listEl.innerHTML = entries
.map((entry, i) => {
return `<div class="daily-notes-item" role="button" tabindex="0" data-idx="${i}">
<div class="daily-notes-name"></div>
<div class="daily-notes-sub"></div>
</div>`;
})
.join('');
// Set dynamic content via textContent so a malicious filename can't
// inject HTML.
listEl.querySelectorAll('.daily-notes-item').forEach((el) => {
const idx = Number(el.dataset.idx);
const entry = entries[idx];
el.querySelector('.daily-notes-name').textContent = entry.name.replace(/\.md$/, '');
el.querySelector('.daily-notes-sub').textContent = statusOf(entry.name);
el.addEventListener('click', () => {
if (onOpenFile) onOpenFile(entry.path);
});
el.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
el.click();
}
});
});
}
async function refresh({ keepStatus = false } = {}) {
if (typeof listExisting !== 'function') {
setStatus('Daily notes list is unavailable.');
return;
}
if (!keepStatus) setStatus('Loading…');
try {
const list = await listExisting();
// listExisting returns absolute paths (newest first). Pair each path
// with its basename for display.
const paths = Array.isArray(list) ? list : [];
entries = paths
.filter((p) => /\.md$/i.test(p))
.map((p) => ({ path: p, name: basename(p) }));
renderList();
if (!keepStatus) setStatus(entries.length === 0 ? '' : `${entries.length} note${entries.length === 1 ? '' : 's'}`);
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
renderList();
}
}
todayBtn.addEventListener('click', async () => {
setStatus('Opening today…');
todayBtn.disabled = true;
try {
const result = await openToday({});
if (result && result.path && onOpenFile) onOpenFile(result.path);
setStatus(result && result.created ? 'Created today.' : 'Today already exists.');
await refresh({ keepStatus: true });
} catch (err) {
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`);
} finally {
todayBtn.disabled = false;
}
});
refreshBtn.addEventListener('click', () => {
refresh();
});
refresh();
return {
refresh,
basename,
};
}
module.exports = { renderDailyNotesPanel };
+175
View File
@@ -0,0 +1,175 @@
/**
* @jest-environment jsdom
*
* Daily notes sidebar panel tests.
*/
const { renderDailyNotesPanel } = require('../src/sidebar/daily-notes-panel');
function mountPanel(deps = {}) {
const container = document.createElement('div');
document.body.appendChild(container);
const api = renderDailyNotesPanel(container, {
openToday: deps.openToday || jest.fn().mockResolvedValue({ path: '/notes/2026-09-14.md', created: true }),
listExisting: deps.listExisting || jest.fn().mockResolvedValue([]),
onOpenFile: deps.onOpenFile || jest.fn(),
...deps,
});
return { container, api };
}
async function flush() {
for (let i = 0; i < 5; i++) await Promise.resolve();
}
describe('renderDailyNotesPanel — mounting', () => {
test('mounts the panel structure', () => {
const { container } = mountPanel();
expect(container.querySelector('.daily-notes-panel')).not.toBeNull();
expect(container.querySelector('#daily-notes-today')).not.toBeNull();
expect(container.querySelector('#daily-notes-refresh')).not.toBeNull();
expect(container.querySelector('#daily-notes-list')).not.toBeNull();
});
test('shows the empty-state message when there are no notes', async () => {
const { container } = mountPanel({ listExisting: jest.fn().mockResolvedValue([]) });
await flush();
expect(container.querySelector('.daily-notes-empty')).not.toBeNull();
expect(container.textContent).toMatch(/Press.*Today/);
});
test('renders one row per filename', async () => {
const { container } = mountPanel({
listExisting: jest.fn().mockResolvedValue([
'/notes/daily/2026-09-14.md',
'/notes/daily/2026-09-13.md',
'/notes/daily/2026-09-10.md',
]),
});
await flush();
const items = container.querySelectorAll('.daily-notes-item');
expect(items).toHaveLength(3);
expect(items[0].textContent).toContain('2026-09-14');
expect(items[0].textContent).toMatch(/Sep/);
});
});
describe('renderDailyNotesPanel — Today button', () => {
test('clicking Today opens (or creates) today and calls onOpenFile with the path', async () => {
const openToday = jest.fn().mockResolvedValue({
path: '/notes/2026-09-14.md',
created: true,
content: '',
});
const onOpenFile = jest.fn();
const listExisting = jest.fn().mockResolvedValue(['2026-09-14.md']);
const { container } = mountPanel({ openToday, onOpenFile, listExisting });
await flush();
container.querySelector('#daily-notes-today').click();
await flush();
expect(openToday).toHaveBeenCalledWith({});
expect(onOpenFile).toHaveBeenCalledWith('/notes/2026-09-14.md');
});
test('shows a "Created today." / "Today already exists." status depending on the result', async () => {
const onOpenFile = jest.fn();
const { container } = mountPanel({
openToday: jest.fn().mockResolvedValue({ path: '/x.md', created: true }),
listExisting: jest.fn().mockResolvedValue([]),
onOpenFile,
});
await flush();
container.querySelector('#daily-notes-today').click();
await flush();
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/Created today/);
// Re-mount with a "not created" result
const { container: c2 } = mountPanel({
openToday: jest.fn().mockResolvedValue({ path: '/x.md', created: false }),
listExisting: jest.fn().mockResolvedValue([]),
onOpenFile,
});
await flush();
c2.querySelector('#daily-notes-today').click();
await flush();
expect(c2.querySelector('#daily-notes-status').textContent).toMatch(/already exists/);
});
test('handles openToday errors gracefully (no crash, status reports)', async () => {
const openToday = jest.fn().mockRejectedValue(new Error('disk gone'));
const { container } = mountPanel({ openToday });
await flush();
container.querySelector('#daily-notes-today').click();
await flush();
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/disk gone/);
});
});
describe('renderDailyNotesPanel — click an existing entry', () => {
test('clicking a row calls onOpenFile with that row\'s path', async () => {
const onOpenFile = jest.fn();
const { container } = mountPanel({
onOpenFile,
listExisting: jest.fn().mockResolvedValue([
'/notes/daily/2026-09-14.md',
'/notes/daily/2026-09-10.md',
]),
});
await flush();
const items = container.querySelectorAll('.daily-notes-item');
items[1].click();
expect(onOpenFile).toHaveBeenCalledWith('/notes/daily/2026-09-10.md');
});
test('Enter / Space on a focused row opens it', async () => {
const onOpenFile = jest.fn();
const { container } = mountPanel({
onOpenFile,
listExisting: jest.fn().mockResolvedValue(['/notes/daily/2026-09-14.md']),
});
await flush();
const row = container.querySelector('.daily-notes-item');
row.focus();
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await flush();
expect(onOpenFile).toHaveBeenCalledWith('/notes/daily/2026-09-14.md');
});
});
describe('renderDailyNotesPanel — refresh', () => {
test('refresh button re-fetches the list', async () => {
const listExisting = jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce(['2026-09-14.md']);
const { container, api } = mountPanel({ listExisting });
await flush();
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(0);
api.refresh();
await flush();
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(1);
expect(listExisting).toHaveBeenCalledTimes(2);
});
});
describe('renderDailyNotesPanel — graceful degradation', () => {
test('shows an error when listExisting rejects', async () => {
const listExisting = jest.fn().mockRejectedValue(new Error('ENOENT'));
const { container } = mountPanel({ listExisting });
await flush();
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/ENOENT/);
});
test('shows an error when listExisting is not provided', async () => {
const { container } = mountPanel({ listExisting: null });
await flush();
expect(container.querySelector('#daily-notes-status').textContent).toMatch(/unavailable/);
});
test('filters out non-md entries', async () => {
const { container } = mountPanel({
listExisting: jest.fn().mockResolvedValue(['2026-09-14.md', 'readme.txt', '.DS_Store']),
});
await flush();
expect(container.querySelectorAll('.daily-notes-item')).toHaveLength(1);
});
});