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
+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 };