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
@@ -0,0 +1,164 @@
/**
* AI Assistant chat sidebar panel.
*
* Renders a compact chat interface (message list, composer, status badge) and
* wires it to the plugin's IPC-backed completion helper. All network work
* happens in the main process; this file only manages conversation state
* (history is kept in memory for the session, not persisted).
*
* @module AiChatPanel
*/
/** Simple HTML escape for all user/model text before insertion. */
function esc(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Render the chat panel into its sidebar container.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {Function} deps.complete async ({system, messages}) => {content}
* @param {Function} deps.insertAtCursor (text) => void — insert reply in doc
* @param {Function} deps.getSelection () => string — for "use selection"
* @param {{provider:string, model:string, configured:boolean}} deps.statusInfo
* @param {Function} deps.onOpenSettings () => void — jump to provider config
*/
function renderChatPanel(container, deps) {
container.replaceChildren();
const panel = document.createElement('div');
panel.className = 'ws-panel ai-chat-panel';
panel.innerHTML = `
<div class="ws-section">
<div class="ai-chat-status">
<span class="ai-chat-badge" data-role="badge"></span>
<button class="ws-btn ws-btn-sm" data-role="settings" type="button">Setup…</button>
</div>
<div class="ai-chat-messages" data-role="messages" aria-live="polite"></div>
<div class="ai-chat-composer">
<textarea data-role="input" rows="3" placeholder="Ask about your document…
(Shift+Enter for newline)"></textarea>
<div class="ai-chat-actions">
<button class="ws-btn" data-role="use-selection" type="button"
title="Insert the current editor selection into the composer">Use selection</button>
<button class="ws-btn ws-btn-primary" data-role="send" type="button">Send</button>
</div>
<div class="ai-chat-footer">
<button class="ws-btn ws-btn-sm" data-role="insert-last" type="button"
title="Insert the last assistant reply at the cursor">Insert last reply</button>
<button class="ws-btn ws-btn-sm" data-role="clear" type="button">Clear chat</button>
</div>
</div>
</div>`;
container.appendChild(panel);
// --- Element handles -----------------------------------------------------
const badge = panel.querySelector('[data-role="badge"]');
const messagesEl = panel.querySelector('[data-role="messages"]');
const inputEl = panel.querySelector('[data-role="input"]');
const sendBtn = panel.querySelector('[data-role="send"]');
const insertLastBtn = panel.querySelector('[data-role="insert-last"]');
const useSelectionBtn = panel.querySelector('[data-role="use-selection"]');
const clearBtn = panel.querySelector('[data-role="clear"]');
const settingsBtn = panel.querySelector('[data-role="settings"]');
// --- Conversation state (session-scoped) ---------------------------------
const history = []; // {role: 'user'|'assistant', content}
let lastAssistantReply = '';
let busy = false;
function renderBadge() {
const { provider, model, configured } = deps.statusInfo;
badge.textContent = configured ? `${provider} · ${model}` : 'Not configured';
badge.classList.toggle('ai-chat-badge-ok', Boolean(configured));
badge.classList.toggle('ai-chat-badge-off', !configured);
sendBtn.disabled = !configured || busy;
}
function appendMessage(role, text) {
const item = document.createElement('div');
item.className = `ai-chat-msg ai-chat-msg-${role}`;
item.innerHTML = `<div class="ai-chat-msg-role">${role === 'user' ? 'You' : 'AI'}</div>
<div class="ai-chat-msg-body">${esc(text)}</div>`;
messagesEl.appendChild(item);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function appendBusy() {
const item = document.createElement('div');
item.className = 'ai-chat-msg ai-chat-msg-assistant ai-chat-msg-busy';
item.dataset.role = 'busy';
item.textContent = 'Thinking…';
messagesEl.appendChild(item);
messagesEl.scrollTop = messagesEl.scrollHeight;
return item;
}
async function send() {
const text = inputEl.value.trim();
if (!text || busy || !deps.statusInfo.configured) return;
busy = true;
renderBadge();
inputEl.value = '';
appendMessage('user', text);
history.push({ role: 'user', content: text });
const busyEl = appendBusy();
try {
// Keep a rolling window of the last 12 turns so long chats don't grow
// request payloads unboundedly.
const window = history.slice(-12);
const { content } = await deps.complete({ messages: window });
lastAssistantReply = content;
history.push({ role: 'assistant', content });
appendMessage('assistant', content);
} catch (error) {
appendMessage('assistant', `Error: ${error.message || 'request failed'}`);
} finally {
busyEl.remove();
busy = false;
renderBadge();
inputEl.focus();
}
}
// --- Wire events ----------------------------------------------------------
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send();
}
});
useSelectionBtn.addEventListener('click', () => {
const sel = deps.getSelection();
if (sel) inputEl.value += (inputEl.value ? '\n\n' : '') + sel;
inputEl.focus();
});
insertLastBtn.addEventListener('click', () => {
if (lastAssistantReply) deps.insertAtCursor(lastAssistantReply);
});
clearBtn.addEventListener('click', () => {
history.length = 0;
lastAssistantReply = '';
messagesEl.replaceChildren();
});
settingsBtn.addEventListener('click', () => deps.onOpenSettings());
renderBadge();
if (!deps.statusInfo.configured) {
appendMessage(
'assistant',
'Set up a provider first (OpenAI, Anthropic, or a local Ollama/LM Studio server) via the Setup button.'
);
}
}
module.exports = { renderChatPanel };
+293
View File
@@ -0,0 +1,293 @@
/**
* AI Assistant built-in plugin.
*
* Brings LLM assistance into the editor with multi-provider support:
* - OpenAI / Anthropic (cloud, API key required)
* - Ollama / LM Studio / any OpenAI-compatible endpoint (local, no key)
*
* All provider traffic is proxied through the main process
* (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter
* the renderer and the renderer CSP stays closed to AI endpoints.
*
* Extension points:
* - Sidebar "AI Chat" panel (session-scoped conversation, insert replies)
* - Commands: Summarize / Improve / Explain / Translate selection
* - EventBus `ai:analyze` handler — powers the writing-studio Proofread
* panel, which was built waiting for exactly this plugin
*
* @module ai-assistant
*/
const { PluginAPI } = require('../../../plugins/plugin-api');
const { renderChatPanel } = require('./chat-panel');
const { buildTaskPrompt, buildProofreadPrompt, parseProofreadIssues } = require('./prompts');
class AiAssistantPlugin extends PluginAPI {
init(context) {
this.context = context;
// Cached provider status; refreshed from main whenever settings change
this._statusInfo = { provider: '', model: '', configured: false };
this._settingsModalOpen = false;
this._refreshStatus();
this._registerSidebar(context);
this._registerCommands(context);
this._registerAnalyzeHandler(context);
}
/** Fetch provider/model/configured from the main process (no key material). */
async _refreshStatus() {
try {
const status = await this.context.ipc.invoke('ai-assistant:status', null);
this._statusInfo = {
provider: status.provider || '',
model: status.model || '',
configured: Boolean(status.configured),
};
} catch {
this._statusInfo = { provider: '', model: '', configured: false };
}
}
_registerSidebar(context) {
// Panel body + rail icon: PluginContext namespaces the panel id to
// "ai-assistant:chat"; SidebarManager creates the rail icon from the
// inline SVG so the plugin needs no app-shell edits.
context.sidebar.registerPanel('chat', {
title: 'AI Chat',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"></path>
<path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"></path>
</svg>`,
render: (container) =>
renderChatPanel(container, {
complete: (request) => this._complete(request),
insertAtCursor: (text) => context.editor.insertAtCursor(text),
getSelection: () => context.editor.getSelection() || '',
statusInfo: this._statusInfo,
onOpenSettings: () => this._openSettingsModal(),
}),
});
}
_registerCommands(context) {
const runOnSelection = async (action, extra) => {
const selection = context.editor.getSelection();
const text = selection && selection.trim() ? selection : context.editor.getContent() || '';
if (!text.trim()) return;
try {
const { system, user } = buildTaskPrompt(action, text, extra);
const { content } = await this._complete({
system,
messages: [{ role: 'user', content: user }],
});
if (action === 'improve' && selection && selection.trim()) {
// Improving an explicit selection replaces it at the cursor
context.editor.insertAtCursor(content);
} else {
context.editor.insertAtCursor(`\n\n${content}`);
}
} catch (error) {
console.warn('[ai-assistant] task failed:', error.message);
}
};
context.commands.register('summarize-selection', 'AI: Summarize Selection', () =>
runOnSelection('summarize')
);
context.commands.register('improve-selection', 'AI: Improve Writing', () =>
runOnSelection('improve')
);
context.commands.register('explain-selection', 'AI: Explain Selection', () =>
runOnSelection('explain')
);
context.commands.register('translate-selection', 'AI: Translate Selection', () => {
// window.prompt() is not supported in Electron renderers — use a small
// inline dialog instead. Cancelling leaves everything untouched.
this._inputBox('Translate to which language?', 'e.g. French, Japanese').then((target) => {
if (target) runOnSelection('translate', target);
});
});
context.commands.register('open-chat', 'AI: Open Chat', () => {
this._openSettingsModalRefresh();
// Ask the host to expand our sidebar panel (wired via the event bus)
this.context.events.emit('sidebar:open-panel', { panel: 'ai-assistant:chat' });
});
}
/**
* Minimal promise-based text input dialog (Electron has no window.prompt).
* Resolves with the text, or null when cancelled.
* @returns {Promise<string|null>}
*/
_inputBox(label, placeholder) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'ai-settings-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="ai-settings-dialog">
<h3>${label}</h3>
<input type="text" data-role="input" placeholder="${placeholder || ''}" />
<div class="ai-settings-actions">
<button data-role="ok" type="button" class="ws-btn ws-btn-primary">OK</button>
<button data-role="cancel" type="button" class="ws-btn">Cancel</button>
</div>
</div>`;
const input = overlay.querySelector('[data-role="input"]');
const done = (value) => {
overlay.remove();
resolve(value);
};
overlay
.querySelector('[data-role="ok"]')
.addEventListener('click', () => done(input.value.trim() || null));
overlay.querySelector('[data-role="cancel"]').addEventListener('click', () => done(null));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) done(null);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') done(input.value.trim() || null);
if (event.key === 'Escape') done(null);
});
document.body.appendChild(overlay);
input.focus();
});
}
/**
* Fulfill the writing-studio proofread contract: listen for `ai:analyze`
* events ({text, type, callback}) and answer with {issues: [...]} parsed
* from the model's JSON reply.
*/
_registerAnalyzeHandler(context) {
context.events.on('ai:analyze', async (payload) => {
if (!payload || typeof payload.callback !== 'function') return;
if (!this._statusInfo.configured) {
payload.callback({
issues: [
{
type: 'setup',
message: 'AI Assistant is not configured. Set a provider via AI: Open Chat → Setup.',
suggestion: '',
},
],
});
return;
}
try {
const { system, user } = buildProofreadPrompt(String(payload.text || ''));
const { content } = await this._complete({
system,
messages: [{ role: 'user', content: user }],
});
payload.callback({ issues: parseProofreadIssues(content) });
} catch (error) {
payload.callback({ issues: [{ type: 'error', message: error.message, suggestion: '' }] });
}
});
}
/**
* Shared completion helper: main process owns provider settings, so the
* plugin only sends the prompt payload.
*/
async _complete(request) {
await this._refreshStatus();
if (!this._statusInfo.configured) {
throw new Error('AI Assistant is not configured (see AI Chat → Setup)');
}
return this.context.ipc.invoke('ai-assistant:complete', request);
}
/**
* Tiny settings modal rendered by the plugin itself (plugin settings have
* no host-provided UI yet). Persists through the plugin settings store,
* which lands in settings.json main-side.
*/
_openSettingsModalRefresh() {
this._refreshStatus();
}
_openSettingsModal() {
if (this._settingsModalOpen) return;
this._settingsModalOpen = true;
const overlay = document.createElement('div');
overlay.className = 'ai-settings-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.innerHTML = `
<div class="ai-settings-dialog">
<h3>AI Assistant Settings</h3>
<label>Provider
<select data-role="provider">
<option value="ollama">Ollama (local)</option>
<option value="lmstudio">LM Studio (local)</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI-compatible…</option>
</select>
</label>
<label>Model <input data-role="model" type="text" placeholder="e.g. llama3.1, gpt-4o-mini" /></label>
<label>Base URL <input data-role="baseUrl" type="text" placeholder="http://localhost:11434/v1" /></label>
<label>API Key <input data-role="apiKey" type="password" placeholder="(not needed for local)" /></label>
<label>Temperature <input data-role="temperature" type="number" min="0" max="2" step="0.1" /></label>
<p class="ai-settings-note">Settings are stored locally in settings.json. API keys never leave this machine.</p>
<div class="ai-settings-actions">
<button data-role="save" type="button">Save</button>
<button data-role="cancel" type="button">Cancel</button>
</div>
</div>`;
const close = () => {
overlay.remove();
this._settingsModalOpen = false;
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
const $ = (role) => overlay.querySelector(`[data-role="${role}"]`);
// Pre-fill from current settings
Promise.all([
this.context.settings.get('provider'),
this.context.settings.get('model'),
this.context.settings.get('baseUrl'),
this.context.settings.get('apiKey'),
this.context.settings.get('temperature'),
]).then(([provider, model, baseUrl, apiKey, temperature]) => {
if (provider) $('provider').value = provider;
if (model) $('model').value = model;
if (baseUrl) $('baseUrl').value = baseUrl;
if (apiKey) $('apiKey').value = apiKey;
if (temperature !== undefined && temperature !== null) {
$('temperature').value = temperature;
}
});
$('cancel').addEventListener('click', close);
$('save').addEventListener('click', async () => {
await this.context.settings.set('provider', $('provider').value);
await this.context.settings.set('model', $('model').value.trim());
await this.context.settings.set('baseUrl', $('baseUrl').value.trim());
await this.context.settings.set('apiKey', $('apiKey').value.trim());
await this.context.settings.set('temperature', parseFloat($('temperature').value) || 0.7);
await this._refreshStatus();
close();
});
document.body.appendChild(overlay);
}
deactivate() {
/* no long-lived timers to clean up */
}
}
module.exports = { Plugin: AiAssistantPlugin };
@@ -0,0 +1,44 @@
{
"id": "ai-assistant",
"name": "AI Assistant",
"version": "1.0.0",
"description": "Multi-provider AI help: chat panel, summarize/improve/explain/translate commands, and grammar proofreading via the writing-studio Proofread panel",
"icon": "sparkles",
"extensionPoints": {
"sidebar": [{ "id": "chat", "title": "AI Chat", "order": 40 }],
"commands": [
{ "id": "summarize-selection", "label": "AI: Summarize Selection", "shortcut": "" },
{ "id": "improve-selection", "label": "AI: Improve Writing", "shortcut": "" },
{ "id": "explain-selection", "label": "AI: Explain Selection", "shortcut": "" },
{ "id": "translate-selection", "label": "AI: Translate Selection", "shortcut": "" },
{ "id": "open-chat", "label": "AI: Open Chat", "shortcut": "" }
]
},
"settings": [
{
"key": "provider",
"type": "string",
"default": "",
"label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible)"
},
{ "key": "model", "type": "string", "default": "", "label": "Model name" },
{
"key": "baseUrl",
"type": "string",
"default": "",
"label": "API base URL (optional override)"
},
{
"key": "apiKey",
"type": "string",
"default": "",
"label": "API key (not required for local providers)"
},
{
"key": "temperature",
"type": "number",
"default": 0.7,
"label": "Sampling temperature (0-2)"
}
]
}
@@ -0,0 +1,142 @@
/**
* Prompt builders and output parsers for the AI Assistant plugin.
*
* Kept as a pure module (no DOM, no IPC) so prompt construction and the
* proofread-issue parser can be unit-tested directly.
*
* @module AiPrompts
*/
/**
* Task prompt templates. Each entry maps an assistant action to a system
* prompt (behavior contract) and a user-prompt builder over the document
* text. Markdown output is requested for editor-facing actions so results
* can be inserted straight into the document.
*/
const TASKS = {
summarize: {
system:
'You are a concise writing assistant. Summarize the user text in clear markdown. ' +
'Use a short paragraph followed by 3-5 bullet points with the key ideas.',
user: (text, extra) => `Summarize the following${extra ? ` (${extra})` : ''}:\n\n${text}`,
},
improve: {
system:
'You are a professional editor. Improve the user text for clarity, flow, and correctness. ' +
'Return ONLY the rewritten markdown — no preamble, no explanations, no code fences ' +
'around the whole answer.',
user: (text) => `Rewrite and improve this text:\n\n${text}`,
},
explain: {
system:
'You are a patient technical explainer. Explain the user text in plain language, ' +
'using short markdown sections and examples where helpful.',
user: (text) => `Explain the following:\n\n${text}`,
},
translate: {
system:
'You are a careful translator. Translate the user text, preserving markdown formatting, ' +
'tone, and technical terminology. Return ONLY the translation.',
user: (text, targetLanguage) =>
`Translate the following to ${targetLanguage || 'English'}:\n\n${text}`,
},
chat: {
// Free-form conversation; the panel supplies its own message history
system:
'You are a helpful writing and markdown assistant inside a desktop editor. ' +
'Answer in markdown. Be concise unless asked for detail.',
user: (text) => text,
},
};
/**
* Build a {system, user} prompt pair for a known task action.
*
* @param {string} action Task id (summarize|improve|explain|translate|chat)
* @param {string} text Document text or selection
* @param {string} [extra] e.g. target language for translate
* @returns {{system: string, user: string}}
* @throws {Error} on unknown action
*/
function buildTaskPrompt(action, text, extra) {
const task = TASKS[action];
if (!task) throw new Error(`Unknown AI task "${action}"`);
return { system: task.system, user: task.user(String(text || ''), extra) };
}
/**
* System+user prompts for grammar proofreading. The writing-studio proofread
* panel expects a callback with `{issues: [{type, message, suggestion}]}`,
* so the model is asked for strict JSON.
*/
const PROOFREAD_SYSTEM =
'You are a strict proofreader. Find grammar, spelling, and punctuation issues in the text. ' +
'Respond with ONLY a JSON array — no prose, no code fences. Each element must be an object: ' +
'{"type": "grammar"|"spelling"|"punctuation"|"style", "message": string, "suggestion": string}. ' +
'The message should quote or describe the problematic fragment; the suggestion is the fix. ' +
'If there are no issues, respond with [].';
/** @returns {{system: string, user: string}} */
function buildProofreadPrompt(text) {
return {
system: PROOFREAD_SYSTEM,
user: `Proofread the following text and list its issues as the JSON array described:\n\n${text}`,
};
}
/**
* Parse a model's proofread answer into an issues array. Tolerates the usual
* LLM quirks: code fences around the JSON, leading prose, trailing commas,
* and single-quoted keys. Returns [] when nothing parseable is found rather
* than throwing — a chatty model must not break the panel.
*
* @param {string} modelOutput Raw assistant text
* @returns {Array<{type: string, message: string, suggestion: string}>}
*/
function parseProofreadIssues(modelOutput) {
const raw = String(modelOutput || '').trim();
if (!raw) return [];
// Strip markdown code fences the model may have added despite instructions
const unfenced = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```$/i, '')
.trim();
// Grab the outermost [...] block; ignores any leading/trailing prose
const start = unfenced.indexOf('[');
const end = unfenced.lastIndexOf(']');
if (start === -1 || end === -1 || end <= start) return [];
let jsonSlice = unfenced.slice(start, end + 1);
let parsed;
try {
parsed = JSON.parse(jsonSlice);
} catch {
try {
// Retry after trimming trailing commas (a common LLM artifact)
jsonSlice = jsonSlice.replace(/,\s*([\]}])/g, '$1');
parsed = JSON.parse(jsonSlice);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) return [];
// Normalize/whitelist fields so the panel always gets a stable shape
return parsed
.filter((item) => item && typeof item === 'object' && (item.message || item.suggestion))
.slice(0, 100)
.map((item) => ({
type: typeof item.type === 'string' ? item.type.toLowerCase() : 'grammar',
message: String(item.message || item.suggestion || ''),
suggestion: item.suggestion === undefined ? '' : String(item.suggestion),
}));
}
module.exports = {
buildTaskPrompt,
buildProofreadPrompt,
parseProofreadIssues,
TASKS,
};
@@ -0,0 +1,176 @@
/**
* Comment store for the Collaboration plugin.
*
* Inline document comments live in a sidecar folder (`.comments/`) next to the
* document they annotate, one JSON file per document (`<name>.json`), matching
* the v5 collaboration design. Keeping comments out of the document itself
* means they never ship in exports or leak into Git commits of the prose.
*
* A comment anchors to a line number plus the anchor line's text snippet; the
* snippet lets the UI detect drift ("line moved / text changed") after edits
* without storing full document positions.
*
* All IO is injected, so the module is unit-testable and works from both the
* renderer (via IPC file helpers) and tests (via in-memory fakes).
*
* @module CommentStore
*/
const COMMENT_FOLDER = '.comments';
/**
* Path of the sidecar comments file for a document path.
* Pure string surgery — no IO, easy to test.
*
* @param {string} docPath Absolute path to the markdown document
* @param {Function} pathUtil path module (or equivalent) — injected for tests
* @returns {string} e.g. /docs/.comments/notes.md.json
*/
function commentsFilePathFor(docPath, pathUtil) {
const p = pathUtil;
return p.join(p.dirname(docPath), COMMENT_FOLDER, p.basename(docPath) + '.json');
}
/**
* Generate a reasonably unique comment id without external deps.
*/
function newCommentId(now = Date.now()) {
return 'c' + now.toString(36) + Math.random().toString(36).slice(2, 8);
}
/**
* Load all comments for a document. Missing file/corrupt JSON → [] (a fresh
* document simply has no comments yet; a corrupt store must not crash the app).
*
* @param {string} docPath Document path
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
* @param {Function} pathUtil injected path module
* @returns {Array<object>} comments sorted by line then createdAt
*/
function loadComments(docPath, io, pathUtil) {
if (!docPath) return [];
const file = commentsFilePathFor(docPath, pathUtil);
try {
if (!io.fileExists(file)) return [];
const raw = io.readFile(file);
const data = JSON.parse(raw);
if (!Array.isArray(data.comments)) return [];
return normalizeComments(data.comments);
} catch {
return [];
}
}
/**
* Persist comments for a document (creates the sidecar structure on demand).
*
* @param {string} docPath Document path
* @param {Array<object>} comments
* @param {object} io - { writeFile, ensureDirectory }
* @param {Function} pathUtil injected path module
*/
function saveComments(docPath, comments, io, pathUtil) {
const file = commentsFilePathFor(docPath, pathUtil);
io.ensureDirectory(pathUtil.dirname(file));
io.writeFile(
file,
JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2)
);
}
/** Coerce/validate raw entries into the canonical comment shape. */
function normalizeComments(raw) {
return raw
.filter((c) => c && typeof c === 'object' && typeof c.line === 'number')
.map((c) => ({
id: typeof c.id === 'string' ? c.id : newCommentId(),
line: Math.max(1, Math.floor(c.line)),
anchorText: typeof c.anchorText === 'string' ? c.anchorText : '',
author: typeof c.author === 'string' ? c.author : 'anonymous',
text: typeof c.text === 'string' ? c.text : '',
createdAt: typeof c.createdAt === 'number' ? c.createdAt : Date.now(),
resolved: c.resolved === true,
}))
.sort((a, b) => a.line - b.line || a.createdAt - b.createdAt);
}
/**
* Add a comment anchored to a document line.
*
* @param {Array<object>} comments Existing list (mutated copy returned)
* @param {{line:number, anchorText?:string, author?:string, text:string}} input
* @returns {object} the created comment
*/
function addComment(comments, input) {
const comment = {
id: newCommentId(),
line: Math.max(1, Math.floor(input.line || 1)),
anchorText: String(input.anchorText || '').slice(0, 200),
author: String(input.author || 'me'),
text: String(input.text || '').slice(0, 4000),
createdAt: Date.now(),
resolved: false,
};
comments.push(comment);
return comment;
}
/**
* Toggle a comment's resolved flag by id.
* @returns {boolean} new resolved state, or null when the id is unknown
*/
function toggleResolved(comments, id) {
const comment = comments.find((c) => c.id === id);
if (!comment) return null;
comment.resolved = !comment.resolved;
return comment.resolved;
}
/**
* Remove a comment by id. @returns {boolean} whether anything was removed.
*/
function deleteComment(comments, id) {
const index = comments.findIndex((c) => c.id === id);
if (index === -1) return false;
comments.splice(index, 1);
return true;
}
/**
* Find the next unresolved comment for F8-style navigation (wraps around).
* "Next" is relative to the given line: the first unresolved comment on a
* line > fromLine, wrapping to the top when none exist below.
*
* @returns {object|null} the comment to jump to, or null when none are open
*/
function nextUnresolved(comments, fromLine) {
const open = comments.filter((c) => !c.resolved);
if (open.length === 0) return null;
return open.find((c) => c.line > fromLine) || open[0];
}
/**
* Drift check for one comment against the live document lines.
* @returns {'ok'|'moved'|'changed'|'missing'} anchor status
*/
function anchorStatus(comment, lines) {
if (!Array.isArray(lines) || lines.length === 0) return 'missing';
if (comment.line > lines.length) return 'missing';
const currentText = lines[comment.line - 1].trim();
if (!comment.anchorText) return 'moved';
return currentText === comment.anchorText.trim() ? 'ok' : 'changed';
}
module.exports = {
COMMENT_FOLDER,
commentsFilePathFor,
loadComments,
saveComments,
addComment,
toggleResolved,
deleteComment,
nextUnresolved,
anchorStatus,
newCommentId,
normalizeComments,
};
@@ -0,0 +1,149 @@
/**
* Collaboration plugin — Comments sidebar panel.
*
* Lists the inline comments anchored to the current document, lets the user
* add a comment at the cursor line, jump to a comment's anchor, and
* resolve/delete entries. Comment persistence flows through the injected IO
* (IPC-backed file helpers in the app; fakes in tests).
*
* @module CommentsPanel
*/
const store = require('./comment-store');
/** Escape text before it enters any innerHTML template. */
function esc(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Render the comments panel.
*
* @param {HTMLElement} container Sidebar panel content element
* @param {object} deps
* @param {object} deps.editor Plugin editor API (getCurrentFilePath,
* getCurrentLine, jumpToLine, getContent)
* @param {object} deps.io { readFile, writeFile, fileExists, ensureDirectory }
* @param {Function} deps.pathUtil injected path module
* @param {string} [deps.author='me'] Display name for new comments
*/
function renderCommentsPanel(container, deps) {
container.replaceChildren();
const { editor, io, pathUtil } = deps;
const docPath = editor.getCurrentFilePath();
const panel = document.createElement('div');
panel.className = 'ws-panel';
// --- Header ---------------------------------------------------------------
const header = document.createElement('div');
header.className = 'ws-section';
const heading = document.createElement('h3');
heading.className = 'ws-heading';
heading.textContent = docPath ? 'Inline Comments' : 'No document open';
header.appendChild(heading);
if (docPath) {
const hint = document.createElement('p');
hint.className = 'ws-muted';
hint.textContent =
'Comments are stored in .comments/ next to the file and never exported. F8 jumps to the next open comment.';
header.appendChild(hint);
}
panel.appendChild(header);
if (!docPath) {
const note = document.createElement('p');
note.className = 'ws-muted';
note.textContent = 'Open a saved document to annotate it.';
panel.appendChild(note);
container.appendChild(panel);
return;
}
// --- Composer -------------------------------------------------------------
const composer = document.createElement('div');
composer.className = 'ws-section';
const textarea = document.createElement('textarea');
textarea.rows = 2;
textarea.placeholder = 'Comment on the current line…';
const addBtn = document.createElement('button');
addBtn.className = 'ws-btn ws-btn-primary';
addBtn.textContent = 'Comment on line ' + editor.getCurrentLine();
addBtn.addEventListener('click', () => {
const text = textarea.value.trim();
if (!text) return;
const line = editor.getCurrentLine();
const anchorText = (getLines(editor)[line - 1] || '').trim();
const comments = store.loadComments(docPath, io, pathUtil);
store.addComment(comments, { line, anchorText, author: deps.author || 'me', text });
store.saveComments(docPath, comments, io, pathUtil);
renderCommentsPanel(container, deps); // re-render with fresh list
});
composer.appendChild(textarea);
composer.appendChild(addBtn);
panel.appendChild(composer);
// --- List -----------------------------------------------------------------
const comments = store.loadComments(docPath, io, pathUtil);
const lines = getLines(editor);
const list = document.createElement('div');
list.className = 'ws-issues-list';
if (comments.length === 0) {
const empty = document.createElement('p');
empty.className = 'ws-muted';
empty.textContent = 'No comments yet.';
list.appendChild(empty);
}
for (const comment of comments) {
const status = store.anchorStatus(comment, lines);
const item = document.createElement('div');
item.className = 'ws-issue-item' + (comment.resolved ? ' comment-resolved' : '');
const date = new Date(comment.createdAt).toLocaleString();
item.innerHTML = `
<div class="ws-issue-type">
<span data-role="meta">L${comment.line} · ${esc(comment.author)} · ${esc(date)}
${status !== 'ok' ? '· ' + (status === 'changed' ? 'text changed' : status === 'moved' ? 'moved' : 'gone') : ''}
</span>
</div>
<div class="ws-issue-text">${esc(comment.text)}</div>
${comment.anchorText ? `<div class="ws-muted">> ${esc(comment.anchorText)}</div>` : ''}
<div class="ws-issue-actions">
<button class="ws-btn ws-btn-sm" data-action="jump">Go to line</button>
<button class="ws-btn ws-btn-sm" data-action="resolve">${comment.resolved ? 'Reopen' : 'Resolve'}</button>
<button class="ws-btn ws-btn-sm" data-action="delete">Delete</button>
</div>`;
item.querySelector('[data-action="jump"]').addEventListener('click', () => {
editor.jumpToLine(comment.line);
});
item.querySelector('[data-action="resolve"]').addEventListener('click', () => {
const updated = store.loadComments(docPath, io, pathUtil);
store.toggleResolved(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps);
});
item.querySelector('[data-action="delete"]').addEventListener('click', () => {
const updated = store.loadComments(docPath, io, pathUtil);
store.deleteComment(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps);
});
list.appendChild(item);
}
panel.appendChild(list);
container.appendChild(panel);
}
/** Split the live document content into lines (1-based indexing helper). */
function getLines(editor) {
return String(editor.getContent() || '').split('\n');
}
module.exports = { renderCommentsPanel };
+109
View File
@@ -0,0 +1,109 @@
/**
* Collaboration built-in plugin — anchor-based inline comments.
*
* Implements the comment half of the v5 collaboration design:
* - Comments sidebar panel (add / list / jump / resolve / delete)
* - F8 navigates to the next open comment (document-level key handler)
* - Ctrl+Alt+M adds a comment at the cursor line
*
* Comments persist to `<folder>/.comments/<file>.json` via the app's IPC file
* helpers; nothing is written into the document itself, so exports and Git
* commits of the prose stay clean.
*
* @module collaboration
*/
const { PluginAPI } = require('../../../plugins/plugin-api');
const { renderCommentsPanel } = require('./comments-panel');
const store = require('./comment-store');
class CollaborationPlugin extends PluginAPI {
init(context) {
this.context = context;
// IO adapters over the allowlisted IPC file helpers; comments-panel
// and comment-store stay renderer-agnostic.
this._io = {
readFile: (p) => context.ipc.invoke('read-file', p),
writeFile: (p, c) => context.ipc.invoke('write-file', { path: p, content: c }),
fileExists: (p) => context.ipc.invoke('path-exists', p),
ensureDirectory: (p) => context.ipc.invoke('ensure-directory', p),
};
this._registerSidebar(context);
this._registerCommands(context);
this._installF8Navigation();
}
_registerSidebar(context) {
context.sidebar.registerPanel('comments', {
title: 'Comments',
icon: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>`,
render: (container) => renderCommentsPanel(container, this._panelDeps()),
});
}
_panelDeps() {
return {
editor: this.context.editor,
io: this._io,
pathUtil: require('path'),
author: 'me',
};
}
_registerCommands(context) {
context.commands.register(
'add-comment',
'Comment: Add Comment at Cursor',
() => {
// Opens the Comments panel focused on the composer at the cursor line
this._openPanel();
},
'Ctrl+Alt+M'
);
context.commands.register('next-comment', 'Comment: Next Open Comment', () => {
this._jumpToNextComment();
});
}
/**
* Document-level F8 handler (uninstalled on deactivate). Inside textareas
* and inputs F8 must keep its default behavior.
*/
_installF8Navigation() {
this._f8Handler = (event) => {
if (event.key !== 'F8') return;
const target = event.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
event.preventDefault();
this._jumpToNextComment();
};
document.addEventListener('keydown', this._f8Handler);
}
_jumpToNextComment() {
const docPath = this.context.editor.getCurrentFilePath();
if (!docPath) return;
const comments = store.loadComments(docPath, this._io, require('path'));
const fromLine = this.context.editor.getCurrentLine();
const next = store.nextUnresolved(comments, fromLine);
if (next) this.context.editor.jumpToLine(next.line);
}
_openPanel() {
this.context.events.emit('sidebar:open-panel', { panel: 'collaboration:comments' });
}
deactivate() {
if (this._f8Handler) {
document.removeEventListener('keydown', this._f8Handler);
this._f8Handler = null;
}
}
}
module.exports = { Plugin: CollaborationPlugin };
@@ -0,0 +1,15 @@
{
"id": "collaboration",
"name": "Collaboration",
"version": "1.0.0",
"description": "Anchor-based inline comments stored in .comments/ sidecar files, with an F8 next-comment navigator",
"icon": "message-square",
"extensionPoints": {
"sidebar": [{ "id": "comments", "title": "Comments", "order": 34 }],
"commands": [
{ "id": "add-comment", "label": "Comment: Add Comment at Cursor", "shortcut": "Ctrl+Alt+M" },
{ "id": "next-comment", "label": "Comment: Next Open Comment", "shortcut": "F8" }
]
},
"settings": []
}
@@ -94,7 +94,44 @@ function renderGoalsPanel(container, { engines, settings }) {
section3.appendChild(chart);
panel.appendChild(section3);
// GitHub-style activity heatmap over the same 30-day window: intensity is
// relative to the daily goal (empty / <33% / <66% / <100% / goal met).
const section4 = document.createElement('div');
section4.className = 'ws-section';
const heading4 = document.createElement('h3');
heading4.className = 'ws-heading';
heading4.textContent = 'Writing Heatmap';
section4.appendChild(heading4);
const heatmap = document.createElement('div');
heatmap.className = 'ws-heatmap';
heatmap.setAttribute('role', 'img');
heatmap.setAttribute('aria-label', 'Daily writing activity over the last 30 days');
for (const day of last30) {
const cell = document.createElement('span');
cell.className = 'ws-heatmap-cell ' + heatLevel(day.words, dailyGoal);
cell.title = `${day.date}: ${day.words} words${day.words >= dailyGoal ? ' — goal met' : ''}`;
heatmap.appendChild(cell);
}
section4.appendChild(heatmap);
panel.appendChild(section4);
container.appendChild(panel);
}
/**
* Map a day's word count to a heatmap intensity class (l0 implicit…l4).
* @param {number} words words written that day
* @param {number} goal daily goal
* @returns {string} class suffix ('' for empty days)
*/
function heatLevel(words, goal) {
if (!words || words <= 0) return '';
const ratio = words / Math.max(1, goal);
if (ratio >= 1) return 'l4';
if (ratio >= 0.66) return 'l3';
if (ratio >= 0.33) return 'l2';
return 'l1';
}
module.exports = { renderGoalsPanel };
+9
View File
@@ -58,6 +58,15 @@ class PluginContext {
getSelection: () => editor.getSelection(),
insertAtCursor: (text) => editor.insertAtCursor(text),
onContentChanged: (cb) => editor.onContentChanged(cb),
// Current tab's file path (null for untitled tabs) — plugins that store
// sidecar data (comments, snapshots, …) key off this.
getCurrentFilePath: () => (editor.getCurrentFilePath ? editor.getCurrentFilePath() : null),
// Scroll the active editor to a 1-based line and place the cursor there
jumpToLine: (line) => {
if (editor.jumpToLine) editor.jumpToLine(line);
},
// 1-based line of the primary cursor
getCurrentLine: () => (editor.getCurrentLine ? editor.getCurrentLine() : 1),
};
this.events = {