feat: v4.6.0 — AI assistant, collaboration, knowledge base, and 15 more features

- AI Assistant plugin: multi-provider chat (OpenAI/Anthropic/Ollama/LM Studio),
  summarize/improve/translate commands, proofread via ai:analyze; calls
  proxied through main so API keys stay out of the renderer
- Collaboration plugin: anchor-based comments in .comments/ sidecars with
  drift detection and F8 navigation
- Local knowledge base: [[wiki-links]] with click-to-create + Backlinks panel
- Crash recovery: debounced session snapshots with restore prompt on launch
- Version history: pre-save snapshots, History panel with restore/diff/delete
- Real PDF encryption: swap pdf-lib for @cantoo/pdf-lib (probe-driven UI)
- XLSX export (native workbooks via JSZip), ODT headers/footers + page size
- Offline KaTeX (bundled CSS+fonts), local-first PlantUML rendering
- Editor: vim mode toggle, snippet Tab-expansion, zen word-goal setter,
  writing heatmap, writing-studio panels wired with rail icons
- Quick Note global scratchpad (Ctrl+Alt+Q), markdownconverter:// deep links,
  REPL first-run confirmation
- Fix: Ctrl+Shift+P collision, pandoc converter availability check, CLI
  dangling --css/--reference-doc flags, dead converter button

8 new test suites; 613 tests green; lint clean
This commit is contained in:
2026-09-05 20:48:39 +05:30
parent b83ba91731
commit c4dcbd8caf
105 changed files with 5695 additions and 195 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* Backlinks sidebar panel — the "what links here?" view for the local
* knowledge-base. Lists documents in the current folder that wiki-link to the
* active document, with jump-to-source. Re-scans on every render (panel opens
* / refresh) because scans are bounded and folders are small in practice.
*
* @module BacklinksPanel
*/
const { collectMarkdownFiles, findBacklinks } = require('../../utils/backlinks');
const { docNameFor } = require('../../utils/wiki-links');
/**
* Render the backlinks panel.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {() => string|null} deps.getCurrentFilePath Active tab's file path
* @param {(dir: string) => Promise<{entries:Array}|null>} deps.listDir
* @param {(path: string) => Promise<string|null>} deps.readFile
* @param {(path: string) => void} deps.onFileOpen Open a file in a new tab
* @param {Function} deps.pathUtil injected path module
*/
async function renderBacklinksPanel(container, deps) {
container.replaceChildren();
const { getCurrentFilePath, listDir, readFile, onFileOpen, pathUtil } = deps;
const panel = document.createElement('div');
panel.className = 'ws-panel';
const heading = document.createElement('h3');
heading.className = 'ws-heading';
heading.textContent = 'Backlinks';
panel.appendChild(heading);
const docPath = getCurrentFilePath();
if (!docPath) {
appendNote(panel, 'Open a saved document to see what links to it.');
container.appendChild(panel);
return;
}
const docName = docNameFor(docPath, pathUtil);
const dir = pathUtil.dirname(docPath);
const status = document.createElement('p');
status.className = 'ws-muted';
status.textContent = `Scanning for links to "${docName}"…`;
panel.appendChild(status);
container.appendChild(panel);
try {
const files = await collectMarkdownFiles(dir, listDir);
const links = await findBacklinks({ docName, docPath, files, readFile });
status.textContent =
files.length === 0
? 'No markdown files found in this folder.'
: `${links.length} document${links.length === 1 ? '' : 's'} link here ` +
`(scanned ${files.length} files)`;
if (links.length > 0) {
const list = document.createElement('div');
list.className = 'ws-issues-list';
for (const link of links) {
const item = document.createElement('div');
item.className = 'ws-issue-item';
const name = pathUtil.basename(link.path);
item.innerHTML = `
<div class="ws-issue-type"><span>${escapeText(name)} : line ${link.line}</span></div>
<div class="ws-issue-text">${escapeText(link.context)}</div>`;
// Whole item is clickable — jump straight to the linking document
item.setAttribute('role', 'link');
item.setAttribute('tabindex', '0');
item.style.cursor = 'pointer';
item.addEventListener('click', () => onFileOpen(link.path));
item.addEventListener('keydown', (event) => {
if (event.key === 'Enter') onFileOpen(link.path);
});
list.appendChild(item);
}
panel.appendChild(list);
}
} catch (error) {
status.textContent = 'Scan failed: ' + (error.message || error);
}
}
function appendNote(parent, text) {
const note = document.createElement('p');
note.className = 'ws-muted';
note.textContent = text;
parent.appendChild(note);
}
function escapeText(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
module.exports = { renderBacklinksPanel };
+180
View File
@@ -0,0 +1,180 @@
/**
* History sidebar panel — local version history for the current document.
*
* Lists snapshots captured automatically before every save (plus manual ones),
* and offers Restore (replace editor content), Diff (side-by-side line diff
* against the current editor content, reusing utils/line-diff), and Delete.
*
* @module HistoryPanel
*/
const { computeLineDiff } = require('../utils/line-diff');
/** Escape text before it enters innerHTML templates. */
function esc(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** "3 min ago"-style relative time for version labels. */
function timeAgo(ts) {
const seconds = Math.max(1, Math.floor((Date.now() - ts) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
/**
* Render the history panel.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {() => string|null} deps.getCurrentFilePath Active tab's file path
* @param {() => string} deps.getCurrentContent Live editor content
* @param {(content: string) => void} deps.replaceContent Swap the editor's document
* @param {object} deps.history IPC-backed VersionHistory api:
* { list(docPath), read(docPath, id), save(docPath, content, label), delete(docPath, id) }
*/
async function renderHistoryPanel(container, deps) {
container.replaceChildren();
const { getCurrentFilePath, getCurrentContent, replaceContent, history } = deps;
const panel = document.createElement('div');
panel.className = 'ws-panel';
const heading = document.createElement('h3');
heading.className = 'ws-heading';
heading.textContent = 'Version History';
panel.appendChild(heading);
const docPath = getCurrentFilePath();
if (!docPath) {
const note = document.createElement('p');
note.className = 'ws-muted';
note.textContent =
'Open a saved document to see its local history. Versions are captured automatically before each save.';
panel.appendChild(note);
container.appendChild(panel);
return;
}
// Manual snapshot control: pin the current state before a risky edit
const snapshotBtn = document.createElement('button');
snapshotBtn.className = 'ws-btn ws-btn-primary';
snapshotBtn.textContent = 'Save version now';
snapshotBtn.addEventListener('click', async () => {
await history.save(docPath, getCurrentContent(), 'manual');
renderHistoryPanel(container, deps);
});
panel.appendChild(snapshotBtn);
let versions = [];
try {
versions = (await history.list(docPath)) || [];
} catch {
/* leave the list empty */
}
const list = document.createElement('div');
list.className = 'ws-issues-list';
if (versions.length === 0) {
const empty = document.createElement('p');
empty.className = 'ws-muted';
empty.textContent = 'No versions yet — one is captured each time you save.';
list.appendChild(empty);
}
for (const version of versions) {
const item = document.createElement('div');
item.className = 'ws-issue-item';
const date = new Date(version.createdAt).toLocaleString();
item.innerHTML = `
<div class="ws-issue-type">
<span>${esc(version.label || 'version')} · ${esc(timeAgo(version.createdAt))}</span>
</div>
<div class="ws-muted">${esc(date)} · ${version.wordCount ?? '?'} words</div>
<div class="ws-issue-actions">
<button class="ws-btn ws-btn-sm" data-action="restore">Restore</button>
<button class="ws-btn ws-btn-sm" data-action="diff">Diff</button>
<button class="ws-btn ws-btn-sm" data-action="delete">Delete</button>
</div>`;
item.querySelector('[data-action="restore"]').addEventListener('click', async () => {
if (
!confirm(
'Replace the editor content with this version? (Your current text is snapped first.)'
)
) {
return;
}
// Guard rail: snapshot the live content before overwriting it
await history.save(docPath, getCurrentContent(), 'before restore');
const content = await history.read(docPath, version.id);
replaceContent(content);
});
item.querySelector('[data-action="diff"]').addEventListener('click', async () => {
const content = await history.read(docPath, version.id);
showDiffModal(content, getCurrentContent(), version);
});
item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
await history.delete(docPath, version.id);
renderHistoryPanel(container, deps);
});
list.appendChild(item);
}
panel.appendChild(list);
container.appendChild(panel);
}
/**
* Overlay modal showing a unified line diff (version → current).
* Read-only; closed with its button, Esc, or backdrop click.
*/
function showDiffModal(oldText, newText, version) {
const overlay = document.createElement('div');
overlay.className = 'ai-settings-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
const body = document.createElement('div');
body.className = 'history-diff-body';
// computeLineDiff takes raw texts and returns {type: 'added'|'removed'|'unchanged', text}
const diffLines = computeLineDiff(String(oldText || ''), String(newText || ''));
for (const line of diffLines) {
const div = document.createElement('div');
div.className = `history-diff-line history-diff-${line.type}`;
div.textContent =
(line.type === 'added' ? '+ ' : line.type === 'removed' ? '- ' : ' ') + (line.text ?? '');
body.appendChild(div);
}
const dialog = document.createElement('div');
dialog.className = 'ai-settings-dialog history-diff-dialog';
dialog.innerHTML = `<h3>Version diff — ${esc(new Date(version.createdAt).toLocaleString())}</h3>
<p class="ai-settings-note">Red: version · Green: current editor content</p>`;
dialog.appendChild(body);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = 'Close';
closeBtn.className = 'ws-btn';
dialog.appendChild(closeBtn);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
const close = () => overlay.remove();
closeBtn.addEventListener('click', close);
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
}
module.exports = { renderHistoryPanel };
+30 -1
View File
@@ -17,8 +17,37 @@ class SidebarManager {
?.addEventListener('click', () => this.collapse());
}
registerPanel(name, { title, render }) {
/**
* Register a sidebar panel. Core panels (explorer/git/…) get their icons
* from index.html; plugins pass an `icon` (inline SVG string) and a rail
* button is created for them automatically so their panel is reachable
* without editing the app shell.
*
* @param {string} name Panel id
* @param {object} opts { title, render, icon? }
*/
registerPanel(name, { title, render, icon }) {
this.panels.set(name, { title, render });
if (icon && !document.querySelector(`.sidebar-icon[data-panel="${name}"]`)) {
this.addIcon(name, { title, icon });
}
}
/**
* Append an icon button to the sidebar rail for a registered panel.
* Idempotent — re-registering a plugin won't duplicate its icon.
*/
addIcon(name, { title, icon }) {
const btn = document.createElement('button');
btn.className = 'sidebar-icon';
btn.dataset.panel = name;
btn.title = title || name;
btn.setAttribute('aria-label', title || name);
btn.innerHTML = icon;
btn.addEventListener('click', () => this.togglePanel(name));
this.sidebar?.querySelector('.sidebar-icons')?.appendChild(btn) ||
this.sidebar?.appendChild(btn);
}
togglePanel(name) {