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.
Files changed (58) hide show
  1. package/package.json +3 -1
  2. package/src/commands.js +112 -4
  3. package/src/comment-syntax.js +364 -39
  4. package/src/config/handlers.js +1 -2
  5. package/src/config/schema.js +46 -4
  6. package/src/document-template.js +2236 -0
  7. package/src/frontmatter-updater.js +204 -74
  8. package/src/grammar.js +758 -0
  9. package/src/index.js +1074 -55
  10. package/src/keymap.js +11 -2
  11. package/src/markdown/block-decorations.js +108 -5
  12. package/src/markdown/facets.js +37 -0
  13. package/src/markdown/html-inline.js +9 -5
  14. package/src/markdown/index.js +13 -3
  15. package/src/markdown/inline-commands.js +256 -0
  16. package/src/markdown/inline-model.js +578 -0
  17. package/src/markdown/inline-state.js +103 -0
  18. package/src/markdown/renderer.js +219 -12
  19. package/src/markdown/styles.js +290 -3
  20. package/src/markdown/widgets/alert-title.js +10 -8
  21. package/src/markdown/widgets/frontmatter.js +0 -6
  22. package/src/markdown/widgets/index.js +1 -0
  23. package/src/markdown/widgets/list-marker.js +29 -0
  24. package/src/markdown/wysiwyg.js +1158 -0
  25. package/src/mrp-types.js +2 -0
  26. package/src/output-widget.js +532 -18
  27. package/src/page-view-pagination.js +127 -0
  28. package/src/runtime-lsp.js +1757 -150
  29. package/src/section-controls/commands.js +617 -0
  30. package/src/section-controls/index.js +63 -0
  31. package/src/section-controls/plugin.js +165 -0
  32. package/src/section-controls/widgets.js +936 -0
  33. package/src/shell/ai-menu.js +11 -0
  34. package/src/shell/components/context-panel.js +572 -0
  35. package/src/shell/components/status-bar.js +10 -2
  36. package/src/shell/layouts/studio.js +206 -14
  37. package/src/shell/orchestrator-client.js +69 -0
  38. package/src/spellcheck.js +166 -0
  39. package/src/tables/README.md +97 -0
  40. package/src/tables/commands/insert-linked-table.js +122 -0
  41. package/src/tables/commands/open-table-workspace.js +43 -0
  42. package/src/tables/index.js +24 -0
  43. package/src/tables/jobs/client.js +158 -0
  44. package/src/tables/parsing/anchors.js +82 -0
  45. package/src/tables/parsing/linked-table-blocks.js +61 -0
  46. package/src/tables/state/linked-table-state.js +68 -0
  47. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  48. package/src/tables/widgets/linked-table-widget.js +256 -0
  49. package/src/tables/workspace/controller.js +616 -0
  50. package/src/term-pty-client.js +51 -2
  51. package/src/term-widget.js +43 -3
  52. package/src/widgets/theme-utils.js +24 -16
  53. package/src/widgets/theme.js +1015 -1
  54. package/src/runtime-codelens/detector.js +0 -279
  55. package/src/runtime-codelens/index.js +0 -76
  56. package/src/runtime-codelens/plugin.js +0 -142
  57. package/src/runtime-codelens/styles.js +0 -184
  58. package/src/runtime-codelens/widgets.js +0 -216
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Linked-table document/workspace controller.
3
+ *
4
+ * The first implementation keeps the embedded widget actions and `tableJobs`
5
+ * map in sync.
6
+ */
7
+
8
+ import { LINKED_TABLE_EVENT } from '../commands/open-table-workspace.js';
9
+ import { TableJobsClient } from '../jobs/client.js';
10
+ import { createLinkedTableBlockAnchor } from '../parsing/anchors.js';
11
+ import { findLinkedTableBlocksInState } from '../parsing/linked-table-blocks.js';
12
+ import {
13
+ revealLinkedTableMarkdownEffect,
14
+ hideLinkedTableMarkdownEffect,
15
+ } from '../state/linked-table-state.js';
16
+
17
+ function cloneValue(value) {
18
+ if (Array.isArray(value)) return value.map(cloneValue);
19
+ if (value && typeof value === 'object') {
20
+ const out = {};
21
+ for (const key of Object.keys(value)) out[key] = cloneValue(value[key]);
22
+ return out;
23
+ }
24
+ return value;
25
+ }
26
+
27
+ const TERMINAL_JOB_STATUSES = new Set(['completed', 'error', 'cancelled']);
28
+ const ACTIVE_JOB_STATUSES = new Set(['requested', 'claimed', 'running', 'writing']);
29
+ const STALE_CHECK_TTL_MS = 5000;
30
+
31
+ function createActionMetadata(detail) {
32
+ return {
33
+ action: detail.action || null,
34
+ label: detail.label || detail.tableId || null,
35
+ source: 'linked-table-widget',
36
+ };
37
+ }
38
+
39
+ function summarizeStatusLabel(status) {
40
+ switch (status) {
41
+ case 'requested':
42
+ case 'claimed':
43
+ return 'Pending';
44
+ case 'running':
45
+ return 'Running';
46
+ case 'writing':
47
+ return 'Writing';
48
+ case 'error':
49
+ return 'Error';
50
+ case 'cancelled':
51
+ return 'Cancelled';
52
+ case 'completed':
53
+ return 'Fresh';
54
+ default:
55
+ return status || 'Fresh';
56
+ }
57
+ }
58
+
59
+ function isTextLikePath(filePath) {
60
+ const textLikeExtensions = new Set([
61
+ 'md', 'qmd', 'txt', 'csv', 'tsv', 'json', 'yaml', 'yml', 'r', 'py', 'js', 'ts', 'sql', 'html', 'css', 'xml', 'toml'
62
+ ]);
63
+ const match = String(filePath || '').match(/\.([^.]+)$/);
64
+ return !!(match && textLikeExtensions.has(match[1].toLowerCase()));
65
+ }
66
+
67
+ function isEditorDocumentPath(filePath) {
68
+ const match = String(filePath || '').match(/\.([^.]+)$/);
69
+ if (!match) return false;
70
+ return new Set(['md', 'qmd']).has(match[1].toLowerCase());
71
+ }
72
+
73
+ function toTimestamp(value) {
74
+ const date = new Date(value || '');
75
+ const time = date.getTime();
76
+ return Number.isFinite(time) ? time : null;
77
+ }
78
+
79
+ function formatTimestamp(value) {
80
+ const time = toTimestamp(value);
81
+ return time === null ? '' : new Date(time).toLocaleString();
82
+ }
83
+
84
+ function latestJobTimestamp(job) {
85
+ if (!job) return 0;
86
+ return job.completedAt || job.startedAt || job.claimedAt || job.requestedAt || 0;
87
+ }
88
+
89
+ export class LinkedTableController {
90
+ constructor(options = {}) {
91
+ this.editor = options.editor || null;
92
+ this.view = options.view || this.editor?.view || null;
93
+ this.ydoc = options.ydoc || this.editor?.ydoc || null;
94
+ this.yText = options.yText || this.editor?.getYText?.() || this.editor?.yText || null;
95
+ this.jobsClient = options.jobsClient || (this.ydoc ? new TableJobsClient(this.ydoc, this.ydoc.clientID) : null);
96
+ this.ownsJobsClient = !options.jobsClient;
97
+ this.hostApi = options.hostApi || null;
98
+ this.hostContext = {
99
+ projectRoot: options.projectRoot || null,
100
+ documentPath: options.documentPath || null,
101
+ };
102
+ this.onAction = typeof options.onAction === 'function' ? options.onAction : null;
103
+ this.onJobRequested = typeof options.onJobRequested === 'function' ? options.onJobRequested : null;
104
+ this.onJobStatusChange = typeof options.onJobStatusChange === 'function' ? options.onJobStatusChange : null;
105
+ this.handleSortJobs = options.handleSortJobs !== false;
106
+ this.handleRefreshJobs = options.handleRefreshJobs !== false;
107
+ this.handleOpenMarkdown = options.handleOpenMarkdown !== false;
108
+ this._staleInfoCache = new Map();
109
+ this._staleRefreshScheduled = false;
110
+ this._stalePollingTimer = null;
111
+ this._boundHandleAction = this._handleAction.bind(this);
112
+ this._boundJobsObserver = this._handleJobsMapChange.bind(this);
113
+
114
+ if (this.view?.dom?.addEventListener) {
115
+ this.view.dom.addEventListener(LINKED_TABLE_EVENT, this._boundHandleAction);
116
+ }
117
+
118
+ if (this.jobsClient?.jobs?.observe) {
119
+ this.jobsClient.jobs.observe(this._boundJobsObserver);
120
+ }
121
+
122
+ const host = this.getHostApi();
123
+ if (host?.table?.resolvePaths && host?.getFileInfo && typeof setInterval === 'function') {
124
+ this._stalePollingTimer = setInterval(() => {
125
+ this.refreshVisibleTableStaleness().catch((error) => {
126
+ console.warn('[mrmd-editor/tables] stale polling failed:', error);
127
+ });
128
+ }, STALE_CHECK_TTL_MS);
129
+ }
130
+
131
+ this.syncWidgetStatuses();
132
+ }
133
+
134
+ _notifyAction(detail, event) {
135
+ if (!this.onAction) return;
136
+ try {
137
+ this.onAction(detail, event, this);
138
+ } catch (error) {
139
+ console.warn('[mrmd-editor/tables] linked-table action callback failed:', error);
140
+ }
141
+ }
142
+
143
+ _notifyRequested(jobId, detail) {
144
+ if (!this.onJobRequested) return;
145
+ try {
146
+ this.onJobRequested(jobId, detail, this.jobsClient?.getJob(jobId) || null, this);
147
+ } catch (error) {
148
+ console.warn('[mrmd-editor/tables] linked-table job request callback failed:', error);
149
+ }
150
+ }
151
+
152
+ _watchJob(jobId, detail) {
153
+ if (!this.jobsClient || !this.onJobStatusChange) return;
154
+
155
+ const unsubscribe = this.jobsClient.onStatusChange(jobId, (status, job) => {
156
+ try {
157
+ this.onJobStatusChange(status, job, detail, this);
158
+ } catch (error) {
159
+ console.warn('[mrmd-editor/tables] linked-table job status callback failed:', error);
160
+ }
161
+
162
+ if (detail?.tableId && status === 'completed') {
163
+ this._staleInfoCache.delete(detail.tableId);
164
+ }
165
+
166
+ this.syncWidgetStatuses();
167
+
168
+ if (TERMINAL_JOB_STATUSES.has(status)) {
169
+ unsubscribe();
170
+ }
171
+ });
172
+ }
173
+
174
+ _handleJobsMapChange() {
175
+ this.syncWidgetStatuses();
176
+ }
177
+
178
+ _listVisibleTableIds() {
179
+ if (!this.view?.dom?.querySelectorAll) return [];
180
+ return Array.from(this.view.dom.querySelectorAll('.cm-linked-table-widget[data-table-id]'))
181
+ .map((element) => element.dataset.tableId)
182
+ .filter(Boolean);
183
+ }
184
+
185
+ _findBlockByTableId(tableId) {
186
+ if (!tableId || !this.view?.state) return null;
187
+ return findLinkedTableBlocksInState(this.view.state).find((block) => block?.spec?.id === tableId) || null;
188
+ }
189
+
190
+ _scheduleStalenessRefresh() {
191
+ if (this._staleRefreshScheduled) return;
192
+ const host = this.getHostApi();
193
+ if (!host?.table?.resolvePaths || !host?.getFileInfo) return;
194
+
195
+ this._staleRefreshScheduled = true;
196
+ queueMicrotask(() => {
197
+ this._staleRefreshScheduled = false;
198
+ this.refreshVisibleTableStaleness().catch((error) => {
199
+ console.warn('[mrmd-editor/tables] stale refresh failed:', error);
200
+ });
201
+ });
202
+ }
203
+
204
+ _listJobsForTable(tableId) {
205
+ if (!this.jobsClient?.jobs || !tableId) return [];
206
+ return Array.from(this.jobsClient.jobs.values())
207
+ .filter((job) => job?.tableId === tableId)
208
+ .sort((left, right) => latestJobTimestamp(right) - latestJobTimestamp(left));
209
+ }
210
+
211
+ findActiveJobForTable(tableId) {
212
+ return this._listJobsForTable(tableId).find((job) => ACTIVE_JOB_STATUSES.has(job.status)) || null;
213
+ }
214
+
215
+ summarizeTableStatus(tableId, fallbackMaterializedAt = '') {
216
+ const jobs = this._listJobsForTable(tableId);
217
+ const activeJob = jobs.find((job) => ACTIVE_JOB_STATUSES.has(job.status));
218
+ if (activeJob) {
219
+ const phaseTitle = activeJob.status === 'writing'
220
+ ? 'Writing updated markdown snapshot'
221
+ : activeJob.status === 'running'
222
+ ? 'Materializing linked-table result'
223
+ : 'Queued linked-table job';
224
+ return {
225
+ status: activeJob.status,
226
+ label: summarizeStatusLabel(activeJob.status),
227
+ title: activeJob.error?.message || phaseTitle,
228
+ busy: true,
229
+ };
230
+ }
231
+
232
+ const latest = jobs[0] || null;
233
+ if (latest?.status === 'error') {
234
+ return {
235
+ status: 'error',
236
+ label: 'Error',
237
+ title: latest.error?.message || 'Linked-table job failed',
238
+ busy: false,
239
+ };
240
+ }
241
+
242
+ if (latest?.status === 'cancelled') {
243
+ return {
244
+ status: 'cancelled',
245
+ label: 'Cancelled',
246
+ title: 'Linked-table job cancelled',
247
+ busy: false,
248
+ };
249
+ }
250
+
251
+ const materializedAt = latest?.result?.updatedSpec?.snapshot?.materializedAt || fallbackMaterializedAt || '';
252
+ const staleInfo = this._staleInfoCache.get(tableId);
253
+ if (staleInfo?.status === 'stale') {
254
+ return {
255
+ status: 'stale',
256
+ label: 'Stale',
257
+ title: staleInfo.title || 'Linked-table source changed after the last materialization',
258
+ busy: false,
259
+ };
260
+ }
261
+
262
+ const rowCount = latest?.result?.snapshot?.rowCount ?? latest?.result?.materialized?.rowCount ?? null;
263
+ return {
264
+ status: 'fresh',
265
+ label: 'Fresh',
266
+ title: materializedAt
267
+ ? `Last materialized ${formatTimestamp(materializedAt)}${Number.isInteger(rowCount) ? ` • ${rowCount} rows` : ''}`
268
+ : 'Linked-table snapshot is current',
269
+ busy: false,
270
+ };
271
+ }
272
+
273
+ async refreshTableStaleness(tableIdOrBlock) {
274
+ const block = typeof tableIdOrBlock === 'string'
275
+ ? this._findBlockByTableId(tableIdOrBlock)
276
+ : tableIdOrBlock;
277
+ if (!block?.spec?.id) return null;
278
+
279
+ const tableId = block.spec.id;
280
+ if (this.findActiveJobForTable(tableId)) return null;
281
+
282
+ const host = this.getHostApi();
283
+ if (!host?.table?.resolvePaths || !host?.getFileInfo) return null;
284
+
285
+ const materializedAt = block.spec?.snapshot?.materializedAt || '';
286
+ const materializedTime = toTimestamp(materializedAt);
287
+ if (materializedTime === null) {
288
+ this._staleInfoCache.delete(tableId);
289
+ return null;
290
+ }
291
+
292
+ const cached = this._staleInfoCache.get(tableId);
293
+ if (cached && cached.materializedAt === materializedAt && (Date.now() - cached.checkedAt) < STALE_CHECK_TTL_MS) {
294
+ return cached;
295
+ }
296
+
297
+ const resolved = await this.resolveHostPaths({ tableId, spec: block.spec });
298
+ const sourcePaths = resolved?.sourcePaths || [];
299
+ if (sourcePaths.length === 0) {
300
+ this._staleInfoCache.set(tableId, {
301
+ status: 'fresh',
302
+ checkedAt: Date.now(),
303
+ materializedAt,
304
+ });
305
+ return this._staleInfoCache.get(tableId);
306
+ }
307
+
308
+ let latestModified = null;
309
+ let latestPath = null;
310
+ for (const source of sourcePaths) {
311
+ const info = await host.getFileInfo(source.path);
312
+ if (!info?.success || !info.modified) continue;
313
+ const modifiedTime = toTimestamp(info.modified);
314
+ if (modifiedTime === null) continue;
315
+ if (latestModified === null || modifiedTime > latestModified) {
316
+ latestModified = modifiedTime;
317
+ latestPath = source.path;
318
+ }
319
+ }
320
+
321
+ const stale = latestModified !== null && latestModified > materializedTime;
322
+ const entry = stale
323
+ ? {
324
+ status: 'stale',
325
+ checkedAt: Date.now(),
326
+ materializedAt,
327
+ title: `Source changed ${formatTimestamp(latestModified)}${latestPath ? ` • ${latestPath.split('/').pop()}` : ''}`,
328
+ }
329
+ : {
330
+ status: 'fresh',
331
+ checkedAt: Date.now(),
332
+ materializedAt,
333
+ };
334
+
335
+ this._staleInfoCache.set(tableId, entry);
336
+ return entry;
337
+ }
338
+
339
+ async refreshVisibleTableStaleness() {
340
+ const visibleTableIds = this._listVisibleTableIds();
341
+ await Promise.all(visibleTableIds.map((tableId) => this.refreshTableStaleness(tableId)));
342
+ this.syncWidgetStatuses({ scheduleStalenessCheck: false });
343
+ }
344
+
345
+ syncWidgetStatuses(options = {}) {
346
+ if (!this.view?.dom?.querySelectorAll) return;
347
+
348
+ const widgets = this.view.dom.querySelectorAll('.cm-linked-table-widget[data-table-id]');
349
+ for (const widget of widgets) {
350
+ const tableId = widget.dataset.tableId;
351
+ if (!tableId) continue;
352
+
353
+ const statusInfo = this.summarizeTableStatus(tableId, widget.dataset.materializedAt || '');
354
+ widget.dataset.jobStatus = statusInfo.status;
355
+ widget.setAttribute('aria-busy', statusInfo.busy ? 'true' : 'false');
356
+ widget.title = statusInfo.title || '';
357
+
358
+ const badges = widget.querySelector('.cm-linked-table-badges');
359
+ if (badges) {
360
+ badges.querySelectorAll('.cm-linked-table-status-badge').forEach((badge) => badge.remove());
361
+ const badge = document.createElement('span');
362
+ badge.className = `cm-linked-table-badge cm-linked-table-status-badge cm-linked-table-status-${statusInfo.status}${statusInfo.busy ? ' cm-linked-table-status-active' : ''}`;
363
+ badge.textContent = statusInfo.busy ? `${statusInfo.label}…` : statusInfo.label;
364
+ if (statusInfo.title) badge.title = statusInfo.title;
365
+ badges.appendChild(badge);
366
+ }
367
+
368
+ const refreshButton = widget.querySelector('.cm-linked-table-action[data-linked-table-action="refresh"]');
369
+ if (refreshButton) {
370
+ refreshButton.textContent = statusInfo.busy ? 'Working…' : 'Refresh';
371
+ }
372
+
373
+ const actions = widget.querySelectorAll('.cm-linked-table-action, .cm-linked-table-sortable');
374
+ actions.forEach((actionEl) => {
375
+ if (actionEl.matches('.cm-linked-table-action[data-linked-table-action="open-markdown"], .cm-linked-table-action[data-linked-table-action="open-grid"], .cm-linked-table-action[data-linked-table-action="open-source"], .cm-linked-table-action[data-linked-table-action="reveal-source"]')) {
376
+ return;
377
+ }
378
+ if ('disabled' in actionEl) {
379
+ actionEl.disabled = !!statusInfo.busy;
380
+ }
381
+ if (statusInfo.busy) {
382
+ actionEl.setAttribute('aria-disabled', 'true');
383
+ } else {
384
+ actionEl.removeAttribute('aria-disabled');
385
+ }
386
+ });
387
+ }
388
+
389
+ if (options.scheduleStalenessCheck !== false) {
390
+ this._scheduleStalenessRefresh();
391
+ }
392
+ }
393
+
394
+ getHostApi() {
395
+ if (this.hostApi) return this.hostApi;
396
+ if (typeof window !== 'undefined' && window?.electronAPI) return window.electronAPI;
397
+ return null;
398
+ }
399
+
400
+ setHostContext(context = {}) {
401
+ this.hostContext = {
402
+ ...this.hostContext,
403
+ ...context,
404
+ };
405
+ return this.getHostContext();
406
+ }
407
+
408
+ getHostContext() {
409
+ return { ...this.hostContext };
410
+ }
411
+
412
+ createBlockAnchor(detail) {
413
+ if (!this.yText) {
414
+ throw new Error('LinkedTableController requires a Y.Text to create block anchors');
415
+ }
416
+
417
+ return createLinkedTableBlockAnchor(this.yText, {
418
+ tableId: detail.tableId,
419
+ headerFrom: detail.headerFrom,
420
+ snapshotTo: detail.snapshotTo,
421
+ });
422
+ }
423
+
424
+ async resolveHostPaths(detail) {
425
+ const host = this.getHostApi();
426
+ const projectRoot = this.hostContext.projectRoot;
427
+ const documentPath = this.hostContext.documentPath;
428
+ if (!host?.table?.resolvePaths) return null;
429
+ if (!projectRoot || !documentPath || !detail?.spec) return null;
430
+
431
+ return host.table.resolvePaths({
432
+ projectRoot,
433
+ documentPath,
434
+ spec: cloneValue(detail.spec),
435
+ });
436
+ }
437
+
438
+ requestSort(detail) {
439
+ if (!this.jobsClient) {
440
+ throw new Error('LinkedTableController requires a TableJobsClient to request sort jobs');
441
+ }
442
+
443
+ const activeJob = this.findActiveJobForTable(detail.tableId);
444
+ if (activeJob) {
445
+ return activeJob.id;
446
+ }
447
+
448
+ this._staleInfoCache.delete(detail.tableId);
449
+
450
+ const blockAnchor = this.createBlockAnchor(detail);
451
+ const jobId = this.jobsClient.requestSort({
452
+ tableId: detail.tableId,
453
+ blockAnchor,
454
+ spec: cloneValue(detail.spec),
455
+ column: detail.column,
456
+ direction: detail.direction || 'asc',
457
+ metadata: createActionMetadata(detail),
458
+ });
459
+
460
+ this._notifyRequested(jobId, detail);
461
+ this._watchJob(jobId, detail);
462
+ this.syncWidgetStatuses();
463
+ return jobId;
464
+ }
465
+
466
+ requestRefresh(detail) {
467
+ if (!this.jobsClient) {
468
+ throw new Error('LinkedTableController requires a TableJobsClient to request refresh jobs');
469
+ }
470
+
471
+ const activeJob = this.findActiveJobForTable(detail.tableId);
472
+ if (activeJob) {
473
+ return activeJob.id;
474
+ }
475
+
476
+ this._staleInfoCache.delete(detail.tableId);
477
+
478
+ const blockAnchor = this.createBlockAnchor(detail);
479
+ const jobId = this.jobsClient.requestRefresh({
480
+ tableId: detail.tableId,
481
+ blockAnchor,
482
+ spec: cloneValue(detail.spec),
483
+ metadata: createActionMetadata(detail),
484
+ });
485
+
486
+ this._notifyRequested(jobId, detail);
487
+ this._watchJob(jobId, detail);
488
+ this.syncWidgetStatuses();
489
+ return jobId;
490
+ }
491
+
492
+ openMarkdown(detail) {
493
+ if (!this.handleOpenMarkdown) return false;
494
+
495
+ if (this.view?.dispatch && detail.tableId) {
496
+ this.view.dispatch({
497
+ effects: [revealLinkedTableMarkdownEffect.of({ tableId: detail.tableId })],
498
+ selection: {
499
+ anchor: Number.isInteger(detail.headerFrom) ? detail.headerFrom : (detail.snapshotFrom || 0),
500
+ head: Number.isInteger(detail.snapshotTo) ? detail.snapshotTo : (detail.headerFrom || 0),
501
+ },
502
+ scrollIntoView: true,
503
+ });
504
+ return true;
505
+ }
506
+
507
+ return false;
508
+ }
509
+
510
+ async openSource(detail) {
511
+ const host = this.getHostApi();
512
+ if (!host) return false;
513
+
514
+ const resolved = await this.resolveHostPaths(detail);
515
+ const primarySourcePath = resolved?.sourcePaths?.[0]?.path || null;
516
+ if (!primarySourcePath) return false;
517
+
518
+ if (host.openFile && isEditorDocumentPath(primarySourcePath)) {
519
+ await host.openFile(primarySourcePath);
520
+ return true;
521
+ }
522
+
523
+ if (host.shell?.openPath && isTextLikePath(primarySourcePath)) {
524
+ await host.shell.openPath(primarySourcePath);
525
+ return true;
526
+ }
527
+
528
+ if (host.shell?.showItemInFolder) {
529
+ await host.shell.showItemInFolder(primarySourcePath);
530
+ return true;
531
+ }
532
+
533
+ return false;
534
+ }
535
+
536
+ async revealSource(detail) {
537
+ const host = this.getHostApi();
538
+ if (!host?.shell?.showItemInFolder) return false;
539
+
540
+ const resolved = await this.resolveHostPaths(detail);
541
+ const primarySourcePath = resolved?.sourcePaths?.[0]?.path || null;
542
+ if (!primarySourcePath) return false;
543
+
544
+ await host.shell.showItemInFolder(primarySourcePath);
545
+ return true;
546
+ }
547
+
548
+ closeMarkdown(detail) {
549
+ if (!this.view?.dispatch || !detail?.tableId) return false;
550
+ this.view.dispatch({
551
+ effects: [hideLinkedTableMarkdownEffect.of({ tableId: detail.tableId })],
552
+ selection: {
553
+ anchor: Number.isInteger(detail.tableFrom) ? detail.tableFrom : (detail.headerFrom || 0),
554
+ },
555
+ scrollIntoView: true,
556
+ });
557
+ return true;
558
+ }
559
+
560
+ _handleAction(event) {
561
+ const detail = event?.detail || {};
562
+ if (!detail?.action) return;
563
+
564
+ let jobId = null;
565
+
566
+ if (detail.action === 'sort' && this.handleSortJobs && detail.tableId && detail.spec && detail.column) {
567
+ jobId = this.requestSort(detail);
568
+ detail.jobId = jobId;
569
+ } else if (detail.action === 'refresh' && this.handleRefreshJobs && detail.tableId && detail.spec) {
570
+ jobId = this.requestRefresh(detail);
571
+ detail.jobId = jobId;
572
+ } else if (detail.action === 'open-markdown') {
573
+ this.openMarkdown(detail);
574
+ } else if (detail.action === 'close-markdown') {
575
+ this.closeMarkdown(detail);
576
+ } else if (detail.action === 'open-source') {
577
+ Promise.resolve(this.openSource(detail)).catch((error) => {
578
+ console.warn('[mrmd-editor/tables] open-source action failed:', error);
579
+ });
580
+ } else if (detail.action === 'reveal-source') {
581
+ Promise.resolve(this.revealSource(detail)).catch((error) => {
582
+ console.warn('[mrmd-editor/tables] reveal-source action failed:', error);
583
+ });
584
+ }
585
+
586
+ this._notifyAction(detail, event);
587
+ }
588
+
589
+ destroy() {
590
+ if (this.view?.dom?.removeEventListener) {
591
+ this.view.dom.removeEventListener(LINKED_TABLE_EVENT, this._boundHandleAction);
592
+ }
593
+
594
+ if (this.jobsClient?.jobs?.unobserve && this._boundJobsObserver) {
595
+ this.jobsClient.jobs.unobserve(this._boundJobsObserver);
596
+ }
597
+
598
+ if (this._stalePollingTimer) {
599
+ clearInterval(this._stalePollingTimer);
600
+ this._stalePollingTimer = null;
601
+ }
602
+
603
+ if (this.ownsJobsClient && this.jobsClient?.destroy) {
604
+ this.jobsClient.destroy();
605
+ }
606
+ }
607
+ }
608
+
609
+ export function createLinkedTableController(options = {}) {
610
+ return new LinkedTableController(options);
611
+ }
612
+
613
+ export default {
614
+ LinkedTableController,
615
+ createLinkedTableController,
616
+ };