mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
refactor: extract View, Modal, and SettingsTab from main.ts
Move ParallelReaderView to src/view.ts (341 lines), CardEditModal to src/modal.ts (56 lines), ParallelReaderSettingTab to src/settings-tab.ts (370 lines). Add PluginHost interface in types.ts to break circular deps. main.ts reduced from 1613 to 588 lines. Change-Id: I90902e914f162ff92b9a7f7c190b4d397a2c8c12
This commit is contained in:
parent
f3afc4fbe0
commit
532cd31ef4
7 changed files with 937 additions and 1170 deletions
52
main.js
52
main.js
File diff suppressed because one or more lines are too long
56
src/modal.ts
Normal file
56
src/modal.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use strict';
|
||||
|
||||
import { Modal } from 'obsidian';
|
||||
import type { ResolvedCard, CardPatch, PluginHost } from './types';
|
||||
import { addTextButton } from './ui-helpers';
|
||||
|
||||
export class CardEditModal extends Modal {
|
||||
plugin: PluginHost;
|
||||
card: ResolvedCard;
|
||||
onSave: (patch: CardPatch) => void | Promise<void>;
|
||||
|
||||
constructor(app, plugin: PluginHost, card: ResolvedCard, onSave: (patch: CardPatch) => void | Promise<void>) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.card = card || ({} as ResolvedCard);
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl('h2', { text: this.plugin.t('editCardTitle') });
|
||||
|
||||
const titleInput = this.createLabeledInput(contentEl, this.plugin.t('editCardTitleField'), this.card.title || '');
|
||||
const gistInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardGistField'), this.card.gist || '', 3);
|
||||
const bulletsInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardBulletsField'), (this.card.bullets || []).join('\n'), 8);
|
||||
|
||||
const actions = contentEl.createDiv({ cls: 'parallel-reader-modal-actions' });
|
||||
addTextButton(actions, null, this.plugin.t('editCardCancel'), () => this.close(), 'parallel-reader-text-button');
|
||||
addTextButton(actions, null, this.plugin.t('editCardSave'), async () => {
|
||||
await this.onSave({
|
||||
title: titleInput.value.trim() || this.card.title || '',
|
||||
gist: gistInput.value.trim(),
|
||||
bullets: bulletsInput.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean),
|
||||
});
|
||||
this.close();
|
||||
}, 'parallel-reader-text-button');
|
||||
}
|
||||
|
||||
createLabeledInput(parent, label: string, value: string) {
|
||||
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
|
||||
wrapper.createEl('label', { text: label });
|
||||
const input = wrapper.createEl('input', { attr: { type: 'text' } });
|
||||
input.value = value;
|
||||
return input;
|
||||
}
|
||||
|
||||
createLabeledTextarea(parent, label: string, value: string, rows: number) {
|
||||
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
|
||||
wrapper.createEl('label', { text: label });
|
||||
const textarea = wrapper.createEl('textarea');
|
||||
textarea.rows = rows;
|
||||
textarea.value = value;
|
||||
return textarea;
|
||||
}
|
||||
}
|
||||
370
src/settings-tab.ts
Normal file
370
src/settings-tab.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
'use strict';
|
||||
|
||||
import { PluginSettingTab, Setting, Notice, requestUrl } from 'obsidian';
|
||||
import type { PluginHost } from './types';
|
||||
import { resolveCliPath, runCli } from './cli';
|
||||
import { testApiBackend } from './providers';
|
||||
import {
|
||||
API_AUTH_TYPES,
|
||||
API_FORMATS,
|
||||
API_PROVIDER_PRESETS,
|
||||
DEFAULT_SETTINGS,
|
||||
DEFAULT_MAX_CACHE_ENTRIES,
|
||||
PROMPT_LANGUAGES,
|
||||
UI_LANGUAGES,
|
||||
applyApiProviderPreset,
|
||||
getApiFormat,
|
||||
getApiPreset,
|
||||
isApiBackend,
|
||||
} from './settings';
|
||||
|
||||
async function testBackend(settings) {
|
||||
if (settings.backend === 'codex') {
|
||||
const cmd = resolveCliPath('codex', settings.cliPath);
|
||||
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
|
||||
return `codex @ ${cmd}\n${stdout.trim()}`;
|
||||
}
|
||||
if (settings.backend === 'claude-code') {
|
||||
const cmd = resolveCliPath('claude', settings.cliPath);
|
||||
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
|
||||
return `claude @ ${cmd}\n${stdout.trim()}`;
|
||||
}
|
||||
if (isApiBackend(settings.backend)) {
|
||||
return testApiBackend(requestUrl, settings);
|
||||
}
|
||||
throw new Error('Unknown backend: ' + settings.backend);
|
||||
}
|
||||
|
||||
export class ParallelReaderSettingTab extends PluginSettingTab {
|
||||
plugin: PluginHost;
|
||||
|
||||
constructor(app, plugin: PluginHost) {
|
||||
super(app, plugin as any);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display() {
|
||||
const { containerEl } = this;
|
||||
const tr = (key, vars?) => this.plugin.t(key, vars);
|
||||
containerEl.empty();
|
||||
containerEl.createEl('h2', { text: tr('settingsTitle') });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingUiLanguageName'))
|
||||
.setDesc(tr('settingUiLanguageDesc'))
|
||||
.addDropdown(d => {
|
||||
for (const [id, label] of Object.entries(UI_LANGUAGES)) {
|
||||
d.addOption(id, label);
|
||||
}
|
||||
return d
|
||||
.setValue(this.plugin.settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.uiLanguage = v;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingBackendName'))
|
||||
.setDesc(tr('settingBackendDesc'))
|
||||
.addDropdown(d => d
|
||||
.addOption('claude-code', 'Claude Code CLI')
|
||||
.addOption('codex', 'Codex CLI')
|
||||
.addOption('api', 'API / Provider')
|
||||
.addOption('anthropic-api', 'Anthropic API (legacy)')
|
||||
.setValue(this.plugin.settings.backend)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.backend = v;
|
||||
if (v === 'api' && !this.plugin.settings.apiBaseUrl) {
|
||||
applyApiProviderPreset(this.plugin.settings, this.plugin.settings.apiProvider || 'anthropic');
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
}));
|
||||
|
||||
const apiBackend = isApiBackend(this.plugin.settings.backend);
|
||||
|
||||
if (!apiBackend) {
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingCliPathName'))
|
||||
.setDesc(tr('settingCliPathDesc'))
|
||||
.addText(t => t
|
||||
.setPlaceholder(tr('settingCliPathPlaceholder'))
|
||||
.setValue(this.plugin.settings.cliPath)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.cliPath = v.trim();
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingCliTimeoutName'))
|
||||
.addText(t => t
|
||||
.setValue(String(this.plugin.settings.cliTimeoutMs))
|
||||
.onChange(async v => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
this.plugin.settings.cliTimeoutMs = n;
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
containerEl.createEl('h3', { text: tr('apiProviderHeader') });
|
||||
|
||||
const preset = getApiPreset(this.plugin.settings);
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingProviderPresetName'))
|
||||
.setDesc(tr('settingProviderPresetDesc'))
|
||||
.addDropdown(d => {
|
||||
for (const [id, entry] of Object.entries(API_PROVIDER_PRESETS)) {
|
||||
d.addOption(id, entry.label);
|
||||
}
|
||||
return d
|
||||
.setValue(this.plugin.settings.apiProvider)
|
||||
.onChange(async v => {
|
||||
applyApiProviderPreset(this.plugin.settings, v);
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingApiFormatName'))
|
||||
.setDesc(tr('settingApiFormatDesc'))
|
||||
.addDropdown(d => {
|
||||
for (const [id, entry] of Object.entries(API_FORMATS)) {
|
||||
d.addOption(id, entry.label);
|
||||
}
|
||||
return d
|
||||
.setValue(getApiFormat(this.plugin.settings))
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.apiFormat = v;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingBaseUrlName'))
|
||||
.setDesc(tr('settingBaseUrlDesc'))
|
||||
.addText(t => t
|
||||
.setPlaceholder(
|
||||
(this.plugin.settings.apiProvider || '').startsWith('custom-')
|
||||
? 'https://your-provider.example/v1'
|
||||
: (preset.baseUrl || API_FORMATS[getApiFormat(this.plugin.settings)].defaultBaseUrl)
|
||||
)
|
||||
.setValue(this.plugin.settings.apiBaseUrl)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.apiBaseUrl = v.trim();
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingApiKeyName'))
|
||||
.setDesc(tr('settingApiKeyDesc'))
|
||||
.addText(t => {
|
||||
t.inputEl.type = 'password';
|
||||
return t
|
||||
.setPlaceholder('sk-...')
|
||||
.setValue(this.plugin.settings.apiKey)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.apiKey = v.trim();
|
||||
this.plugin.saveSettingsDebounced();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingApiKeyEnvName'))
|
||||
.setDesc(tr('settingApiKeyEnvDesc'))
|
||||
.addText(t => t
|
||||
.setPlaceholder(preset.envVar || 'OPENAI_API_KEY')
|
||||
.setValue(this.plugin.settings.apiKeyEnvVar)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.apiKeyEnvVar = v.trim();
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingAuthTypeName'))
|
||||
.setDesc(tr('settingAuthTypeDesc'))
|
||||
.addDropdown(d => {
|
||||
for (const [id, label] of Object.entries(API_AUTH_TYPES)) {
|
||||
d.addOption(id, label);
|
||||
}
|
||||
return d
|
||||
.setValue(this.plugin.settings.apiAuthType || 'auto')
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.apiAuthType = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingHeadersName'))
|
||||
.setDesc(tr('settingHeadersDesc'))
|
||||
.addTextArea(t => t
|
||||
.setPlaceholder('cf-aig-authorization: Bearer ...')
|
||||
.setValue(this.plugin.settings.apiHeaders)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.apiHeaders = v;
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingMaxTokensName'))
|
||||
.addText(t => t
|
||||
.setValue(String(this.plugin.settings.apiMaxTokens))
|
||||
.onChange(async v => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
this.plugin.settings.apiMaxTokens = n;
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingModelName'))
|
||||
.setDesc(apiBackend
|
||||
? tr('settingModelDescApi')
|
||||
: tr('settingModelDescCli'))
|
||||
.addText(t => t
|
||||
.setPlaceholder(apiBackend ? (getApiPreset(this.plugin.settings).model || 'model-id') : DEFAULT_SETTINGS.model)
|
||||
.setValue(this.plugin.settings.model)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.model = v.trim() || (apiBackend ? '' : DEFAULT_SETTINGS.model);
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingMaxInputName'))
|
||||
.setDesc(tr('settingMaxInputDesc'))
|
||||
.addText(t => t
|
||||
.setValue(String(this.plugin.settings.maxDocChars || DEFAULT_SETTINGS.maxDocChars))
|
||||
.onChange(async v => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n >= 1000) {
|
||||
this.plugin.settings.maxDocChars = n;
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}
|
||||
}));
|
||||
|
||||
containerEl.createEl('h3', { text: tr('promptHeader') });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingPromptLanguageName'))
|
||||
.setDesc(tr('settingPromptLanguageDesc'))
|
||||
.addDropdown(d => {
|
||||
for (const [id, label] of Object.entries(PROMPT_LANGUAGES)) {
|
||||
d.addOption(id, label);
|
||||
}
|
||||
return d
|
||||
.setValue(this.plugin.settings.promptLanguage || DEFAULT_SETTINGS.promptLanguage)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.promptLanguage = v;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingCardRangeName'))
|
||||
.setDesc(tr('settingCardRangeDesc'))
|
||||
.addText(t => t
|
||||
.setPlaceholder('min')
|
||||
.setValue(String(this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards))
|
||||
.onChange(async v => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
this.plugin.settings.minCards = n;
|
||||
if (this.plugin.settings.maxCards < n) this.plugin.settings.maxCards = n;
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}
|
||||
}))
|
||||
.addText(t => t
|
||||
.setPlaceholder('max')
|
||||
.setValue(String(this.plugin.settings.maxCards || DEFAULT_SETTINGS.maxCards))
|
||||
.onChange(async v => {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
this.plugin.settings.maxCards = Math.max(n, this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards);
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingCustomPromptName'))
|
||||
.setDesc(tr('settingCustomPromptDesc'))
|
||||
.addTextArea(t => {
|
||||
t.inputEl.rows = 8;
|
||||
return t
|
||||
.setPlaceholder(tr('settingCustomPromptPlaceholder'))
|
||||
.setValue(this.plugin.settings.customSystemPrompt || '')
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.customSystemPrompt = v;
|
||||
this.plugin.saveSettingsDebounced();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingTestBackendName'))
|
||||
.setDesc(apiBackend ? tr('settingTestBackendDescApi') : tr('settingTestBackendDescCli'))
|
||||
.addButton(b => b
|
||||
.setButtonText('Test')
|
||||
.onClick(async () => {
|
||||
try {
|
||||
const result = await testBackend(this.plugin.settings);
|
||||
new Notice(`✓ ${result.slice(0, 180)}`, 8000);
|
||||
} catch (e) {
|
||||
new Notice(tr('backendTestFailed', { error: e.message }), 10000);
|
||||
}
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingExportFolderName'))
|
||||
.setDesc(tr('settingExportFolderDesc'))
|
||||
.addText(t => t
|
||||
.setValue(this.plugin.settings.exportFolder)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.exportFolder = v.trim() || DEFAULT_SETTINGS.exportFolder;
|
||||
this.plugin.saveSettingsDebounced();
|
||||
}));
|
||||
|
||||
containerEl.createEl('h3', { text: tr('cacheHeader') });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(tr('settingMaxCacheName'))
|
||||
.setDesc(tr('settingMaxCacheDesc'))
|
||||
.addText(t => {
|
||||
t.setValue(String(this.plugin.settings.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES));
|
||||
const commit = async () => {
|
||||
const n = parseInt(t.getValue(), 10);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
this.plugin.settings.maxCacheEntries = n;
|
||||
await this.plugin.saveSettings();
|
||||
const removed = await this.plugin.pruneCacheIfNeeded();
|
||||
if (removed.length > 0) new Notice(tr('cachePruned', { count: removed.length }));
|
||||
this.display();
|
||||
}
|
||||
};
|
||||
t.inputEl.addEventListener('change', commit);
|
||||
t.inputEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') t.inputEl.blur();
|
||||
});
|
||||
return t;
|
||||
});
|
||||
|
||||
const cacheCount = Object.keys(this.plugin.cache).length;
|
||||
new Setting(containerEl)
|
||||
.setName(tr('cachedNotesName', { count: cacheCount }))
|
||||
.setDesc(tr('cachedNotesDesc'))
|
||||
.addButton(b => b
|
||||
.setButtonText(tr('clearAllCacheButton'))
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
const n = Object.keys(this.plugin.cache).length;
|
||||
await this.plugin.cacheClear();
|
||||
new Notice(tr('cacheClearedAll', { count: n }));
|
||||
this.display();
|
||||
}));
|
||||
}
|
||||
}
|
||||
25
src/types.ts
25
src/types.ts
|
|
@ -112,3 +112,28 @@ export interface PromptPair {
|
|||
system: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
/* ---------- Plugin host interface ---------- */
|
||||
|
||||
/**
|
||||
* Minimal interface that extracted UI classes (View, Modal, SettingsTab)
|
||||
* use to call back into the plugin. Avoids circular imports between
|
||||
* main.ts and the extracted modules.
|
||||
*/
|
||||
export interface PluginHost {
|
||||
app: { vault: any; workspace: any };
|
||||
settings: PluginSettings;
|
||||
cache: Record<string, CacheEntry>;
|
||||
manifest?: { id: string };
|
||||
t(key: string, vars?: Record<string, string | number>): string;
|
||||
isGeneratingFile(file: any): boolean;
|
||||
cancelGenerationForFile(file: any): boolean;
|
||||
runForFile(file: any, force: boolean): Promise<void>;
|
||||
copyCurrentViewMarkdown(): Promise<void>;
|
||||
scrollEditorToLine(line: number, file: any): Promise<void>;
|
||||
cacheReplaceCards(filePath: string, cards: ResolvedCard[]): Promise<boolean>;
|
||||
saveSettings(): Promise<void>;
|
||||
saveSettingsDebounced(delayMs?: number): void;
|
||||
cacheClear(): Promise<void>;
|
||||
pruneCacheIfNeeded(): Promise<string[]>;
|
||||
}
|
||||
|
|
|
|||
341
src/view.ts
Normal file
341
src/view.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
'use strict';
|
||||
|
||||
import { ItemView, Notice, TFile, Menu, MarkdownRenderer } from 'obsidian';
|
||||
import type { ResolvedCard, CardPatch, PluginHost } from './types';
|
||||
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards';
|
||||
import { cardToMarkdown, cardToPlain, cardsToMarkdown } from './markdown';
|
||||
import { activeSectionLine, nextCardIndex } from './navigation';
|
||||
import { normalizeVaultPath } from './vault';
|
||||
import { ensureVaultFolder } from './vault';
|
||||
import { addIconButton, addTextButton, copyToClipboard } from './ui-helpers';
|
||||
import { CardEditModal } from './modal';
|
||||
|
||||
export const VIEW_TYPE_PARALLEL = 'parallel-reader-view';
|
||||
|
||||
export class ParallelReaderView extends ItemView {
|
||||
plugin: PluginHost;
|
||||
sections: ResolvedCard[];
|
||||
sourceFile: TFile | null;
|
||||
cards: HTMLElement[];
|
||||
activeIdx: number;
|
||||
stale: boolean;
|
||||
loadingMessage: string;
|
||||
errorMessage: string;
|
||||
|
||||
constructor(leaf, plugin: PluginHost) {
|
||||
super(leaf);
|
||||
this.plugin = plugin;
|
||||
this.sections = [];
|
||||
this.sourceFile = null;
|
||||
this.cards = [];
|
||||
this.activeIdx = -1;
|
||||
this.loadingMessage = '';
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
getViewType() { return VIEW_TYPE_PARALLEL; }
|
||||
getDisplayText() { return this.plugin.t('displayName'); }
|
||||
getIcon() { return 'book-open'; }
|
||||
|
||||
onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass('parallel-reader-container');
|
||||
container.setAttr('tabindex', '0');
|
||||
container.addEventListener('keydown', e => this.handleKeydown(e as KeyboardEvent));
|
||||
this.renderEmpty();
|
||||
this.focusSummaryPane();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
renderEmpty() {
|
||||
this.sourceFile = null;
|
||||
this.sections = [];
|
||||
this.stale = false;
|
||||
this.loadingMessage = '';
|
||||
this.errorMessage = '';
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
const hint = container.createDiv({ cls: 'parallel-reader-empty' });
|
||||
hint.createEl('h3', { text: this.plugin.t('appTitle') });
|
||||
hint.createEl('p', { text: this.plugin.t('emptyOpenNote') });
|
||||
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
|
||||
}
|
||||
|
||||
focusSummaryPane() {
|
||||
const container = this.containerEl.children[1] as HTMLElement;
|
||||
if (!container || typeof container.focus !== 'function') return false;
|
||||
container.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadFor(file, sections, stale) {
|
||||
this.sourceFile = file;
|
||||
this.sections = sections;
|
||||
this.stale = !!stale;
|
||||
this.loadingMessage = '';
|
||||
this.errorMessage = '';
|
||||
this.render();
|
||||
}
|
||||
|
||||
async renderLoading(file, message) {
|
||||
this.sourceFile = file;
|
||||
this.sections = [];
|
||||
this.stale = false;
|
||||
this.loadingMessage = message || this.plugin.t('loadingDefault');
|
||||
this.errorMessage = '';
|
||||
this.render();
|
||||
}
|
||||
|
||||
async renderError(file, message) {
|
||||
this.sourceFile = file;
|
||||
this.sections = [];
|
||||
this.stale = false;
|
||||
this.loadingMessage = '';
|
||||
this.errorMessage = message || this.plugin.t('errorTitle');
|
||||
this.render();
|
||||
}
|
||||
|
||||
renderEmptyWithHint(file) {
|
||||
this.sourceFile = file;
|
||||
this.sections = [];
|
||||
this.stale = false;
|
||||
this.loadingMessage = '';
|
||||
this.errorMessage = '';
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
const hint = container.createDiv({ cls: 'parallel-reader-empty' });
|
||||
hint.createEl('h3', { text: file.basename });
|
||||
hint.createEl('p', { text: this.plugin.t('emptyNoCache') });
|
||||
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
|
||||
const header = container.createDiv({ cls: 'parallel-reader-header' });
|
||||
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
|
||||
headerRow.createEl('div', { text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' });
|
||||
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
|
||||
if (this.sourceFile) {
|
||||
if (this.plugin.isGeneratingFile(this.sourceFile)) {
|
||||
addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => this.plugin.cancelGenerationForFile(this.sourceFile));
|
||||
} else {
|
||||
addIconButton(actions, 'refresh-cw', this.plugin.t('actionRegenerate'), () => this.plugin.runForFile(this.sourceFile, true));
|
||||
}
|
||||
addIconButton(actions, 'copy', this.plugin.t('actionCopyAll'), () => this.plugin.copyCurrentViewMarkdown());
|
||||
addIconButton(actions, 'download', this.plugin.t('actionExport'), () => this.exportToVault());
|
||||
}
|
||||
|
||||
if (this.stale) {
|
||||
const banner = container.createDiv({ cls: 'parallel-reader-stale-banner' });
|
||||
banner.createSpan({ text: this.plugin.t('staleBanner') });
|
||||
addTextButton(
|
||||
banner,
|
||||
'refresh-cw',
|
||||
this.plugin.t('actionRegenerate'),
|
||||
() => this.plugin.runForFile(this.sourceFile, true),
|
||||
'parallel-reader-stale-button'
|
||||
);
|
||||
}
|
||||
|
||||
if (this.loadingMessage) {
|
||||
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-loading' });
|
||||
state.createDiv({ cls: 'parallel-reader-spinner' });
|
||||
state.createEl('div', { text: this.loadingMessage, cls: 'parallel-reader-state-title' });
|
||||
state.createEl('div', { text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.errorMessage) {
|
||||
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' });
|
||||
state.createEl('div', { text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
|
||||
state.createEl('div', { text: this.errorMessage, cls: 'parallel-reader-state-subtitle' });
|
||||
addTextButton(
|
||||
state,
|
||||
'refresh-cw',
|
||||
this.plugin.t('actionRegenerate'),
|
||||
() => this.plugin.runForFile(this.sourceFile, true),
|
||||
'parallel-reader-text-button'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = container.createDiv({ cls: 'parallel-reader-cards' });
|
||||
this.cards = [];
|
||||
const sourcePath = this.sourceFile?.path || '';
|
||||
this.sections.forEach((s, i) => {
|
||||
const card = list.createDiv({ cls: 'parallel-reader-card' });
|
||||
card.dataset.idx = String(i);
|
||||
if (s.startLine < 0) card.addClass('parallel-reader-card-unanchored');
|
||||
|
||||
const title = card.createEl('div', { cls: 'parallel-reader-card-title' });
|
||||
title.createSpan({ text: s.title });
|
||||
if (s.startLine < 0) {
|
||||
title.createEl('span', { text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') });
|
||||
}
|
||||
|
||||
if (s.gist) {
|
||||
const gistEl = card.createEl('div', { cls: 'parallel-reader-gist' });
|
||||
MarkdownRenderer.render(this.app, s.gist, gistEl, sourcePath, this).catch(() => {
|
||||
gistEl.setText(s.gist);
|
||||
});
|
||||
}
|
||||
|
||||
const bs = s.bullets || [];
|
||||
if (bs.length > 0) {
|
||||
const bulletsEl = card.createEl('div', { cls: 'parallel-reader-bullets-md' });
|
||||
const md = bs.map(b => `- ${b}`).join('\n');
|
||||
MarkdownRenderer.render(this.app, md, bulletsEl, sourcePath, this).catch(() => {
|
||||
bulletsEl.setText(md);
|
||||
});
|
||||
} else if (!s.gist) {
|
||||
card.createEl('div', { cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') });
|
||||
}
|
||||
|
||||
card.addEventListener('click', (e) => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.toString().length > 0) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && target.tagName === 'A') return;
|
||||
if (s.startLine >= 0) this.plugin.scrollEditorToLine(s.startLine, this.sourceFile);
|
||||
});
|
||||
|
||||
card.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
const menu = new Menu();
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyMarkdown')).setIcon('copy')
|
||||
.onClick(() => copyToClipboard(cardToMarkdown(s), this.plugin.t('copiedMarkdown'))));
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyPlain')).setIcon('clipboard-copy')
|
||||
.onClick(() => copyToClipboard(cardToPlain(s), this.plugin.t('copiedPlain'))));
|
||||
if (s.anchor) {
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyAnchor')).setIcon('quote-glyph')
|
||||
.onClick(() => copyToClipboard(s.anchor, this.plugin.t('copiedAnchor'))));
|
||||
}
|
||||
menu.addSeparator();
|
||||
if (s.startLine >= 0) {
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuJumpSource')).setIcon('arrow-right')
|
||||
.onClick(() => this.plugin.scrollEditorToLine(s.startLine, this.sourceFile)));
|
||||
}
|
||||
menu.addSeparator();
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuEditCard')).setIcon('pencil')
|
||||
.onClick(() => this.openEditCardModal(i)));
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuDeleteCard')).setIcon('trash')
|
||||
.onClick(() => this.deleteCard(i)));
|
||||
menu.showAtMouseEvent(e);
|
||||
});
|
||||
|
||||
this.cards.push(card);
|
||||
});
|
||||
if (this.activeIdx >= 0 && this.cards[this.activeIdx]) {
|
||||
this.cards[this.activeIdx].addClass('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSection(idx) {
|
||||
if (idx === this.activeIdx) return;
|
||||
if (this.activeIdx >= 0 && this.cards[this.activeIdx]) {
|
||||
this.cards[this.activeIdx].removeClass('is-active');
|
||||
}
|
||||
this.activeIdx = idx;
|
||||
if (idx >= 0 && this.cards[idx]) {
|
||||
this.cards[idx].addClass('is-active');
|
||||
this.cards[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
moveActiveSection(delta) {
|
||||
const nextIdx = nextCardIndex(this.activeIdx, this.sections.length, delta);
|
||||
this.setActiveSection(nextIdx);
|
||||
this.focusSummaryPane();
|
||||
return nextIdx;
|
||||
}
|
||||
|
||||
jumpToActiveSection() {
|
||||
const line = activeSectionLine(this.sections, this.activeIdx);
|
||||
if (line < 0 || !this.sourceFile) return -1;
|
||||
this.plugin.scrollEditorToLine(line, this.sourceFile);
|
||||
return line;
|
||||
}
|
||||
|
||||
handleKeydown(e: KeyboardEvent) {
|
||||
if (e.altKey && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.moveActiveSection(-1);
|
||||
return;
|
||||
}
|
||||
if (e.altKey && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.moveActiveSection(1);
|
||||
return;
|
||||
}
|
||||
if (!e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.key === 'Enter') {
|
||||
const line = this.jumpToActiveSection();
|
||||
if (line >= 0) e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCard(index) {
|
||||
if (!this.sourceFile) return false;
|
||||
const nextSections = removeCardAt(this.sections, index);
|
||||
if (nextSections.length === this.sections.length) return false;
|
||||
const previousLength = this.sections.length;
|
||||
this.sections = nextSections;
|
||||
this.activeIdx = activeIndexAfterCardDelete(index, previousLength, this.activeIdx);
|
||||
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
|
||||
this.render();
|
||||
new Notice(this.plugin.t('cardDeleted'));
|
||||
return true;
|
||||
}
|
||||
|
||||
openEditCardModal(index) {
|
||||
if (!this.sourceFile || !this.sections[index]) return false;
|
||||
new CardEditModal(this.app, this.plugin, this.sections[index], async patch => { await this.updateCard(index, patch); }).open();
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateCard(index, patch: CardPatch) {
|
||||
if (!this.sourceFile) return false;
|
||||
const nextSections = updateCardAt(this.sections, index, patch);
|
||||
if (nextSections.length !== this.sections.length) return false;
|
||||
this.sections = nextSections;
|
||||
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
|
||||
this.render();
|
||||
new Notice(this.plugin.t('cardSaved'));
|
||||
return true;
|
||||
}
|
||||
|
||||
async exportToVault() {
|
||||
if (!this.sourceFile) return;
|
||||
const folder = normalizeVaultPath(this.plugin.settings.exportFolder);
|
||||
const name = `${this.sourceFile.basename} - ${this.plugin.t('displayName')}.md`;
|
||||
const targetPath = `${folder}/${name}`;
|
||||
|
||||
const markdown = [
|
||||
'---',
|
||||
`source: [[${this.sourceFile.basename}]]`,
|
||||
`generated: ${new Date().toISOString().slice(0, 10)}`,
|
||||
'tool: parallel-reader',
|
||||
'---',
|
||||
'',
|
||||
cardsToMarkdown(`${this.sourceFile.basename} · ${this.plugin.t('displayName')}`, this.sections),
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const app = this.plugin.app;
|
||||
await ensureVaultFolder(app, folder);
|
||||
|
||||
const existing = app.vault.getAbstractFileByPath(targetPath);
|
||||
if (existing instanceof TFile) {
|
||||
await app.vault.modify(existing, markdown);
|
||||
} else {
|
||||
await app.vault.create(targetPath, markdown);
|
||||
}
|
||||
new Notice(this.plugin.t('exported', { path: targetPath }));
|
||||
}
|
||||
}
|
||||
|
|
@ -44,11 +44,12 @@ const t = plugin.__test;
|
|||
|
||||
assert.ok(t, 'test helpers should be exported');
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8');
|
||||
assert.ok(!/\basync\s+onOpen\s*\(/.test(mainSource), 'ParallelReaderView.onOpen should not be async without await');
|
||||
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(mainSource), 'empty onClose should not be async');
|
||||
assert.ok(/focusSummaryPane\s*\(\)/.test(mainSource), 'summary pane should expose a focus helper');
|
||||
assert.ok(/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(mainSource), 'summary pane focus should not scroll the page');
|
||||
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(mainSource), 'card navigation should focus the summary pane');
|
||||
const viewSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'view.ts'), 'utf8');
|
||||
assert.ok(!/\basync\s+onOpen\s*\(/.test(viewSource), 'ParallelReaderView.onOpen should not be async without await');
|
||||
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(viewSource), 'empty onClose should not be async');
|
||||
assert.ok(/focusSummaryPane\s*\(\)/.test(viewSource), 'summary pane should expose a focus helper');
|
||||
assert.ok(/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(viewSource), 'summary pane focus should not scroll the page');
|
||||
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(viewSource), 'card navigation should focus the summary pane');
|
||||
assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path');
|
||||
assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable');
|
||||
assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches');
|
||||
|
|
|
|||
Loading…
Reference in a new issue