2026-04-25 06:12:17 +00:00
|
|
|
'use strict';
|
2026-04-27 05:16:13 +00:00
|
|
|
import { MarkdownView, Notice, Plugin, TFile } from 'obsidian';
|
2026-04-26 09:54:35 +00:00
|
|
|
import {
|
2026-04-26 10:10:17 +00:00
|
|
|
type BatchRunState,
|
2026-04-26 09:54:35 +00:00
|
|
|
batchProgressVars,
|
2026-04-26 10:10:17 +00:00
|
|
|
createBatchRunState,
|
2026-04-26 09:54:35 +00:00
|
|
|
createBatchStats,
|
2026-04-26 10:10:17 +00:00
|
|
|
markBatchFileRunning,
|
2026-04-27 05:16:13 +00:00
|
|
|
promptForBatchFolder,
|
2026-04-27 03:15:35 +00:00
|
|
|
recordBatchError,
|
2026-04-26 10:10:17 +00:00
|
|
|
recordBatchProcessed,
|
2026-04-26 09:54:35 +00:00
|
|
|
recordBatchSkip,
|
2026-04-26 10:10:17 +00:00
|
|
|
requestBatchCancel,
|
2026-04-26 09:54:35 +00:00
|
|
|
selectBatchFiles,
|
|
|
|
|
shouldSkipBatchFile,
|
2026-04-26 10:12:38 +00:00
|
|
|
validateBatchFolderInput,
|
2026-04-26 09:54:35 +00:00
|
|
|
} from './src/batch';
|
2026-04-27 07:43:58 +00:00
|
|
|
import { shouldConfirmRegenerate } from './src/cache';
|
2026-04-26 06:07:03 +00:00
|
|
|
import { CacheManager } from './src/cache-manager';
|
2026-04-27 07:43:58 +00:00
|
|
|
import { resolveCardAnchors } from './src/cards';
|
2026-05-05 12:19:57 +00:00
|
|
|
import { showGenerationError } from './src/error-ui';
|
2026-04-26 09:45:43 +00:00
|
|
|
import { cancellationNoticeKey, summarizeDocument } from './src/generation';
|
2026-04-25 15:47:49 +00:00
|
|
|
import {
|
|
|
|
|
classifyGenerationError,
|
|
|
|
|
GenerationJobAlreadyRunningError,
|
|
|
|
|
GenerationJobCancelledError,
|
|
|
|
|
GenerationJobManager,
|
|
|
|
|
} from './src/generation-job-manager';
|
2026-04-25 07:15:26 +00:00
|
|
|
import { translate } from './src/i18n';
|
2026-04-25 15:40:56 +00:00
|
|
|
import { cardsToMarkdown } from './src/markdown';
|
2026-04-27 05:15:04 +00:00
|
|
|
import { confirmRegenerateEditedCards } from './src/modal';
|
2026-04-25 15:47:49 +00:00
|
|
|
import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll';
|
2026-04-27 07:43:58 +00:00
|
|
|
import { cacheEntryMatches, DEFAULT_SETTINGS, normalizeSettings } from './src/settings';
|
2026-04-25 15:47:49 +00:00
|
|
|
import { ParallelReaderSettingTab } from './src/settings-tab';
|
2026-04-27 07:43:58 +00:00
|
|
|
import type { StreamProgress } from './src/streaming';
|
2026-04-26 06:08:46 +00:00
|
|
|
import type {
|
|
|
|
|
CacheEntry,
|
|
|
|
|
ObsidianEditorWithCm,
|
|
|
|
|
ObsidianMenu,
|
|
|
|
|
ObsidianMenuItem,
|
|
|
|
|
PluginSettings,
|
|
|
|
|
ResolvedCard,
|
2026-04-29 05:51:33 +00:00
|
|
|
RunForFileOptions,
|
2026-05-02 05:52:49 +00:00
|
|
|
RunForFileResult,
|
2026-04-26 06:08:46 +00:00
|
|
|
} from './src/types';
|
2026-04-27 07:43:58 +00:00
|
|
|
import { copyToClipboard } from './src/ui-helpers';
|
2026-04-25 15:40:56 +00:00
|
|
|
import { ParallelReaderView, VIEW_TYPE_PARALLEL } from './src/view';
|
2026-04-25 06:12:17 +00:00
|
|
|
|
|
|
|
|
/* ---------- Plugin ---------- */
|
|
|
|
|
|
|
|
|
|
class ParallelReaderPlugin extends Plugin {
|
2026-04-26 01:21:54 +00:00
|
|
|
settings!: PluginSettings;
|
2026-04-26 06:07:03 +00:00
|
|
|
cacheManager!: CacheManager;
|
2026-04-26 01:21:54 +00:00
|
|
|
jobs!: GenerationJobManager;
|
2026-04-26 10:10:17 +00:00
|
|
|
activeBatch: BatchRunState | null = null;
|
2026-04-26 01:21:54 +00:00
|
|
|
_scrollDispose: (() => void) | null = null;
|
2026-05-03 02:45:10 +00:00
|
|
|
_settingsSaveTimer: number | null = null;
|
2026-04-26 06:07:03 +00:00
|
|
|
|
|
|
|
|
get cache(): Record<string, CacheEntry> {
|
|
|
|
|
return this.cacheManager.cache;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
t(key: string, vars?: Record<string, string | number>) {
|
2026-04-25 07:15:26 +00:00
|
|
|
return translate(this.settings || DEFAULT_SETTINGS, key, vars);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 06:12:17 +00:00
|
|
|
async onload() {
|
|
|
|
|
await this.loadSettings();
|
|
|
|
|
this.jobs = new GenerationJobManager();
|
|
|
|
|
|
2026-04-25 07:15:26 +00:00
|
|
|
this.addRibbonIcon('book-open', this.t('ribbonOpen'), async () => {
|
2026-04-25 06:12:17 +00:00
|
|
|
const active = this.getActiveView();
|
2026-04-26 09:41:28 +00:00
|
|
|
const view = await this.ensureView();
|
|
|
|
|
if (!view) return;
|
2026-04-25 06:12:17 +00:00
|
|
|
if (active?.file) await this.syncViewToFile(active.file);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.registerView(VIEW_TYPE_PARALLEL, (leaf) => new ParallelReaderView(leaf, this));
|
|
|
|
|
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'run',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdRun'),
|
|
|
|
|
callback: () => this.runForActiveFile(false),
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'regen',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdRegen'),
|
|
|
|
|
callback: () => this.runForActiveFile(true),
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'open-view',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdOpenView'),
|
feat: toggle right sidebar instead of detaching tab + component tests
UX change:
- The "open Parallel Reader" command now toggles the right sidebar's
collapsed state instead of detaching the leaf. The tab and its content
are preserved across hide/show, matching standard Obsidian behaviour
for File Explorer and friends. Detaching the tab on every second
press destroyed cached cards and forced re-generation.
Behaviour:
- No leaf yet → create + reveal
- Leaf exists, right sidebar collapsed → reveal (expands sidebar)
- Leaf exists, right sidebar expanded → collapse(); tab is preserved
- No right sidebar (mobile / unusual layout) → reveal as fallback
Tests:
- New tests/view-render.test.js (18 cases) covering the component layer
that was previously a gap. The 1.0.12 → 1.0.13 sidebar regression
could not have been caught by any existing pure-function test; this
file fills that gap by mocking app.workspace + view spies and running
real runForFile / refreshViewAfterCache* / toggleParallelView.
- shouldRender matrix (4 cases): silentView × sourceFile state — the
exact regression introduced in PR2 where guards were over-applied.
- toggleParallelView (4 cases): no-leaf opens, expanded → collapse,
collapsed → reveal, no-rightSplit fallback.
- refreshViewAfterCacheDelete + Clear (4 cases): matching file vs
different file vs no-view, plus clear-all always renderEmpty.
- runForFile outcome enum mapping (6 cases): already-running early
return, already-running from catch, cancelled, generic error,
regenerate-confirm cancellation, skipEditConfirm bypass — these
drive batch statistics and were untested before.
- view.deleteCard / updateCard cardPersistFailed path (3 cases):
exposes ParallelReaderView via test-exports for instantiation;
mocks cacheReplaceCards returning false to verify the failure
notice path lands and the public method returns false.
src/test-exports.ts: export ParallelReaderView for component tests.
tests/catalog.json: register view-render.test.js under component.
Change-Id: I439b5fca07b0928e6fe44d9025c2421c71c6cb28
2026-05-02 07:38:41 +00:00
|
|
|
callback: () => this.toggleParallelView(),
|
2026-04-25 06:12:17 +00:00
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'export-current',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdExport'),
|
|
|
|
|
callback: () => this.exportCurrentView(),
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'copy-current-markdown',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdCopyMarkdown'),
|
|
|
|
|
callback: () => this.copyCurrentViewMarkdown(),
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'cancel-current',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdCancel'),
|
|
|
|
|
callback: () => this.cancelActiveGeneration(),
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'clear-current',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdClearCurrent'),
|
|
|
|
|
callback: async () => {
|
2026-04-25 06:12:17 +00:00
|
|
|
const active = this.getActiveView();
|
2026-04-27 03:15:35 +00:00
|
|
|
if (!active?.file) {
|
|
|
|
|
new Notice(this.t('noCurrentNote'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 06:07:03 +00:00
|
|
|
await this.cacheManager.delete(active.file.path);
|
2026-05-02 05:52:49 +00:00
|
|
|
this.refreshViewAfterCacheDelete(active.file.path);
|
2026-04-25 07:15:26 +00:00
|
|
|
new Notice(this.t('cacheClearedFile', { name: active.file.basename }));
|
2026-04-25 06:12:17 +00:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'clear-all',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdClearAll'),
|
|
|
|
|
callback: async () => {
|
2026-04-26 06:07:03 +00:00
|
|
|
const n = Object.keys(this.cacheManager.cache).length;
|
|
|
|
|
await this.cacheManager.clear();
|
2026-05-02 05:52:49 +00:00
|
|
|
this.refreshViewAfterCacheClear();
|
2026-04-25 07:15:26 +00:00
|
|
|
new Notice(this.t('cacheClearedAll', { count: n }));
|
2026-04-25 06:12:17 +00:00
|
|
|
},
|
|
|
|
|
});
|
2026-04-25 15:47:49 +00:00
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'card-prev',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdCardPrev'),
|
|
|
|
|
callback: () => this.moveActiveCard(-1),
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'card-next',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdCardNext'),
|
|
|
|
|
callback: () => this.moveActiveCard(1),
|
|
|
|
|
});
|
|
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'card-jump',
|
2026-04-25 15:47:49 +00:00
|
|
|
name: this.t('cmdCardJump'),
|
|
|
|
|
callback: () => this.jumpActiveCard(),
|
|
|
|
|
});
|
2026-04-26 06:18:58 +00:00
|
|
|
this.addCommand({
|
2026-04-26 07:57:16 +00:00
|
|
|
id: 'batch-generate',
|
2026-04-26 06:18:58 +00:00
|
|
|
name: this.t('cmdBatchGenerate'),
|
|
|
|
|
callback: () => this.runBatchForFolder(),
|
|
|
|
|
});
|
2026-04-25 11:44:39 +00:00
|
|
|
|
2026-04-25 06:12:17 +00:00
|
|
|
this.addSettingTab(new ParallelReaderSettingTab(this.app, this));
|
|
|
|
|
|
2026-04-25 15:40:56 +00:00
|
|
|
this.registerEvent(this.app.workspace.on('active-leaf-change', () => this.bindScrollSync()));
|
2026-04-25 15:47:49 +00:00
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.workspace.on('file-open', (file) => {
|
2026-04-27 04:40:29 +00:00
|
|
|
if (file) this.syncViewToFile(file).catch((e: unknown) => console.error('[parallel-reader] sync view', e));
|
2026-04-25 15:47:49 +00:00
|
|
|
}),
|
|
|
|
|
);
|
2026-04-25 15:40:56 +00:00
|
|
|
this.registerEvent(this.app.workspace.on('file-menu', (menu, file) => this.addFileMenuItems(menu, file)));
|
2026-04-26 09:41:28 +00:00
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.vault.on('rename', (file, oldPath) => {
|
2026-04-27 04:40:29 +00:00
|
|
|
if (file instanceof TFile)
|
|
|
|
|
this.handleFileRename(file, oldPath).catch((e: unknown) => console.error('[parallel-reader] file rename', e));
|
2026-04-26 09:41:28 +00:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.vault.on('delete', (file) => {
|
2026-04-27 04:40:29 +00:00
|
|
|
if (file instanceof TFile)
|
|
|
|
|
this.handleFileDelete(file).catch((e: unknown) => console.error('[parallel-reader] file delete', e));
|
2026-04-26 09:41:28 +00:00
|
|
|
}),
|
|
|
|
|
);
|
2026-04-25 06:12:17 +00:00
|
|
|
this.bindScrollSync();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 07:57:16 +00:00
|
|
|
onunload() {
|
2026-05-02 05:52:49 +00:00
|
|
|
if (this.jobs) this.jobs.cancelAllWaiters();
|
2026-04-27 04:40:29 +00:00
|
|
|
this.flushSettingsSave().catch((e: unknown) => console.error('[parallel-reader] flush settings on unload', e));
|
|
|
|
|
this.flushCacheSave().catch((e: unknown) => console.error('[parallel-reader] flush cache on unload', e));
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 15:40:56 +00:00
|
|
|
/* ---------- Settings persistence ---------- */
|
|
|
|
|
|
2026-04-25 06:12:17 +00:00
|
|
|
async loadSettings() {
|
|
|
|
|
const data = (await this.loadData()) || {};
|
|
|
|
|
const settingsBlob = data.settings || {};
|
|
|
|
|
this.settings = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settingsBlob));
|
2026-04-26 06:07:03 +00:00
|
|
|
this.cacheManager = new CacheManager(
|
|
|
|
|
this.app.vault.adapter,
|
2026-04-26 07:57:16 +00:00
|
|
|
this.app.vault.configDir,
|
2026-04-26 06:07:03 +00:00
|
|
|
this.manifest?.id || 'parallel-reader',
|
|
|
|
|
() => this.settings,
|
|
|
|
|
);
|
|
|
|
|
await this.cacheManager.load();
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSettings() {
|
2026-04-25 15:47:49 +00:00
|
|
|
if (this._settingsSaveTimer) {
|
2026-05-03 02:45:10 +00:00
|
|
|
activeWindow.clearTimeout(this._settingsSaveTimer);
|
2026-04-25 15:47:49 +00:00
|
|
|
this._settingsSaveTimer = null;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
await this.saveData({ settings: this.settings });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
saveSettingsDebounced(delayMs = 400) {
|
2026-05-03 02:45:10 +00:00
|
|
|
if (this._settingsSaveTimer) activeWindow.clearTimeout(this._settingsSaveTimer);
|
|
|
|
|
this._settingsSaveTimer = activeWindow.setTimeout(() => {
|
2026-04-25 06:12:17 +00:00
|
|
|
this._settingsSaveTimer = null;
|
2026-04-27 03:15:35 +00:00
|
|
|
this.saveSettings().catch((e: unknown) => console.error('[parallel-reader] failed to save settings', e));
|
2026-04-25 06:12:17 +00:00
|
|
|
}, delayMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async flushSettingsSave() {
|
|
|
|
|
if (!this._settingsSaveTimer) return;
|
2026-05-03 02:45:10 +00:00
|
|
|
activeWindow.clearTimeout(this._settingsSaveTimer);
|
2026-04-25 15:47:49 +00:00
|
|
|
this._settingsSaveTimer = null;
|
2026-04-25 06:12:17 +00:00
|
|
|
await this.saveSettings();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 06:07:03 +00:00
|
|
|
/* ---------- Cache delegation ---------- */
|
2026-04-25 15:40:56 +00:00
|
|
|
|
2026-04-26 07:57:16 +00:00
|
|
|
cacheTouch(filePath: string) {
|
2026-04-26 06:07:03 +00:00
|
|
|
return this.cacheManager.touch(filePath);
|
2026-04-25 11:44:39 +00:00
|
|
|
}
|
|
|
|
|
scheduleCacheSave(delayMs = 5000) {
|
2026-04-26 06:07:03 +00:00
|
|
|
this.cacheManager.scheduleSave(delayMs);
|
2026-04-25 11:44:39 +00:00
|
|
|
}
|
|
|
|
|
async flushCacheSave() {
|
2026-04-26 06:07:03 +00:00
|
|
|
await this.cacheManager.flush();
|
2026-04-25 15:47:49 +00:00
|
|
|
}
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
async cacheReplaceCards(filePath: string, cards: ResolvedCard[]) {
|
2026-04-26 06:07:03 +00:00
|
|
|
return this.cacheManager.replaceCards(filePath, cards);
|
2026-04-25 15:47:49 +00:00
|
|
|
}
|
|
|
|
|
async cacheClear() {
|
2026-04-26 06:07:03 +00:00
|
|
|
return this.cacheManager.clear();
|
|
|
|
|
}
|
|
|
|
|
async pruneCacheIfNeeded() {
|
|
|
|
|
return this.cacheManager.pruneIfNeeded();
|
2026-04-25 15:47:49 +00:00
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
|
2026-04-25 15:40:56 +00:00
|
|
|
/* ---------- View management ---------- */
|
2026-04-25 06:12:17 +00:00
|
|
|
|
feat: toggle right sidebar instead of detaching tab + component tests
UX change:
- The "open Parallel Reader" command now toggles the right sidebar's
collapsed state instead of detaching the leaf. The tab and its content
are preserved across hide/show, matching standard Obsidian behaviour
for File Explorer and friends. Detaching the tab on every second
press destroyed cached cards and forced re-generation.
Behaviour:
- No leaf yet → create + reveal
- Leaf exists, right sidebar collapsed → reveal (expands sidebar)
- Leaf exists, right sidebar expanded → collapse(); tab is preserved
- No right sidebar (mobile / unusual layout) → reveal as fallback
Tests:
- New tests/view-render.test.js (18 cases) covering the component layer
that was previously a gap. The 1.0.12 → 1.0.13 sidebar regression
could not have been caught by any existing pure-function test; this
file fills that gap by mocking app.workspace + view spies and running
real runForFile / refreshViewAfterCache* / toggleParallelView.
- shouldRender matrix (4 cases): silentView × sourceFile state — the
exact regression introduced in PR2 where guards were over-applied.
- toggleParallelView (4 cases): no-leaf opens, expanded → collapse,
collapsed → reveal, no-rightSplit fallback.
- refreshViewAfterCacheDelete + Clear (4 cases): matching file vs
different file vs no-view, plus clear-all always renderEmpty.
- runForFile outcome enum mapping (6 cases): already-running early
return, already-running from catch, cancelled, generic error,
regenerate-confirm cancellation, skipEditConfirm bypass — these
drive batch statistics and were untested before.
- view.deleteCard / updateCard cardPersistFailed path (3 cases):
exposes ParallelReaderView via test-exports for instantiation;
mocks cacheReplaceCards returning false to verify the failure
notice path lands and the public method returns false.
src/test-exports.ts: export ParallelReaderView for component tests.
tests/catalog.json: register view-render.test.js under component.
Change-Id: I439b5fca07b0928e6fe44d9025c2421c71c6cb28
2026-05-02 07:38:41 +00:00
|
|
|
/**
|
|
|
|
|
* Open the panel if it does not exist yet; otherwise toggle the right
|
|
|
|
|
* sidebar's collapsed state. The leaf itself is preserved across toggles —
|
|
|
|
|
* we never detach it, so the panel content survives a hide/show cycle.
|
|
|
|
|
*/
|
|
|
|
|
async toggleParallelView(): Promise<void> {
|
|
|
|
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
|
|
|
|
|
if (leaves.length === 0) {
|
|
|
|
|
const active = this.getActiveView();
|
|
|
|
|
const view = await this.ensureView();
|
|
|
|
|
if (!view) return;
|
|
|
|
|
if (active?.file) await this.syncViewToFile(active.file);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const rightSplit = this.app.workspace.rightSplit;
|
|
|
|
|
if (rightSplit?.collapsed) {
|
|
|
|
|
// Sidebar collapsed → expand and focus our tab.
|
|
|
|
|
await this.app.workspace.revealLeaf(leaves[0]);
|
|
|
|
|
} else if (rightSplit) {
|
|
|
|
|
// Sidebar expanded → collapse it. Tab state is preserved.
|
|
|
|
|
rightSplit.collapse();
|
|
|
|
|
} else {
|
|
|
|
|
// No right sidebar (mobile? unusual layout?) — just reveal.
|
|
|
|
|
await this.app.workspace.revealLeaf(leaves[0]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 09:41:28 +00:00
|
|
|
async ensureView(): Promise<ParallelReaderView | null> {
|
2026-04-25 06:12:17 +00:00
|
|
|
const { workspace } = this.app;
|
|
|
|
|
let leaf = workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0];
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!leaf) {
|
2026-04-26 09:41:28 +00:00
|
|
|
const newLeaf = workspace.getRightLeaf(false);
|
|
|
|
|
if (!newLeaf) {
|
|
|
|
|
new Notice(this.t('viewOpenFailed'));
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
leaf = newLeaf;
|
2026-04-25 15:47:49 +00:00
|
|
|
await leaf.setViewState({ type: VIEW_TYPE_PARALLEL, active: true });
|
|
|
|
|
}
|
2026-04-26 07:57:16 +00:00
|
|
|
await workspace.revealLeaf(leaf);
|
2026-04-25 06:12:17 +00:00
|
|
|
return leaf.view as ParallelReaderView;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 15:47:49 +00:00
|
|
|
getActiveView() {
|
|
|
|
|
return this.app.workspace.getActiveViewOfType(MarkdownView);
|
|
|
|
|
}
|
|
|
|
|
getParallelView() {
|
|
|
|
|
return this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0]?.view as ParallelReaderView | undefined;
|
|
|
|
|
}
|
2026-04-25 11:44:39 +00:00
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
moveActiveCard(delta: number) {
|
2026-04-25 11:44:39 +00:00
|
|
|
const view = this.getParallelView();
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!view?.sections.length) {
|
|
|
|
|
new Notice(this.t('noActiveCard'));
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
2026-04-25 11:44:39 +00:00
|
|
|
return view.moveActiveSection(delta);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jumpActiveCard() {
|
|
|
|
|
const view = this.getParallelView();
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!view || view.jumpToActiveSection() < 0) {
|
|
|
|
|
new Notice(this.t('noActiveCard'));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-04-25 11:44:39 +00:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
isGeneratingFile(file: TFile | null) {
|
2026-04-25 15:47:49 +00:00
|
|
|
return !!file && !!file.path && this.jobs.isRunning(file.path);
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
cancelGenerationForFile(file: TFile | null) {
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!file?.path) {
|
|
|
|
|
new Notice(this.t('noCancelableJob'));
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-04-25 11:44:39 +00:00
|
|
|
const job = this.jobs.get(file.path);
|
|
|
|
|
const noticeKey = cancellationNoticeKey(this.settings, job);
|
2026-04-25 06:12:17 +00:00
|
|
|
const cancelled = this.jobs.cancel(file.path);
|
2026-04-26 10:10:17 +00:00
|
|
|
if (cancelled && this.activeBatch?.currentPath === file.path) requestBatchCancel(this.activeBatch);
|
2026-04-25 11:44:39 +00:00
|
|
|
new Notice(cancelled ? this.t(noticeKey) : this.t('noCancelableJob'));
|
2026-04-25 06:12:17 +00:00
|
|
|
return cancelled;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 10:10:17 +00:00
|
|
|
requestBatchCancellation(): boolean {
|
|
|
|
|
return requestBatchCancel(this.activeBatch);
|
|
|
|
|
}
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
viewIsShowingFile(view: ParallelReaderView | null, file: TFile) {
|
2026-04-25 15:47:49 +00:00
|
|
|
return !!view && !!file && view.sourceFile?.path === file.path;
|
|
|
|
|
}
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
activeFileStillMatches(file: TFile) {
|
2026-04-25 15:47:49 +00:00
|
|
|
const active = this.getActiveView();
|
|
|
|
|
return !active?.file || active.file.path === file.path;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
|
|
|
|
|
cancelActiveGeneration() {
|
2026-04-26 10:10:17 +00:00
|
|
|
if (this.requestBatchCancellation()) {
|
|
|
|
|
const currentPath = this.activeBatch?.currentPath;
|
|
|
|
|
if (currentPath) this.jobs.cancel(currentPath);
|
|
|
|
|
new Notice(this.t('batchCancelRequested'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
const active = this.getActiveView();
|
|
|
|
|
if (active?.file && this.cancelGenerationForFile(active.file)) return;
|
2026-04-25 15:40:56 +00:00
|
|
|
const view = this.getParallelView();
|
2026-04-25 06:12:17 +00:00
|
|
|
if (view?.sourceFile) this.cancelGenerationForFile(view.sourceFile);
|
2026-04-25 07:15:26 +00:00
|
|
|
else new Notice(this.t('noCancelableJob'));
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 07:57:16 +00:00
|
|
|
confirmRegenerateEditedCards(): Promise<boolean> {
|
2026-04-29 05:54:53 +00:00
|
|
|
return confirmRegenerateEditedCards(
|
|
|
|
|
this.app,
|
|
|
|
|
this.t('displayName'),
|
|
|
|
|
this.t('confirmRegenerateEditedCards'),
|
|
|
|
|
this.t('confirmRegenerateCancel'),
|
|
|
|
|
this.t('confirmRegenerateProceed'),
|
|
|
|
|
);
|
2026-04-25 11:52:27 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 06:08:46 +00:00
|
|
|
addFileMenuItems(menu: ObsidianMenu, file: unknown) {
|
2026-04-25 06:12:17 +00:00
|
|
|
if (!(file instanceof TFile) || !file.path.endsWith('.md')) return;
|
|
|
|
|
menu.addSeparator();
|
2026-04-26 06:08:46 +00:00
|
|
|
menu.addItem((it: ObsidianMenuItem) =>
|
2026-04-25 15:47:49 +00:00
|
|
|
it
|
|
|
|
|
.setTitle(this.t('fileMenuGenerate'))
|
|
|
|
|
.setIcon('book-open')
|
2026-04-27 12:39:14 +00:00
|
|
|
.onClick(() => void this.runForFile(file, false)),
|
2026-04-25 15:47:49 +00:00
|
|
|
);
|
2026-04-26 06:08:46 +00:00
|
|
|
menu.addItem((it: ObsidianMenuItem) =>
|
2026-04-25 15:47:49 +00:00
|
|
|
it
|
|
|
|
|
.setTitle(this.t('fileMenuRegen'))
|
|
|
|
|
.setIcon('refresh-cw')
|
2026-04-27 12:39:14 +00:00
|
|
|
.onClick(() => void this.runForFile(file, true)),
|
2026-04-25 15:47:49 +00:00
|
|
|
);
|
2026-04-26 06:07:03 +00:00
|
|
|
if (this.cacheManager.get(file.path)) {
|
2026-04-26 06:08:46 +00:00
|
|
|
menu.addItem((it: ObsidianMenuItem) =>
|
2026-04-25 15:47:49 +00:00
|
|
|
it
|
|
|
|
|
.setTitle(this.t('fileMenuClear'))
|
|
|
|
|
.setIcon('trash')
|
|
|
|
|
.onClick(async () => {
|
2026-04-26 06:07:03 +00:00
|
|
|
await this.cacheManager.delete(file.path);
|
2026-05-02 05:52:49 +00:00
|
|
|
this.refreshViewAfterCacheDelete(file.path);
|
2026-04-26 11:34:45 +00:00
|
|
|
new Notice(this.t('cacheClearedFile', { name: file.basename }));
|
2026-04-25 15:47:49 +00:00
|
|
|
}),
|
|
|
|
|
);
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 05:52:49 +00:00
|
|
|
private refreshViewAfterCacheDelete(filePath: string) {
|
|
|
|
|
const view = this.getParallelView();
|
|
|
|
|
if (view?.sourceFile?.path === filePath) view.renderEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private refreshViewAfterCacheClear() {
|
|
|
|
|
const view = this.getParallelView();
|
|
|
|
|
if (view) view.renderEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
async handleFileRename(file: TFile, oldPath: string) {
|
2026-04-25 06:12:17 +00:00
|
|
|
if (!(file instanceof TFile) || !oldPath) return;
|
|
|
|
|
const wasMarkdown = oldPath.endsWith('.md');
|
|
|
|
|
const isMarkdown = file.path.endsWith('.md');
|
|
|
|
|
if (!wasMarkdown && !isMarkdown) return;
|
|
|
|
|
if (wasMarkdown && !isMarkdown) {
|
2026-04-26 06:07:03 +00:00
|
|
|
await this.cacheManager.delete(oldPath);
|
2026-04-25 15:40:56 +00:00
|
|
|
const view = this.getParallelView();
|
2026-04-25 15:44:28 +00:00
|
|
|
if (view && view.sourceFile?.path === oldPath) view.renderEmpty();
|
2026-04-25 06:12:17 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!wasMarkdown) return;
|
2026-04-26 10:05:38 +00:00
|
|
|
await this.cacheManager.move(oldPath, file.path);
|
2026-04-25 15:40:56 +00:00
|
|
|
const view = this.getParallelView();
|
2026-04-25 06:12:17 +00:00
|
|
|
if (view?.sourceFile && (view.sourceFile.path === oldPath || view.sourceFile.path === file.path)) {
|
|
|
|
|
view.sourceFile = file;
|
|
|
|
|
await this.syncViewToFile(file);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
async handleFileDelete(file: TFile) {
|
2026-04-25 06:12:17 +00:00
|
|
|
if (!(file instanceof TFile)) return;
|
2026-04-26 06:07:03 +00:00
|
|
|
await this.cacheManager.delete(file.path);
|
2026-04-25 15:40:56 +00:00
|
|
|
const view = this.getParallelView();
|
2026-04-25 06:12:17 +00:00
|
|
|
if (view?.sourceFile?.path === file.path) view.renderEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async exportCurrentView() {
|
|
|
|
|
const active = this.getActiveView();
|
|
|
|
|
const view = await this.ensureView();
|
2026-04-26 09:41:28 +00:00
|
|
|
if (!view) return;
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
|
|
|
if (active?.file) await this.syncViewToFile(active.file);
|
|
|
|
|
}
|
|
|
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
|
|
|
new Notice(this.t('noExportContent'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
await view.exportToVault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async copyCurrentViewMarkdown() {
|
|
|
|
|
const active = this.getActiveView();
|
|
|
|
|
const view = await this.ensureView();
|
2026-04-26 09:41:28 +00:00
|
|
|
if (!view) return;
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
|
|
|
if (active?.file) await this.syncViewToFile(active.file);
|
|
|
|
|
}
|
|
|
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
|
|
|
new Notice(this.t('noCopyContent'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await copyToClipboard(
|
|
|
|
|
cardsToMarkdown(`${view.sourceFile.basename} · ${this.t('displayName')}`, view.sections),
|
|
|
|
|
this.t('copiedAllMarkdown'),
|
|
|
|
|
);
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 15:40:56 +00:00
|
|
|
/* ---------- Generation ---------- */
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
async runForActiveFile(force: boolean) {
|
2026-04-25 06:12:17 +00:00
|
|
|
const mdView = this.getActiveView();
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!mdView?.file) {
|
|
|
|
|
new Notice(this.t('openNoteFirst'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
return this.runForFile(mdView.file, force);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 05:52:49 +00:00
|
|
|
async runForFile(
|
|
|
|
|
file: TFile | null,
|
|
|
|
|
force: boolean,
|
|
|
|
|
options: RunForFileOptions = {},
|
|
|
|
|
preloadedContent?: string,
|
|
|
|
|
): Promise<RunForFileResult> {
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!file) {
|
|
|
|
|
new Notice(this.t('openNoteFirst'));
|
2026-05-02 05:52:49 +00:00
|
|
|
return 'error';
|
2026-04-25 15:47:49 +00:00
|
|
|
}
|
2026-05-02 05:52:49 +00:00
|
|
|
if (this.jobs.isPending(file.path)) {
|
2026-04-25 15:47:49 +00:00
|
|
|
new Notice(this.t('alreadyGenerating'));
|
2026-05-02 05:52:49 +00:00
|
|
|
return 'already-running';
|
2026-04-25 15:47:49 +00:00
|
|
|
}
|
2026-04-26 09:41:28 +00:00
|
|
|
if (
|
2026-05-02 05:52:49 +00:00
|
|
|
!options.skipEditConfirm &&
|
2026-04-26 09:41:28 +00:00
|
|
|
shouldConfirmRegenerate(this.cacheManager.get(file.path), force) &&
|
|
|
|
|
!(await this.confirmRegenerateEditedCards())
|
|
|
|
|
) {
|
2026-04-25 15:47:49 +00:00
|
|
|
new Notice(this.t('regenerateCancelled'));
|
2026-05-02 05:52:49 +00:00
|
|
|
return 'cancelled';
|
2026-04-25 15:47:49 +00:00
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
|
2026-04-25 15:40:56 +00:00
|
|
|
let view: ParallelReaderView | null = null;
|
2026-05-02 05:52:49 +00:00
|
|
|
let outcome: RunForFileResult = 'error';
|
|
|
|
|
|
|
|
|
|
await this.jobs
|
2026-04-25 15:47:49 +00:00
|
|
|
.start(file.path, async (job) => {
|
|
|
|
|
job.setPhase('reading');
|
2026-05-02 05:52:49 +00:00
|
|
|
const content = preloadedContent ?? (await this.app.vault.read(file));
|
2026-04-25 15:47:49 +00:00
|
|
|
job.throwIfCancelled();
|
|
|
|
|
if (!content.trim()) {
|
|
|
|
|
new Notice(this.t('emptyNote'));
|
2026-05-02 05:52:49 +00:00
|
|
|
outcome = 'empty';
|
2026-04-25 06:12:17 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 05:52:49 +00:00
|
|
|
if (options.silentView) {
|
|
|
|
|
view = this.getParallelView() ?? null;
|
|
|
|
|
} else {
|
|
|
|
|
view = await this.ensureView();
|
|
|
|
|
if (!view) {
|
|
|
|
|
outcome = 'no-view';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-25 15:47:49 +00:00
|
|
|
job.throwIfCancelled();
|
|
|
|
|
|
2026-05-02 06:10:49 +00:00
|
|
|
// In non-silent mode the user explicitly asked to generate THIS file —
|
|
|
|
|
// bind the view to it (so subsequent viewIsShowingFile guards pass and
|
|
|
|
|
// streaming/loadFor render correctly even if the panel was previously
|
|
|
|
|
// showing a different note).
|
|
|
|
|
const shouldRender = !options.silentView || this.viewIsShowingFile(view, file);
|
|
|
|
|
|
2026-04-25 15:47:49 +00:00
|
|
|
job.setPhase('cache-check');
|
|
|
|
|
if (!force) {
|
2026-04-26 06:07:03 +00:00
|
|
|
const entry = this.cacheManager.get(file.path);
|
|
|
|
|
if (entry && cacheEntryMatches(entry, content, this.settings)) {
|
2026-04-26 11:34:45 +00:00
|
|
|
this.cacheTouch(file.path);
|
2026-05-02 06:10:49 +00:00
|
|
|
if (view && shouldRender && this.activeFileStillMatches(file)) {
|
2026-05-02 05:52:49 +00:00
|
|
|
view.loadFor(file, resolveCardAnchors(content, entry.cards), false);
|
|
|
|
|
}
|
|
|
|
|
outcome = 'cached';
|
2026-04-25 15:47:49 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 06:10:49 +00:00
|
|
|
if (view && shouldRender) view.renderLoading(file, this.t('loadingGenerating'));
|
2026-04-25 15:47:49 +00:00
|
|
|
const maxDocChars = Number(this.settings.maxDocChars) || DEFAULT_SETTINGS.maxDocChars;
|
|
|
|
|
if (content.length > maxDocChars) new Notice(this.t('longNoteTruncated', { count: maxDocChars }));
|
|
|
|
|
new Notice(this.t('generatingNotice'));
|
|
|
|
|
|
|
|
|
|
job.setPhase('generating');
|
2026-04-27 05:50:05 +00:00
|
|
|
const sections = await summarizeDocument(content, this.settings, job, this.streamProgressFor(view, file));
|
2026-04-25 15:47:49 +00:00
|
|
|
job.throwIfCancelled();
|
|
|
|
|
if (sections.length === 0) {
|
2026-05-02 06:10:49 +00:00
|
|
|
if (view && shouldRender) view.renderError(file, this.t('noCardsReturned'));
|
2026-04-25 15:47:49 +00:00
|
|
|
new Notice(this.t('noCardsReturned'));
|
2026-05-02 05:52:49 +00:00
|
|
|
outcome = 'empty';
|
2026-04-25 15:47:49 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets }));
|
|
|
|
|
job.setPhase('saving');
|
2026-04-26 06:07:03 +00:00
|
|
|
await this.cacheManager.put(file.path, content, rawCards, this.settings);
|
2026-04-25 15:47:49 +00:00
|
|
|
job.throwIfCancelled();
|
2026-05-02 06:10:49 +00:00
|
|
|
if (view && shouldRender) view.loadFor(file, sections, false);
|
2026-04-25 15:47:49 +00:00
|
|
|
const unanchored = sections.filter((s) => s.startLine < 0).length;
|
|
|
|
|
new Notice(
|
|
|
|
|
this.t('generationDone', {
|
|
|
|
|
count: sections.length,
|
|
|
|
|
suffix: unanchored ? this.t('unanchoredSuffix', { count: unanchored }) : '',
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-02 05:52:49 +00:00
|
|
|
outcome = 'generated';
|
2026-04-25 15:47:49 +00:00
|
|
|
})
|
2026-04-29 05:51:33 +00:00
|
|
|
.catch((e: unknown) => {
|
|
|
|
|
this.handleGenerationError(e, file, view);
|
2026-05-02 05:52:49 +00:00
|
|
|
if (e instanceof GenerationJobAlreadyRunningError) outcome = 'already-running';
|
|
|
|
|
else if (e instanceof GenerationJobCancelledError) outcome = 'cancelled';
|
|
|
|
|
else outcome = 'error';
|
2026-04-29 05:51:33 +00:00
|
|
|
if (options.rethrowErrors) throw e;
|
|
|
|
|
});
|
2026-05-02 05:52:49 +00:00
|
|
|
|
|
|
|
|
return outcome;
|
2026-04-27 05:50:05 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private streamProgressFor(
|
|
|
|
|
view: ParallelReaderView | null,
|
|
|
|
|
file: TFile,
|
|
|
|
|
): ((progress: StreamProgress) => void) | undefined {
|
|
|
|
|
if (!view) return undefined;
|
|
|
|
|
return (progress: StreamProgress) => {
|
|
|
|
|
if (!progress.done && this.viewIsShowingFile(view, file)) {
|
|
|
|
|
view.renderStreamingPreview(file, progress.accumulated);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null) {
|
|
|
|
|
if (e instanceof GenerationJobAlreadyRunningError) {
|
2026-05-02 04:50:35 +00:00
|
|
|
new Notice(this.t('generationAlreadyRunning'));
|
2026-04-27 05:50:05 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e instanceof GenerationJobCancelledError) {
|
|
|
|
|
if (view && this.viewIsShowingFile(view, file)) view.renderError(file, this.t('cancelledError'));
|
|
|
|
|
new Notice(this.t('cancelled'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const kind = classifyGenerationError(e);
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
|
|
|
console.error(e);
|
|
|
|
|
if (view && this.viewIsShowingFile(view, file)) view.renderError(file, msg);
|
2026-05-05 12:19:57 +00:00
|
|
|
showGenerationError(
|
|
|
|
|
{
|
|
|
|
|
app: this.app,
|
|
|
|
|
settings: this.settings,
|
|
|
|
|
openSettings: () => this.openPluginSettings(),
|
|
|
|
|
},
|
|
|
|
|
kind,
|
|
|
|
|
e,
|
|
|
|
|
msg,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private openPluginSettings(): void {
|
|
|
|
|
// Obsidian doesn't expose a typed API for opening a specific plugin tab; use the documented
|
|
|
|
|
// (but technically internal) setting/openTabById path with safe fallbacks.
|
|
|
|
|
const setting = (this.app as unknown as { setting?: { open: () => void; openTabById: (id: string) => void } })
|
|
|
|
|
.setting;
|
|
|
|
|
if (!setting) return;
|
|
|
|
|
try {
|
|
|
|
|
setting.open();
|
|
|
|
|
setting.openTabById(this.manifest.id);
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
console.warn('[parallel-reader] failed to open settings tab', err);
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 06:18:58 +00:00
|
|
|
async runBatchForFolder() {
|
2026-04-26 10:10:17 +00:00
|
|
|
if (this.activeBatch) {
|
|
|
|
|
new Notice(this.t('batchAlreadyRunning'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 05:58:10 +00:00
|
|
|
const folderPath = await promptForBatchFolder(
|
|
|
|
|
this.app,
|
|
|
|
|
this.t('batchSelectFolder'),
|
|
|
|
|
this.t('batchFolderPrompt'),
|
|
|
|
|
this.t('batchFolderConfirm'),
|
2026-04-29 06:13:47 +00:00
|
|
|
this.t('batchFolderCancel'),
|
2026-04-29 05:58:10 +00:00
|
|
|
);
|
2026-04-26 06:18:58 +00:00
|
|
|
if (folderPath === null) return;
|
2026-04-26 10:12:38 +00:00
|
|
|
const validation = validateBatchFolderInput(folderPath, (path) => {
|
|
|
|
|
const target = this.app.vault.getAbstractFileByPath(path);
|
|
|
|
|
return !!target && !(target instanceof TFile);
|
|
|
|
|
});
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
const noticeKey = validation.reason === 'unsafe' ? 'batchInvalidFolderInput' : 'batchFolderNotFound';
|
|
|
|
|
new Notice(this.t(noticeKey, { path: validation.folderPath || folderPath }));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const allFiles = selectBatchFiles(this.app.vault.getMarkdownFiles(), validation.folderPath);
|
2026-04-26 06:18:58 +00:00
|
|
|
if (allFiles.length === 0) {
|
|
|
|
|
new Notice(this.t('batchNoMarkdown'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 10:10:17 +00:00
|
|
|
const batch = createBatchRunState();
|
|
|
|
|
this.activeBatch = batch;
|
2026-04-26 09:54:35 +00:00
|
|
|
let stats = createBatchStats(allFiles.length);
|
2026-04-26 10:10:17 +00:00
|
|
|
try {
|
|
|
|
|
for (let i = 0; i < allFiles.length; i++) {
|
|
|
|
|
if (batch.cancelled) break;
|
|
|
|
|
const file = allFiles[i];
|
|
|
|
|
markBatchFileRunning(batch, file.path);
|
|
|
|
|
new Notice(this.t('batchProgress', batchProgressVars(i, allFiles.length)));
|
|
|
|
|
const content = await this.app.vault.read(file);
|
|
|
|
|
if (batch.cancelled) break;
|
|
|
|
|
const entry = this.cacheManager.get(file.path);
|
|
|
|
|
if (shouldSkipBatchFile(entry, content, this.settings)) {
|
|
|
|
|
stats = recordBatchSkip(stats);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-27 03:15:35 +00:00
|
|
|
try {
|
2026-05-02 05:52:49 +00:00
|
|
|
const result = await this.runForFile(
|
|
|
|
|
file,
|
|
|
|
|
false,
|
|
|
|
|
{ rethrowErrors: true, silentView: true, skipEditConfirm: true },
|
|
|
|
|
content,
|
|
|
|
|
);
|
2026-04-29 05:51:33 +00:00
|
|
|
if (batch.cancelled) break;
|
2026-05-02 05:52:49 +00:00
|
|
|
if (result === 'generated') stats = recordBatchProcessed(stats);
|
|
|
|
|
else if (result === 'cached' || result === 'already-running') stats = recordBatchSkip(stats);
|
|
|
|
|
else stats = recordBatchError(stats);
|
2026-04-27 03:15:35 +00:00
|
|
|
} catch (e: unknown) {
|
2026-04-29 05:51:33 +00:00
|
|
|
if (batch.cancelled && e instanceof GenerationJobCancelledError) break;
|
2026-04-27 03:15:35 +00:00
|
|
|
stats = recordBatchError(stats);
|
|
|
|
|
console.error('[parallel-reader] batch error for', file.path, e);
|
|
|
|
|
}
|
2026-04-26 06:18:58 +00:00
|
|
|
}
|
2026-04-26 10:10:17 +00:00
|
|
|
} finally {
|
2026-05-02 05:52:49 +00:00
|
|
|
if (batch.cancelled) this.jobs.cancelAllWaiters();
|
2026-04-26 10:10:17 +00:00
|
|
|
if (this.activeBatch === batch) this.activeBatch = null;
|
2026-04-26 06:18:58 +00:00
|
|
|
}
|
2026-04-26 10:10:17 +00:00
|
|
|
const batchVars: Record<string, string | number> = {
|
|
|
|
|
processed: stats.processed,
|
|
|
|
|
skipped: stats.skipped,
|
2026-04-27 03:15:35 +00:00
|
|
|
errors: stats.errors,
|
2026-04-26 10:10:17 +00:00
|
|
|
total: stats.total,
|
|
|
|
|
};
|
|
|
|
|
if (batch.cancelled) new Notice(this.t('batchCancelled', batchVars));
|
|
|
|
|
else new Notice(this.t('batchDone', batchVars));
|
2026-04-26 06:18:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-25 15:40:56 +00:00
|
|
|
/* ---------- Scroll sync ---------- */
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
async syncViewToFile(file: TFile) {
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!file?.path?.endsWith('.md')) return;
|
2026-04-25 06:12:17 +00:00
|
|
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
|
2026-04-25 15:40:56 +00:00
|
|
|
if (leaves.length === 0) return;
|
2026-04-25 06:12:17 +00:00
|
|
|
const view = leaves[0].view as ParallelReaderView;
|
2026-04-25 15:40:56 +00:00
|
|
|
if (!view || !this.activeFileStillMatches(file)) return;
|
2026-04-26 06:07:03 +00:00
|
|
|
const entry = this.cacheManager.get(file.path);
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!entry) {
|
2026-04-26 11:34:45 +00:00
|
|
|
view.loadFor(file, [], false);
|
2026-04-25 15:47:49 +00:00
|
|
|
view.renderEmptyWithHint(file);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
const content = await this.app.vault.read(file);
|
|
|
|
|
if (!this.activeFileStillMatches(file)) return;
|
|
|
|
|
const stale = !cacheEntryMatches(entry, content, this.settings);
|
2026-04-26 11:34:45 +00:00
|
|
|
this.cacheTouch(file.path);
|
2026-04-27 05:15:04 +00:00
|
|
|
view.loadFor(file, resolveCardAnchors(content, entry.cards), stale);
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bindScrollSync() {
|
2026-04-25 15:47:49 +00:00
|
|
|
if (this._scrollDispose) {
|
|
|
|
|
this._scrollDispose();
|
|
|
|
|
this._scrollDispose = null;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
const mdView = this.getActiveView();
|
|
|
|
|
if (!mdView) return;
|
|
|
|
|
const editor = mdView.editor;
|
2026-04-26 06:08:46 +00:00
|
|
|
const cm = editor && (editor as unknown as ObsidianEditorWithCm).cm;
|
2026-04-25 15:47:49 +00:00
|
|
|
const scrollDom = cm?.scrollDOM ? cm.scrollDOM : mdView.contentEl.querySelector('.cm-scroller');
|
2026-04-25 06:12:17 +00:00
|
|
|
if (!scrollDom) return;
|
2026-04-25 11:44:39 +00:00
|
|
|
const handler = createRafThrottledHandler(() => this.handleEditorScroll(mdView));
|
2026-04-25 06:12:17 +00:00
|
|
|
scrollDom.addEventListener('scroll', handler, { passive: true });
|
2026-04-25 15:47:49 +00:00
|
|
|
this._scrollDispose = () => {
|
|
|
|
|
handler.cancel();
|
|
|
|
|
scrollDom.removeEventListener('scroll', handler);
|
|
|
|
|
};
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
handleEditorScroll(mdView: MarkdownView) {
|
2026-04-25 15:40:56 +00:00
|
|
|
const view = this.getParallelView();
|
2026-04-25 06:12:17 +00:00
|
|
|
if (!view || !mdView.file || view.sourceFile?.path !== mdView.file.path) return;
|
|
|
|
|
const editor = mdView.editor;
|
2026-04-26 06:08:46 +00:00
|
|
|
const cm = editor && (editor as unknown as ObsidianEditorWithCm).cm;
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!cm?.scrollDOM) return;
|
2026-04-25 15:40:56 +00:00
|
|
|
const rect = cm.scrollDOM.getBoundingClientRect();
|
2026-04-25 11:52:27 +00:00
|
|
|
const topY = visibleTopProbeY(rect);
|
2026-04-25 06:12:17 +00:00
|
|
|
let topLine = 0;
|
|
|
|
|
try {
|
|
|
|
|
const pos = cm.posAtCoords({ x: rect.left + 20, y: topY });
|
2026-04-25 15:40:56 +00:00
|
|
|
if (pos != null) topLine = cm.state.doc.lineAt(pos).number - 1;
|
2026-05-09 05:38:29 +00:00
|
|
|
} catch {
|
2026-04-27 05:18:28 +00:00
|
|
|
/* posAtCoords/lineAt can throw during editor state transitions — safe to ignore */
|
2026-04-25 15:47:49 +00:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
let activeIdx = -1;
|
|
|
|
|
for (let i = 0; i < view.sections.length; i++) {
|
|
|
|
|
const s = view.sections[i];
|
|
|
|
|
if (s.startLine < 0) continue;
|
|
|
|
|
if (s.startLine <= topLine) activeIdx = i;
|
|
|
|
|
else break;
|
|
|
|
|
}
|
|
|
|
|
view.setActiveSection(activeIdx);
|
|
|
|
|
}
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
findLeafForFile(file: TFile | null) {
|
2026-04-25 06:12:17 +00:00
|
|
|
if (!file) return null;
|
2026-04-25 15:40:56 +00:00
|
|
|
for (const leaf of this.app.workspace.getLeavesOfType('markdown')) {
|
2026-04-26 06:08:46 +00:00
|
|
|
const v = leaf.view as { file?: TFile };
|
2026-04-25 15:47:49 +00:00
|
|
|
if (v?.file && v.file.path === file.path) return leaf;
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
refactor: enable noImplicitAny and add type annotations
Turn on noImplicitAny in tsconfig.json. Add explicit type annotations
to all 137 previously untyped parameters across main.ts, view.ts,
modal.ts, settings-tab.ts, providers.ts, cli.ts, anchor.ts, scroll.ts,
ui-helpers.ts, vault.ts, and settings.ts.
Change-Id: Icc5a69ab57113e9f6dc08dd97e3a8fa5e7e24937
2026-04-26 00:58:41 +00:00
|
|
|
async scrollEditorToLine(line: number, file: TFile | null) {
|
2026-04-25 06:12:17 +00:00
|
|
|
let leaf = file ? this.findLeafForFile(file) : null;
|
2026-04-25 15:47:49 +00:00
|
|
|
if (!leaf && file) {
|
|
|
|
|
leaf = this.app.workspace.getLeaf('tab');
|
|
|
|
|
await leaf.openFile(file, { active: false });
|
|
|
|
|
}
|
|
|
|
|
if (!leaf) {
|
|
|
|
|
const active = this.getActiveView();
|
|
|
|
|
if (active) leaf = active.leaf;
|
|
|
|
|
}
|
|
|
|
|
if (!leaf) {
|
|
|
|
|
new Notice(this.t('noEditor'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
this.app.workspace.setActiveLeaf(leaf, { focus: true });
|
|
|
|
|
const mdView = leaf.view;
|
|
|
|
|
if (!(mdView instanceof MarkdownView)) return;
|
|
|
|
|
mdView.setEphemeralState({ line });
|
|
|
|
|
if (mdView.editor) {
|
2026-04-25 15:47:49 +00:00
|
|
|
try {
|
|
|
|
|
mdView.editor.setCursor({ line, ch: 0 });
|
|
|
|
|
mdView.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true);
|
2026-05-09 05:38:29 +00:00
|
|
|
} catch {
|
2026-04-27 05:18:28 +00:00
|
|
|
/* setCursor/scrollIntoView can throw during view transitions — safe to ignore */
|
2026-04-25 15:47:49 +00:00
|
|
|
}
|
2026-04-25 06:12:17 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default ParallelReaderPlugin;
|