Skip to content

Commit e2da459

Browse files
committed
feat: checkbox sync fix + comprehensive test suite
- Fix critical checkbox toggle sync bug in webview preview - Implement controlled state pattern with optimistic UI - Use WorkspaceEdit by URI to avoid stale editor references - Improve scanner to skip fenced code blocks and support nested/ordered lists - Add 34 comprehensive tests (17 unit scanner, 11 unit toggle, 6 integration) - Add debug toggle command for verbose logging - Migrate console.log to centralized Logger class - Update CHANGELOG.md for v1.0.9 release
1 parent e717670 commit e2da459

14 files changed

Lines changed: 1215 additions & 49 deletions

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,49 @@ All notable changes to the "markdown-checkbox-preview" extension will be documen
44

55
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
66

7+
## [1.0.9] - 2025-10-15
8+
9+
### 🐛 **Bug Fixes**
10+
11+
- **Checkbox Toggle Sync** 🔄
12+
- Fixed critical bug where checkbox toggles in webview preview were not persisting to markdown files
13+
- Implemented controlled state pattern with optimistic UI for instant visual feedback
14+
- Fixed stale editor reference issue by using URI-based editing via WorkspaceEdit API
15+
- Added targeted checkbox synchronization to prevent visual glitches from full HTML rerenders
16+
17+
- **Scanner Improvements** 🔍
18+
- Fixed false positive checkbox detection in fenced code blocks (Mermaid diagrams, etc.)
19+
- Added support for nested checkboxes and ordered list checkboxes (`1. [ ] Task`)
20+
- Added case-insensitive checkbox detection (`[x]` and `[X]` both supported)
21+
- Improved CRLF line ending compatibility
22+
23+
### **Added**
24+
25+
- **Debug Toggle Command** 🐛
26+
- Added `checkboxPreview.toggleDebug` command to enable/disable verbose logging
27+
- Debug logs now opt-in via command, reducing console noise by default
28+
- Verbose logs written to "Markdown Checkbox Preview" output channel
29+
30+
- **Comprehensive Test Suite**
31+
- Added 17 unit tests for checkbox scanner (fenced blocks, nested items, various formats)
32+
- Added 11 unit tests for URI-based toggle function edge cases
33+
- Added 6 integration tests for end-to-end webview toggle flow
34+
- Total: 34 new tests added (144+ tests total)
35+
36+
### 🛠️ **Enhanced**
37+
38+
- **Logging Infrastructure** 📝
39+
- Migrated all `console.log()` statements to centralized Logger class
40+
- Added debug verbosity toggle for cleaner extension host console
41+
- Improved diagnostic capabilities with structured logging (INFO/WARN/ERROR/DEBUG)
42+
43+
### 🎯 **Technical**
44+
45+
- Exported `toggleCheckboxInDocumentUri()` function for testability
46+
- Added `getCurrentDocumentUri()` getter to AutoPreviewManager
47+
- Implemented `extractCheckboxStates()` helper for state verification
48+
- Enhanced fenced code block detection with state tracking
49+
750
## [1.0.8] - 2025-10-14
851

952
### 🚀 **CI/CD & Infrastructure**

media/main.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
rootElement.innerHTML = message.html;
1313
}
1414
break;
15+
case 'syncCheckboxes':
16+
// Sync checkbox states without full rerender (preserves scroll, focus, etc.)
17+
syncCheckboxStates(message.checkboxes);
18+
break;
1519
case 'updateProgress':
1620
updateProgressBar(message.completed, message.total);
1721
break;
@@ -21,6 +25,16 @@
2125
}
2226
});
2327

28+
// Sync checkbox states from the editor
29+
function syncCheckboxStates(checkboxes) {
30+
checkboxes.forEach(({ line, checked }) => {
31+
const checkbox = document.querySelector(`input.md-checkbox[data-line="${line}"]`);
32+
if (checkbox && checkbox.checked !== checked) {
33+
checkbox.checked = checked;
34+
}
35+
});
36+
}
37+
2438
// Synchronized scrolling: scroll preview to match editor line
2539
function scrollToLine(line) {
2640
// Find the element closest to the target line
@@ -60,17 +74,14 @@
6074
if (target.tagName === 'INPUT' && target.classList.contains('md-checkbox')) {
6175
const lineNumber = parseInt(target.dataset.line, 10);
6276
if (!isNaN(lineNumber)) {
77+
// Allow the checkbox to change immediately (optimistic UI)
78+
// The extension will send back a sync message to confirm the state
79+
6380
// Send toggle message to extension
6481
vscode.postMessage({
6582
type: 'toggle',
6683
line: lineNumber
6784
});
68-
69-
// Disable the checkbox temporarily to prevent rapid clicking
70-
target.disabled = true;
71-
setTimeout(() => {
72-
target.disabled = false;
73-
}, 100);
7485
}
7586
}
7687

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "markdown-checkbox-preview",
33
"displayName": "Markdown Checkbox Preview",
44
"description": "Interactive Markdown checkbox codelens actions (toggle) support, preview with real-time sync, tree view navigation, and comprehensive task management for VS Code",
5-
"version": "1.0.8",
5+
"version": "1.0.9",
66
"publisher": "GSejas",
77
"license": "MIT",
88
"icon": "media/icon.png",
@@ -65,6 +65,11 @@
6565
"title": "Toggle Auto-Preview",
6666
"icon": "$(eye)"
6767
},
68+
{
69+
"command": "checkboxPreview.toggleDebug",
70+
"title": "Toggle Debug Logging",
71+
"icon": "$(bug)"
72+
},
6873
{
6974
"command": "checkboxTree.refresh",
7075
"title": "Refresh",

playwright-report/index.html

Lines changed: 85 additions & 0 deletions
Large diffs are not rendered by default.

src/autoPreviewManager.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,21 @@ export class AutoPreviewManager {
288288
return this.currentPanel;
289289
}
290290

291+
/**
292+
* Returns the currently-registered document URI for the preview panel, if any
293+
*/
294+
public getCurrentDocumentUri(): vscode.Uri | undefined {
295+
if (!this.currentDocumentUri) {
296+
return undefined;
297+
}
298+
try {
299+
return vscode.Uri.parse(this.currentDocumentUri);
300+
} catch (err) {
301+
Logger.error('Failed to parse currentDocumentUri', err);
302+
return undefined;
303+
}
304+
}
305+
291306
/**
292307
* Shows the status bar button if a markdown file is active
293308
*

src/extension.ts

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as vscode from 'vscode';
2-
import { renderMarkdown, getTaskListCount } from './renderer';
2+
import { renderMarkdown, getTaskListCount, extractCheckboxStates } from './renderer';
33
import { CheckboxTreeDataProvider, CheckboxItem } from './checkboxTree';
44
import { CheckboxCodeLensProvider } from './providers/checkboxCodeLensProvider';
55
import { CheckboxHoverProvider } from './providers/checkboxHoverProvider';
@@ -49,6 +49,17 @@ export function activate(context: vscode.ExtensionContext) {
4949
() => autoPreviewManager.toggleAutoPreview()
5050
);
5151

52+
const toggleDebugDisposable = vscode.commands.registerCommand(
53+
'checkboxPreview.toggleDebug',
54+
() => {
55+
Logger.setVerbose(!Logger.isVerbose());
56+
Logger.show();
57+
vscode.window.showInformationMessage(
58+
`Markdown Checkbox Preview: Debug logging ${Logger.isVerbose() ? 'enabled' : 'disabled'}`
59+
);
60+
}
61+
);
62+
5263
const refreshTreeDisposable = vscode.commands.registerCommand('checkboxTree.refresh', () => {
5364
treeDataProvider.refresh();
5465
});
@@ -109,6 +120,7 @@ export function activate(context: vscode.ExtensionContext) {
109120
context.subscriptions.push(
110121
openPreviewDisposable,
111122
toggleAutoPreviewDisposable,
123+
toggleDebugDisposable,
112124
refreshTreeDisposable,
113125
toggleCheckboxDisposable,
114126
navigateToHeaderDisposable,
@@ -221,6 +233,8 @@ function openCheckboxPreview(
221233

222234
// Handle document changes with debouncing to avoid flooding webview
223235
let updateTimeout: NodeJS.Timeout | undefined;
236+
let isTogglingCheckbox = false;
237+
224238
const changeDisposable = vscode.workspace.onDidChangeTextDocument(event => {
225239
if (event.document.uri.toString() === document.uri.toString()) {
226240
Logger.debug(`Document change detected for ${document.uri.toString()} (${event.contentChanges.length} change(s))`);
@@ -230,17 +244,31 @@ function openCheckboxPreview(
230244
clearTimeout(updateTimeout);
231245
}
232246

233-
// Debounce updates by 150ms to batch rapid changes
247+
// Use shorter debounce for checkbox toggles (faster feedback)
248+
const debounceTime = isTogglingCheckbox ? 50 : 150;
249+
250+
// Debounce updates to batch rapid changes
234251
updateTimeout = setTimeout(() => {
235252
const newContent = event.document.getText();
236-
const html = renderMarkdown(newContent);
253+
254+
if (isTogglingCheckbox) {
255+
// For checkbox toggles, send only checkbox states (targeted update)
256+
const checkboxStates = extractCheckboxStates(newContent);
257+
sendMessage({
258+
type: 'syncCheckboxes',
259+
checkboxes: checkboxStates
260+
});
261+
} else {
262+
// For other changes, do full rerender
263+
const html = renderMarkdown(newContent);
264+
sendMessage({
265+
type: 'rerender',
266+
html: html
267+
});
268+
}
269+
270+
// Always update progress
237271
const stats = getTaskListCount(newContent);
238-
239-
sendMessage({
240-
type: 'rerender',
241-
html: html
242-
});
243-
244272
sendMessage({
245273
type: 'updateProgress',
246274
completed: stats.completed,
@@ -251,7 +279,10 @@ function openCheckboxPreview(
251279
if (treeDataProvider) {
252280
treeDataProvider.refresh();
253281
}
254-
}, 150);
282+
283+
// Reset checkbox toggle flag
284+
isTogglingCheckbox = false;
285+
}, debounceTime);
255286
}
256287
});
257288

@@ -282,14 +313,43 @@ function openCheckboxPreview(
282313
// Handle messages from the webview
283314
panel.webview.onDidReceiveMessage(message => {
284315
Logger.debug(`Received webview message: ${message.type}`);
316+
317+
// Prefer editing the document currently tracked by the preview manager
318+
const previewUri = autoPreviewManager?.getCurrentDocumentUri();
319+
285320
switch (message.type) {
286321
case 'toggle':
287322
Logger.debug(`Toggling checkbox at line ${message.line}`);
288-
toggleCheckboxAtLine(editor, message.line);
323+
isTogglingCheckbox = true;
324+
if (previewUri) {
325+
toggleCheckboxInDocumentUri(previewUri, message.line);
326+
} else {
327+
// Fallback to active editor
328+
const currentEditor = vscode.window.activeTextEditor;
329+
if (currentEditor) {
330+
toggleCheckboxAtLine(currentEditor, message.line);
331+
} else {
332+
Logger.error('No document available to toggle checkbox');
333+
}
334+
}
289335
break;
290336
case 'navigate':
291337
Logger.debug(`Navigating to line ${message.line}`);
292-
navigateToLine(editor, message.line);
338+
// Prefer to navigate in the previewed document if available
339+
if (previewUri) {
340+
const doc = vscode.workspace.textDocuments.find(d => d.uri.toString() === previewUri.toString());
341+
const editor = vscode.window.visibleTextEditors.find(e => e.document === doc);
342+
if (editor) {
343+
navigateToLine(editor, message.line);
344+
} else if (doc) {
345+
vscode.window.showTextDocument(doc).then(ed => navigateToLine(ed, message.line));
346+
}
347+
} else {
348+
const currentEditor = vscode.window.activeTextEditor;
349+
if (currentEditor) {
350+
navigateToLine(currentEditor, message.line);
351+
}
352+
}
293353
break;
294354
}
295355
});
@@ -320,6 +380,8 @@ function openCheckboxPreview(
320380
function toggleCheckboxAtLine(editor: vscode.TextEditor, lineNumber: number) {
321381
const document = editor.document;
322382

383+
Logger.info(`Toggle checkbox requested: line=${lineNumber}, documentUri=${document.uri.toString()}`);
384+
323385
if (lineNumber >= document.lineCount) {
324386
Logger.warn(`Toggle requested for line ${lineNumber} beyond document length ${document.lineCount}`);
325387
return;
@@ -328,30 +390,39 @@ function toggleCheckboxAtLine(editor: vscode.TextEditor, lineNumber: number) {
328390
const line = document.lineAt(lineNumber);
329391
const lineText = line.text;
330392

393+
Logger.debug(`Current line text: "${lineText}"`);
394+
331395
// Match different checkbox patterns
332396
let updatedText = lineText;
333397

334398
// Handle [ ] -> [x]
335399
if (lineText.includes('[ ]')) {
336400
updatedText = lineText.replace(/\[ \]/, '[x]');
401+
Logger.debug(`Toggling unchecked -> checked`);
337402
}
338403
// Handle [x] -> [ ] (both lowercase and uppercase)
339404
else if (lineText.match(/\[[xX]\]/)) {
340405
updatedText = lineText.replace(/\[[xX]\]/, '[ ]');
406+
Logger.debug(`Toggling checked -> unchecked`);
407+
} else {
408+
Logger.warn(`No checkbox pattern found in line ${lineNumber}: "${lineText}"`);
341409
}
342410

343411
// Apply the edit if there was a change
344412
if (updatedText !== lineText) {
413+
Logger.info(`Applying edit to line ${lineNumber}: "${lineText}" -> "${updatedText}"`);
345414
editor.edit(editBuilder => {
346415
editBuilder.replace(line.range, updatedText);
347416
}).then(success => {
348417
if (!success) {
349418
vscode.window.showErrorMessage('Failed to update checkbox state');
350419
Logger.error(`Failed to apply checkbox toggle at line ${lineNumber}`);
351420
} else {
352-
Logger.debug(`Checkbox toggled at line ${lineNumber}`);
421+
Logger.info(`✅ Checkbox successfully toggled at line ${lineNumber}`);
353422
}
354423
});
424+
} else {
425+
Logger.warn(`No change needed for line ${lineNumber}`);
355426
}
356427
}
357428

@@ -715,6 +786,45 @@ function getWebviewContent(webview: vscode.Webview, context: vscode.ExtensionCon
715786
</html>`;
716787
}
717788

789+
/**
790+
* Toggle a checkbox in a document by URI using WorkspaceEdit
791+
* Exported for testing purposes
792+
*
793+
* @param uri Document URI
794+
* @param lineNumber Zero-based line number
795+
*/
796+
export async function toggleCheckboxInDocumentUri(uri: vscode.Uri, lineNumber: number): Promise<boolean> {
797+
try {
798+
const doc = await vscode.workspace.openTextDocument(uri);
799+
const line = doc.lineAt(lineNumber);
800+
const lineText = line.text;
801+
802+
let updatedText = lineText;
803+
if (/\[ \]/.test(lineText)) {
804+
updatedText = lineText.replace(/\[ \]/, '[x]');
805+
} else if (/\[[xX]\]/.test(lineText)) {
806+
updatedText = lineText.replace(/\[[xX]\]/, '[ ]');
807+
} else {
808+
Logger.warn(`No checkbox found at line ${lineNumber} in ${uri.toString()}`);
809+
return false;
810+
}
811+
812+
const edit = new vscode.WorkspaceEdit();
813+
edit.replace(uri, line.range, updatedText);
814+
const success = await vscode.workspace.applyEdit(edit);
815+
if (!success) {
816+
Logger.error(`WorkspaceEdit failed for ${uri.toString()} at line ${lineNumber}`);
817+
return false;
818+
} else {
819+
Logger.info(`Checkbox toggled in document ${uri.toString()} at line ${lineNumber}`);
820+
return true;
821+
}
822+
} catch (err) {
823+
Logger.error('Failed to toggle checkbox by URI', err);
824+
return false;
825+
}
826+
}
827+
718828
export function deactivate() {
719829
// Cleanup when extension is deactivated
720830
}

0 commit comments

Comments
 (0)