mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
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:
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user