mrmd-editor 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/commands.js +112 -4
- package/src/comment-syntax.js +364 -39
- package/src/config/handlers.js +1 -2
- package/src/config/schema.js +46 -4
- package/src/document-template.js +2236 -0
- package/src/frontmatter-updater.js +204 -74
- package/src/grammar.js +758 -0
- package/src/index.js +1074 -55
- package/src/keymap.js +11 -2
- package/src/markdown/block-decorations.js +108 -5
- package/src/markdown/facets.js +37 -0
- package/src/markdown/html-inline.js +9 -5
- package/src/markdown/index.js +13 -3
- package/src/markdown/inline-commands.js +256 -0
- package/src/markdown/inline-model.js +578 -0
- package/src/markdown/inline-state.js +103 -0
- package/src/markdown/renderer.js +219 -12
- package/src/markdown/styles.js +290 -3
- package/src/markdown/widgets/alert-title.js +10 -8
- package/src/markdown/widgets/frontmatter.js +0 -6
- package/src/markdown/widgets/index.js +1 -0
- package/src/markdown/widgets/list-marker.js +29 -0
- package/src/markdown/wysiwyg.js +1158 -0
- package/src/mrp-types.js +2 -0
- package/src/output-widget.js +532 -18
- package/src/page-view-pagination.js +127 -0
- package/src/runtime-lsp.js +1757 -150
- package/src/section-controls/commands.js +617 -0
- package/src/section-controls/index.js +63 -0
- package/src/section-controls/plugin.js +165 -0
- package/src/section-controls/widgets.js +936 -0
- package/src/shell/ai-menu.js +11 -0
- package/src/shell/components/context-panel.js +572 -0
- package/src/shell/components/status-bar.js +10 -2
- package/src/shell/layouts/studio.js +206 -14
- package/src/shell/orchestrator-client.js +69 -0
- package/src/spellcheck.js +166 -0
- package/src/tables/README.md +97 -0
- package/src/tables/commands/insert-linked-table.js +122 -0
- package/src/tables/commands/open-table-workspace.js +43 -0
- package/src/tables/index.js +24 -0
- package/src/tables/jobs/client.js +158 -0
- package/src/tables/parsing/anchors.js +82 -0
- package/src/tables/parsing/linked-table-blocks.js +61 -0
- package/src/tables/state/linked-table-state.js +68 -0
- package/src/tables/widgets/linked-table-source-banner.js +77 -0
- package/src/tables/widgets/linked-table-widget.js +256 -0
- package/src/tables/workspace/controller.js +616 -0
- package/src/term-pty-client.js +51 -2
- package/src/term-widget.js +43 -3
- package/src/widgets/theme-utils.js +24 -16
- package/src/widgets/theme.js +1015 -1
- package/src/runtime-codelens/detector.js +0 -279
- package/src/runtime-codelens/index.js +0 -76
- package/src/runtime-codelens/plugin.js +0 -142
- package/src/runtime-codelens/styles.js +0 -184
- package/src/runtime-codelens/widgets.js +0 -216
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
import { OrchestratorClient } from '../orchestrator-client.js';
|
|
15
15
|
import { ShellStateManager } from '../state.js';
|
|
16
16
|
import { createStatusBar } from '../components/status-bar.js';
|
|
17
|
+
import { createContextPanel } from '../components/context-panel.js';
|
|
17
18
|
import { injectShellStyles } from '../styles.js';
|
|
18
19
|
import { showFilePicker, showFolderPicker } from '../dialogs/file-picker.js';
|
|
19
20
|
import { prompt, confirm } from '../dialogs/base-dialog.js';
|
|
20
21
|
import { Drive } from '../drive.js';
|
|
21
22
|
import { AiClient } from '../ai-client.js';
|
|
22
23
|
import { showAiMenu, AI_COMMANDS, injectAiMenuStyles } from '../ai-menu.js';
|
|
24
|
+
import { canImportLinkedTableFromHost } from '../../tables/index.js';
|
|
23
25
|
|
|
24
26
|
// =============================================================================
|
|
25
27
|
// STUDIO
|
|
@@ -92,6 +94,16 @@ export async function createStudio(target, options = {}) {
|
|
|
92
94
|
overflow: hidden;
|
|
93
95
|
`;
|
|
94
96
|
|
|
97
|
+
// Create main row container (editor + context rail)
|
|
98
|
+
const mainContainer = document.createElement('div');
|
|
99
|
+
mainContainer.className = 'mrmd-studio__main';
|
|
100
|
+
mainContainer.style.cssText = `
|
|
101
|
+
display: flex;
|
|
102
|
+
flex: 1;
|
|
103
|
+
min-height: 0;
|
|
104
|
+
overflow: hidden;
|
|
105
|
+
`;
|
|
106
|
+
|
|
95
107
|
// Create editor container
|
|
96
108
|
const editorContainer = document.createElement('div');
|
|
97
109
|
editorContainer.className = 'mrmd-studio__editor';
|
|
@@ -99,8 +111,19 @@ export async function createStudio(target, options = {}) {
|
|
|
99
111
|
flex: 1;
|
|
100
112
|
overflow: hidden;
|
|
101
113
|
position: relative;
|
|
114
|
+
min-width: 0;
|
|
102
115
|
`;
|
|
103
116
|
|
|
117
|
+
const contextPanelContainer = document.createElement('div');
|
|
118
|
+
contextPanelContainer.className = 'mrmd-studio__context';
|
|
119
|
+
contextPanelContainer.style.cssText = `
|
|
120
|
+
display: flex;
|
|
121
|
+
min-height: 0;
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
mainContainer.appendChild(editorContainer);
|
|
125
|
+
mainContainer.appendChild(contextPanelContainer);
|
|
126
|
+
|
|
104
127
|
// Create status bar container
|
|
105
128
|
const statusBarContainer = document.createElement('div');
|
|
106
129
|
statusBarContainer.className = 'mrmd-studio__statusbar';
|
|
@@ -108,9 +131,9 @@ export async function createStudio(target, options = {}) {
|
|
|
108
131
|
// Assemble layout
|
|
109
132
|
if (statusBarConfig.position === 'top') {
|
|
110
133
|
studioEl.appendChild(statusBarContainer);
|
|
111
|
-
studioEl.appendChild(
|
|
134
|
+
studioEl.appendChild(mainContainer);
|
|
112
135
|
} else {
|
|
113
|
-
studioEl.appendChild(
|
|
136
|
+
studioEl.appendChild(mainContainer);
|
|
114
137
|
studioEl.appendChild(statusBarContainer);
|
|
115
138
|
}
|
|
116
139
|
|
|
@@ -142,6 +165,16 @@ export async function createStudio(target, options = {}) {
|
|
|
142
165
|
return () => eventHandlers.get(event).delete(handler);
|
|
143
166
|
}
|
|
144
167
|
|
|
168
|
+
function getCurrentDocumentMarkdownPath(docName = currentDocName) {
|
|
169
|
+
if (!docName) return null;
|
|
170
|
+
if (docName.startsWith('/')) return null;
|
|
171
|
+
return docName.endsWith('.md') ? docName : `${docName}.md`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function supportsLinkedTableImport() {
|
|
175
|
+
return canImportLinkedTableFromHost();
|
|
176
|
+
}
|
|
177
|
+
|
|
145
178
|
/**
|
|
146
179
|
* Detect if cursor is inside a code block and return block info
|
|
147
180
|
* @param {EditorView} view
|
|
@@ -266,6 +299,7 @@ export async function createStudio(target, options = {}) {
|
|
|
266
299
|
// Track current editor instance and preserved state
|
|
267
300
|
let editor = null;
|
|
268
301
|
let currentDocName = null;
|
|
302
|
+
let contextPanelComponent = null;
|
|
269
303
|
let preservedEditorState = {
|
|
270
304
|
theme: editorOptions.theme || null,
|
|
271
305
|
dark: editorOptions.dark ?? null,
|
|
@@ -288,6 +322,9 @@ export async function createStudio(target, options = {}) {
|
|
|
288
322
|
// Preserve theme across switches
|
|
289
323
|
theme: preservedEditorState.theme,
|
|
290
324
|
dark: preservedEditorState.dark,
|
|
325
|
+
// Linked-table host context
|
|
326
|
+
projectRoot: shellState.get('projectRoot') || editorOptions.projectRoot || null,
|
|
327
|
+
documentPath: getCurrentDocumentMarkdownPath(docName) || editorOptions.documentPath || null,
|
|
291
328
|
};
|
|
292
329
|
|
|
293
330
|
// Remove any sync options since we're providing ydoc directly
|
|
@@ -376,14 +413,17 @@ export async function createStudio(target, options = {}) {
|
|
|
376
413
|
});
|
|
377
414
|
}
|
|
378
415
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
juiceLevel: shellState.get('ai')?.juiceLevel || 0,
|
|
384
|
-
});
|
|
416
|
+
// Configure built-in comment syntax with the AI client.
|
|
417
|
+
// The base editor already installs the extension, so only append
|
|
418
|
+
// updated facet config here.
|
|
419
|
+
if (mrmd.default.commentSyntax?.commentConfigFacet) {
|
|
385
420
|
newEditor.view.dispatch({
|
|
386
|
-
effects: mrmd.codemirror.StateEffect.appendConfig.of(
|
|
421
|
+
effects: mrmd.codemirror.StateEffect.appendConfig.of(
|
|
422
|
+
mrmd.default.commentSyntax.commentConfigFacet.of({
|
|
423
|
+
aiClient,
|
|
424
|
+
juiceLevel: shellState.get('ai')?.juiceLevel || 0,
|
|
425
|
+
})
|
|
426
|
+
),
|
|
387
427
|
});
|
|
388
428
|
}
|
|
389
429
|
}
|
|
@@ -398,15 +438,47 @@ export async function createStudio(target, options = {}) {
|
|
|
398
438
|
* @param {Object|null} codeBlock - Code block info if cursor is in a code block
|
|
399
439
|
*/
|
|
400
440
|
async function executeAiCommand(cmd, targetEditor, codeBlock = null) {
|
|
401
|
-
if (!
|
|
441
|
+
if (!mrmd.default.ai) return;
|
|
402
442
|
|
|
403
443
|
const view = targetEditor.view;
|
|
444
|
+
|
|
445
|
+
if (cmd.action === 'insert-frontmatter-template') {
|
|
446
|
+
const handled = targetEditor.commands?.insertFrontmatterTemplate?.() || false;
|
|
447
|
+
if (handled) {
|
|
448
|
+
emit('aiCommandExecuted', { command: cmd.id, action: cmd.action });
|
|
449
|
+
} else {
|
|
450
|
+
emit('aiCommandError', { command: cmd.id, error: 'Could not insert frontmatter template' });
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!aiClient) return;
|
|
456
|
+
|
|
404
457
|
const context = mrmd.default.ai.getAiContext(view);
|
|
405
458
|
const juiceLevel = shellState.get('ai')?.juiceLevel || 0;
|
|
406
459
|
|
|
407
460
|
// Detect language from code block, or fall back to python
|
|
408
461
|
const detectedLanguage = codeBlock?.language || 'python';
|
|
409
462
|
|
|
463
|
+
// Resolve richer context from _assets/context/*.md when available
|
|
464
|
+
let resolvedContextText = context.documentContext;
|
|
465
|
+
try {
|
|
466
|
+
if (currentDocName) {
|
|
467
|
+
const resolved = await orchestratorClient.resolveContext({
|
|
468
|
+
doc: currentDocName,
|
|
469
|
+
content: context.documentContext,
|
|
470
|
+
cursorPos: context.cursorPos,
|
|
471
|
+
selection: { from: context.selectionFrom, to: context.selectionTo },
|
|
472
|
+
ensureExists: true,
|
|
473
|
+
});
|
|
474
|
+
if (resolved?.contextText) {
|
|
475
|
+
resolvedContextText = resolved.contextText;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
console.warn('[Studio] Failed to resolve AI context, falling back to document only:', error);
|
|
480
|
+
}
|
|
481
|
+
|
|
410
482
|
// Mark AI as active
|
|
411
483
|
shellState._set('ai.active', true);
|
|
412
484
|
|
|
@@ -419,20 +491,20 @@ export async function createStudio(target, options = {}) {
|
|
|
419
491
|
params = {
|
|
420
492
|
text_before_cursor: context.textBeforeCursor,
|
|
421
493
|
local_context: context.localContext,
|
|
422
|
-
document_context:
|
|
494
|
+
document_context: resolvedContextText,
|
|
423
495
|
};
|
|
424
496
|
} else if (cmd.program.includes('Fix') || cmd.program.includes('Correct')) {
|
|
425
497
|
params = {
|
|
426
498
|
text_to_fix: context.selectedText,
|
|
427
499
|
local_context: context.localContext,
|
|
428
|
-
document_context:
|
|
500
|
+
document_context: resolvedContextText,
|
|
429
501
|
};
|
|
430
502
|
} else if (cmd.program.includes('Code')) {
|
|
431
503
|
params = {
|
|
432
504
|
code: context.selectedText,
|
|
433
505
|
language: detectedLanguage,
|
|
434
506
|
local_context: context.localContext,
|
|
435
|
-
document_context:
|
|
507
|
+
document_context: resolvedContextText,
|
|
436
508
|
};
|
|
437
509
|
} else if (cmd.program.includes('Synonym')) {
|
|
438
510
|
params = {
|
|
@@ -440,7 +512,7 @@ export async function createStudio(target, options = {}) {
|
|
|
440
512
|
local_context: context.localContext,
|
|
441
513
|
};
|
|
442
514
|
} else if (cmd.program.includes('Document')) {
|
|
443
|
-
params = { document:
|
|
515
|
+
params = { document: resolvedContextText };
|
|
444
516
|
}
|
|
445
517
|
|
|
446
518
|
await mrmd.default.ai.executeAiOperation(view, aiClient, {
|
|
@@ -544,12 +616,24 @@ export async function createStudio(target, options = {}) {
|
|
|
544
616
|
path: normalizedName.endsWith('.md') ? normalizedName : `${normalizedName}.md`,
|
|
545
617
|
root: filesResult.root,
|
|
546
618
|
});
|
|
619
|
+
editor?.setLinkedTableHostContext?.({
|
|
620
|
+
projectRoot: shellState.get('projectRoot') || null,
|
|
621
|
+
documentPath: normalizedName.endsWith('.md') ? normalizedName : `${normalizedName}.md`,
|
|
622
|
+
});
|
|
547
623
|
|
|
548
624
|
// Update status bar with new editor
|
|
549
625
|
if (statusBarComponent) {
|
|
550
626
|
statusBarComponent.setEditor(editor);
|
|
551
627
|
}
|
|
552
628
|
|
|
629
|
+
if (contextPanelComponent) {
|
|
630
|
+
contextPanelComponent.setEditor(editor);
|
|
631
|
+
contextPanelComponent.setDocument(normalizedName);
|
|
632
|
+
contextPanelComponent.refresh().catch((e) => {
|
|
633
|
+
console.warn('[Studio] Failed to refresh context panel:', e);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
553
637
|
emit('fileOpened', { doc: normalizedName });
|
|
554
638
|
|
|
555
639
|
} catch (e) {
|
|
@@ -593,6 +677,8 @@ export async function createStudio(target, options = {}) {
|
|
|
593
677
|
|
|
594
678
|
// Shell action handlers
|
|
595
679
|
const handlers = {
|
|
680
|
+
supportsLinkedTableImport,
|
|
681
|
+
|
|
596
682
|
async onRename() {
|
|
597
683
|
const file = shellState.get('file');
|
|
598
684
|
if (!file) return;
|
|
@@ -670,6 +756,65 @@ export async function createStudio(target, options = {}) {
|
|
|
670
756
|
});
|
|
671
757
|
},
|
|
672
758
|
|
|
759
|
+
async onImportLinkedTable() {
|
|
760
|
+
const file = shellState.get('file');
|
|
761
|
+
const projectRoot = shellState.get('projectRoot');
|
|
762
|
+
|
|
763
|
+
if (!supportsLinkedTableImport()) {
|
|
764
|
+
await confirm({
|
|
765
|
+
title: 'Linked table import unavailable',
|
|
766
|
+
message: 'This build does not expose the Electron linked-table host API yet.',
|
|
767
|
+
confirmLabel: 'OK',
|
|
768
|
+
cancelLabel: '',
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!file || !editor) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (!projectRoot || file.isOutsideProject || !file.path || file.path.startsWith('/')) {
|
|
778
|
+
await confirm({
|
|
779
|
+
title: 'Linked table import requires a project file',
|
|
780
|
+
message: 'Open a markdown document inside a project before importing a linked table.',
|
|
781
|
+
confirmLabel: 'OK',
|
|
782
|
+
cancelLabel: '',
|
|
783
|
+
});
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
showFilePicker({
|
|
788
|
+
mode: 'open',
|
|
789
|
+
title: 'Import Linked Table',
|
|
790
|
+
orchestratorClient,
|
|
791
|
+
initialPath: projectRoot || '~',
|
|
792
|
+
allowOutsideProject: true,
|
|
793
|
+
onSelect: async (sourceFilePath) => {
|
|
794
|
+
try {
|
|
795
|
+
const result = await editor.importLinkedTableFromHost(sourceFilePath, {
|
|
796
|
+
projectRoot,
|
|
797
|
+
documentPath: file.path,
|
|
798
|
+
cacheFormat: 'csv',
|
|
799
|
+
});
|
|
800
|
+
emit('linkedTableImported', {
|
|
801
|
+
sourceFilePath,
|
|
802
|
+
tableId: result.tableId,
|
|
803
|
+
spec: result.spec,
|
|
804
|
+
});
|
|
805
|
+
} catch (error) {
|
|
806
|
+
console.error('[Studio] Linked table import failed:', error);
|
|
807
|
+
await confirm({
|
|
808
|
+
title: 'Linked table import failed',
|
|
809
|
+
message: error.message || String(error),
|
|
810
|
+
confirmLabel: 'OK',
|
|
811
|
+
cancelLabel: '',
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
},
|
|
817
|
+
|
|
673
818
|
// NOTE: onChangeVenv, onChangeCwd, and onRestartRuntime are no longer used.
|
|
674
819
|
// The new runtime model doesn't support changing venv/cwd on running runtimes.
|
|
675
820
|
// Instead, users start new runtimes via the status bar menu and attach docs to them.
|
|
@@ -896,10 +1041,33 @@ export async function createStudio(target, options = {}) {
|
|
|
896
1041
|
path: currentDocName.endsWith('.md') ? currentDocName : `${currentDocName}.md`,
|
|
897
1042
|
root: result.root,
|
|
898
1043
|
});
|
|
1044
|
+
editor?.setLinkedTableHostContext?.({
|
|
1045
|
+
projectRoot: shellState.get('projectRoot') || null,
|
|
1046
|
+
documentPath: currentDocName.endsWith('.md') ? currentDocName : `${currentDocName}.md`,
|
|
1047
|
+
});
|
|
899
1048
|
} catch (e) {
|
|
900
1049
|
console.warn('Could not set initial file context:', e);
|
|
901
1050
|
}
|
|
902
1051
|
|
|
1052
|
+
// Create context panel
|
|
1053
|
+
contextPanelComponent = createContextPanel({
|
|
1054
|
+
container: contextPanelContainer,
|
|
1055
|
+
orchestratorClient,
|
|
1056
|
+
shellState,
|
|
1057
|
+
getCurrentDocument: () => currentDocName,
|
|
1058
|
+
getEditor: () => editor,
|
|
1059
|
+
getAiContext: mrmd.default.ai?.getAiContext,
|
|
1060
|
+
onOpenRaw: async (contextPath) => {
|
|
1061
|
+
const rawDoc = contextPath.replace(/\.md$/, '');
|
|
1062
|
+
await switchDocument(rawDoc);
|
|
1063
|
+
},
|
|
1064
|
+
});
|
|
1065
|
+
contextPanelComponent.setDocument(currentDocName);
|
|
1066
|
+
contextPanelComponent.setEditor(editor);
|
|
1067
|
+
contextPanelComponent.refresh().catch((e) => {
|
|
1068
|
+
console.warn('[Studio] Failed to initialize context panel:', e);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
903
1071
|
// Create studio object
|
|
904
1072
|
const studio = {
|
|
905
1073
|
/** Current editor instance (may change on file switch) */
|
|
@@ -938,6 +1106,29 @@ export async function createStudio(target, options = {}) {
|
|
|
938
1106
|
await switchDocument(docName);
|
|
939
1107
|
},
|
|
940
1108
|
|
|
1109
|
+
/**
|
|
1110
|
+
* Import a linked table into the current document.
|
|
1111
|
+
* If no source path is provided, opens the file picker flow.
|
|
1112
|
+
*
|
|
1113
|
+
* @param {string} [sourceFilePath]
|
|
1114
|
+
*/
|
|
1115
|
+
async importLinkedTable(sourceFilePath) {
|
|
1116
|
+
if (sourceFilePath) {
|
|
1117
|
+
const file = shellState.get('file');
|
|
1118
|
+
const projectRoot = shellState.get('projectRoot');
|
|
1119
|
+
if (!editor || !file?.path || !projectRoot) {
|
|
1120
|
+
throw new Error('Linked table import requires an open project document');
|
|
1121
|
+
}
|
|
1122
|
+
return editor.importLinkedTableFromHost(sourceFilePath, {
|
|
1123
|
+
projectRoot,
|
|
1124
|
+
documentPath: file.path,
|
|
1125
|
+
cacheFormat: 'csv',
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return handlers.onImportLinkedTable();
|
|
1130
|
+
},
|
|
1131
|
+
|
|
941
1132
|
/**
|
|
942
1133
|
* Save current file to a new location
|
|
943
1134
|
* @param {string} targetPath - Target path
|
|
@@ -1018,6 +1209,7 @@ export async function createStudio(target, options = {}) {
|
|
|
1018
1209
|
|
|
1019
1210
|
// Destroy status bar
|
|
1020
1211
|
statusBarComponent?.destroy();
|
|
1212
|
+
contextPanelComponent?.destroy();
|
|
1021
1213
|
|
|
1022
1214
|
// Destroy current editor
|
|
1023
1215
|
if (editor) {
|
|
@@ -416,6 +416,75 @@ export class OrchestratorClient {
|
|
|
416
416
|
return this.listRuntimeAttachments();
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
// ===========================================================================
|
|
420
|
+
// Context Management
|
|
421
|
+
// ===========================================================================
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Resolve markdown-managed AI context for a document.
|
|
425
|
+
* @param {Object} request
|
|
426
|
+
* @param {string} request.doc
|
|
427
|
+
* @param {string} [request.content]
|
|
428
|
+
* @param {number} [request.cursorPos]
|
|
429
|
+
* @param {{from: number, to: number}} [request.selection]
|
|
430
|
+
* @param {string[]} [request.codeSymbols]
|
|
431
|
+
* @param {boolean} [request.ensureExists=false]
|
|
432
|
+
*/
|
|
433
|
+
async resolveContext(request) {
|
|
434
|
+
return this._fetch('/api/context/resolve', {
|
|
435
|
+
method: 'POST',
|
|
436
|
+
body: JSON.stringify(request),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Get context markdown for a document.
|
|
442
|
+
* @param {string} doc
|
|
443
|
+
*/
|
|
444
|
+
async getContext(doc) {
|
|
445
|
+
return this._fetch(`/api/context/${encodeURIComponent(doc)}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Save context markdown for a document.
|
|
450
|
+
* @param {string} doc
|
|
451
|
+
* @param {string} content
|
|
452
|
+
*/
|
|
453
|
+
async saveContext(doc, content) {
|
|
454
|
+
return this._fetch(`/api/context/${encodeURIComponent(doc)}`, {
|
|
455
|
+
method: 'PUT',
|
|
456
|
+
body: JSON.stringify({ content }),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Initialize context markdown for a document if missing.
|
|
462
|
+
* @param {string} doc
|
|
463
|
+
*/
|
|
464
|
+
async initContext(doc) {
|
|
465
|
+
return this._fetch(`/api/context/init/${encodeURIComponent(doc)}`, {
|
|
466
|
+
method: 'POST',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Get project default context markdown.
|
|
472
|
+
*/
|
|
473
|
+
async getDefaultContext() {
|
|
474
|
+
return this._fetch('/api/context');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Save project default context markdown.
|
|
479
|
+
* @param {string} content
|
|
480
|
+
*/
|
|
481
|
+
async saveDefaultContext(content) {
|
|
482
|
+
return this._fetch('/api/context', {
|
|
483
|
+
method: 'PUT',
|
|
484
|
+
body: JSON.stringify({ content }),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
419
488
|
// ===========================================================================
|
|
420
489
|
// Project & Runtime Management
|
|
421
490
|
// ===========================================================================
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Spellcheck / autocorrect for prose in CodeMirror 6
|
|
3
|
+
*
|
|
4
|
+
* Enables the browser's native spellcheck on the CM6 content element, then
|
|
5
|
+
* disables it in places that are usually not natural-language prose:
|
|
6
|
+
* - fenced code blocks
|
|
7
|
+
* - inline code
|
|
8
|
+
* - markdown link URLs
|
|
9
|
+
* - quoted literals like 'foo/bar'
|
|
10
|
+
* - path-like tokens such as ./src/app, /usr/bin, src/utils/file.py
|
|
11
|
+
*
|
|
12
|
+
* Important note about "autocorrect":
|
|
13
|
+
* On desktop Chromium/Electron, what you mostly get is native spellcheck
|
|
14
|
+
* (underlines + suggestions), not aggressive iOS-style auto-replacement.
|
|
15
|
+
* The `autocorrect` attribute is mainly useful in Safari/iOS and is mostly
|
|
16
|
+
* ignored by Chromium. So in Electron this feature is best thought of as
|
|
17
|
+
* fast spellcheck with suggestions, not full mobile-style autocorrect.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { EditorView, Decoration, ViewPlugin } from '@codemirror/view';
|
|
21
|
+
import { syntaxTree } from '@codemirror/language';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Enable browser-native spellcheck on the editor's contenteditable element.
|
|
25
|
+
*/
|
|
26
|
+
const proseSpellcheck = EditorView.contentAttributes.of({
|
|
27
|
+
spellcheck: 'true',
|
|
28
|
+
autocorrect: 'on', // Safari / iOS; harmless elsewhere
|
|
29
|
+
autocapitalize: 'sentences', // mobile keyboards
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reused mark decoration for ranges where spellcheck should be disabled.
|
|
34
|
+
*/
|
|
35
|
+
const noSpellcheckMark = Decoration.mark({
|
|
36
|
+
attributes: { spellcheck: 'false' },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Markdown syntax nodes where spellcheck should be disabled.
|
|
41
|
+
*
|
|
42
|
+
* Confirmed CM6 markdown node names:
|
|
43
|
+
* - FencedCode
|
|
44
|
+
* - InlineCode
|
|
45
|
+
* - URL
|
|
46
|
+
*/
|
|
47
|
+
const SUPPRESSED_NODE_NAMES = new Set([
|
|
48
|
+
'FencedCode',
|
|
49
|
+
'InlineCode',
|
|
50
|
+
'URL',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Regexes for non-prose tokens that often appear in markdown paragraphs but
|
|
55
|
+
* should not be spellchecked.
|
|
56
|
+
*
|
|
57
|
+
* These are intentionally conservative:
|
|
58
|
+
* - quoted literals: 'foo/bar', "snake_case"
|
|
59
|
+
* - unix/relative paths: ./src/app, /usr/bin, ~/work/project
|
|
60
|
+
* - slash-delimited paths/modules: src/widgets/theme.js
|
|
61
|
+
*/
|
|
62
|
+
const NON_PROSE_PATTERNS = [
|
|
63
|
+
/'[^'\n]+'/g,
|
|
64
|
+
/"[^"\n]+"/g,
|
|
65
|
+
/(?:\.{1,2}\/|~\/|\/)[^\s'"`<>]+/g,
|
|
66
|
+
/(?:[A-Za-z0-9._-]+\/){1,}[A-Za-z0-9._-]+\/?/g,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function addRegexRanges(docText, baseFrom, ranges) {
|
|
70
|
+
for (const pattern of NON_PROSE_PATTERNS) {
|
|
71
|
+
pattern.lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = pattern.exec(docText))) {
|
|
74
|
+
const from = baseFrom + match.index;
|
|
75
|
+
const to = from + match[0].length;
|
|
76
|
+
if (to > from) ranges.push({ from, to });
|
|
77
|
+
|
|
78
|
+
// Safety against zero-length regex matches
|
|
79
|
+
if (match[0].length === 0) {
|
|
80
|
+
pattern.lastIndex += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mergeRanges(ranges) {
|
|
87
|
+
if (ranges.length <= 1) return ranges;
|
|
88
|
+
|
|
89
|
+
const sorted = ranges
|
|
90
|
+
.filter((r) => r && r.to > r.from)
|
|
91
|
+
.sort((a, b) => (a.from - b.from) || (a.to - b.to));
|
|
92
|
+
|
|
93
|
+
if (sorted.length === 0) return [];
|
|
94
|
+
|
|
95
|
+
const merged = [sorted[0]];
|
|
96
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
97
|
+
const current = sorted[i];
|
|
98
|
+
const last = merged[merged.length - 1];
|
|
99
|
+
|
|
100
|
+
if (current.from <= last.to) {
|
|
101
|
+
last.to = Math.max(last.to, current.to);
|
|
102
|
+
} else {
|
|
103
|
+
merged.push({ ...current });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* ViewPlugin that disables spellcheck in non-prose regions.
|
|
112
|
+
*
|
|
113
|
+
* Performance:
|
|
114
|
+
* We only inspect visible ranges instead of the whole document. That keeps
|
|
115
|
+
* the work small even on long notes and reduces the chance that spellcheck
|
|
116
|
+
* feels laggy.
|
|
117
|
+
*/
|
|
118
|
+
const noSpellcheckInNonProse = ViewPlugin.fromClass(
|
|
119
|
+
class {
|
|
120
|
+
constructor(view) {
|
|
121
|
+
this.decorations = this.build(view);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
update(update) {
|
|
125
|
+
if (update.docChanged || update.viewportChanged) {
|
|
126
|
+
this.decorations = this.build(update.view);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
build(view) {
|
|
131
|
+
const collected = [];
|
|
132
|
+
const tree = syntaxTree(view.state);
|
|
133
|
+
|
|
134
|
+
for (const { from, to } of view.visibleRanges) {
|
|
135
|
+
tree.iterate({
|
|
136
|
+
from,
|
|
137
|
+
to,
|
|
138
|
+
enter(node) {
|
|
139
|
+
if (SUPPRESSED_NODE_NAMES.has(node.name) && node.from < node.to) {
|
|
140
|
+
collected.push({ from: node.from, to: node.to });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const text = view.state.doc.sliceString(from, to);
|
|
146
|
+
addRegexRanges(text, from, collected);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const merged = mergeRanges(collected);
|
|
150
|
+
return Decoration.set(
|
|
151
|
+
merged.map((r) => noSpellcheckMark.range(r.from, r.to)),
|
|
152
|
+
true,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
{ decorations: (v) => v.decorations },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create spellcheck extensions.
|
|
161
|
+
*
|
|
162
|
+
* @returns {import('@codemirror/state').Extension[]}
|
|
163
|
+
*/
|
|
164
|
+
export function createSpellcheckExtensions() {
|
|
165
|
+
return [proseSpellcheck, noSpellcheckInNonProse];
|
|
166
|
+
}
|