mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
chore: integrate obsidianmd eslint plugin and fix recommended rules
Add eslint v9 + @typescript-eslint/parser + eslint-plugin-obsidianmd as
a dedicated `npm run lint:obsidian` track, separate from the existing
biome lint. Apply autofixes from the recommended config and resolve the
follow-on type/test issues.
- prefer-create-el: createEl('div'/'span') -> createDiv/createSpan
- prefer-active-window-timers: setTimeout/clearTimeout -> activeWindow.*
+ retype timer holders from ReturnType<typeof setTimeout> to number
- prefer-active-doc: drop globalThis.fetch in streaming
- no-useless-catch: drop no-op rethrow wrapper
- no-restricted-globals: streaming.ts is exempted because requestUrl
does not support streaming responses
- @typescript-eslint/no-unsafe-*: disabled (out of scope for an Obsidian
plugin lint pass; tsc + biome already cover type safety)
- tests: polyfill globalThis.activeWindow so unit tests still run in Node
Change-Id: I43cc652e29a66d490e2834e940843c39b211b80b
This commit is contained in:
parent
83dc9b2874
commit
33371cadd6
13 changed files with 4604 additions and 41 deletions
44
eslint.config.mjs
Normal file
44
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import tsparser from "@typescript-eslint/parser";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
|
||||
export default defineConfig([
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
files: ["main.ts", "src/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: { project: "./tsconfig.json" },
|
||||
},
|
||||
rules: {
|
||||
// Out-of-scope for an Obsidian plugin lint pass: TS-strict noise
|
||||
// from JSON.parse / dynamic i18n keys. Biome + tsc cover type safety.
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// streaming.ts legitimately uses bare `fetch` because Obsidian's `requestUrl`
|
||||
// does not support streaming responses (it's a one-shot wrapper).
|
||||
files: ["src/streaming.ts"],
|
||||
rules: {
|
||||
"no-restricted-globals": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/",
|
||||
"main.js",
|
||||
"main.js.map",
|
||||
"tests/",
|
||||
"scripts/",
|
||||
".e2e/",
|
||||
".orchestration/",
|
||||
".agent/",
|
||||
".codex-tmp/",
|
||||
],
|
||||
},
|
||||
]);
|
||||
10
main.ts
10
main.ts
|
|
@ -53,7 +53,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
jobs!: GenerationJobManager;
|
||||
activeBatch: BatchRunState | null = null;
|
||||
_scrollDispose: (() => void) | null = null;
|
||||
_settingsSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
_settingsSaveTimer: number | null = null;
|
||||
|
||||
get cache(): Record<string, CacheEntry> {
|
||||
return this.cacheManager.cache;
|
||||
|
|
@ -198,15 +198,15 @@ class ParallelReaderPlugin extends Plugin {
|
|||
|
||||
async saveSettings() {
|
||||
if (this._settingsSaveTimer) {
|
||||
clearTimeout(this._settingsSaveTimer);
|
||||
activeWindow.clearTimeout(this._settingsSaveTimer);
|
||||
this._settingsSaveTimer = null;
|
||||
}
|
||||
await this.saveData({ settings: this.settings });
|
||||
}
|
||||
|
||||
saveSettingsDebounced(delayMs = 400) {
|
||||
if (this._settingsSaveTimer) clearTimeout(this._settingsSaveTimer);
|
||||
this._settingsSaveTimer = setTimeout(() => {
|
||||
if (this._settingsSaveTimer) activeWindow.clearTimeout(this._settingsSaveTimer);
|
||||
this._settingsSaveTimer = activeWindow.setTimeout(() => {
|
||||
this._settingsSaveTimer = null;
|
||||
this.saveSettings().catch((e: unknown) => console.error('[parallel-reader] failed to save settings', e));
|
||||
}, delayMs);
|
||||
|
|
@ -214,7 +214,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
|
||||
async flushSettingsSave() {
|
||||
if (!this._settingsSaveTimer) return;
|
||||
clearTimeout(this._settingsSaveTimer);
|
||||
activeWindow.clearTimeout(this._settingsSaveTimer);
|
||||
this._settingsSaveTimer = null;
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
|
|
|||
4509
package-lock.json
generated
4509
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check main.ts src/ tests/ scripts/ .e2e/scripts/",
|
||||
"lint:fix": "biome check --write main.ts src/ tests/ scripts/ .e2e/scripts/",
|
||||
"lint:obsidian": "eslint main.ts src/",
|
||||
"version": "node scripts/bump-version.mjs",
|
||||
"test": "npm run build && npm run typecheck && node scripts/run-tests.mjs",
|
||||
"test:only": "node scripts/run-tests.mjs",
|
||||
|
|
@ -22,7 +23,10 @@
|
|||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"esbuild": "^0.24.2",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-obsidianmd": "^0.2.9",
|
||||
"obsidian": "^1.7.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function isValidCacheEntry(entry: unknown): entry is CacheEntry {
|
|||
|
||||
export class CacheManager {
|
||||
cache: Record<string, CacheEntry> = {};
|
||||
private _timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _timer: number | null = null;
|
||||
private _dirty = false;
|
||||
|
||||
constructor(
|
||||
|
|
@ -104,7 +104,7 @@ export class CacheManager {
|
|||
|
||||
async save(): Promise<void> {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
activeWindow.clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
this.prune();
|
||||
|
|
@ -115,7 +115,7 @@ export class CacheManager {
|
|||
scheduleSave(delayMs = 5000): void {
|
||||
this._dirty = true;
|
||||
if (this._timer) return;
|
||||
this._timer = setTimeout(() => {
|
||||
this._timer = activeWindow.setTimeout(() => {
|
||||
this._timer = null;
|
||||
if (!this._dirty) return;
|
||||
this.save().catch((e: unknown) => console.error('[parallel-reader] failed to save cache', e));
|
||||
|
|
@ -124,7 +124,7 @@ export class CacheManager {
|
|||
|
||||
async flush(): Promise<void> {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
activeWindow.clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
if (!this._dirty) return;
|
||||
|
|
|
|||
|
|
@ -63,17 +63,17 @@ export function runCli(
|
|||
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let timer: number | null = null;
|
||||
const fail = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timer) activeWindow.clearTimeout(timer);
|
||||
reject(err);
|
||||
};
|
||||
const succeed = (value: { stdout: string; stderr: string }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timer) activeWindow.clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
try {
|
||||
|
|
@ -98,7 +98,7 @@ export function runCli(
|
|||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
timer = setTimeout(() => {
|
||||
timer = activeWindow.setTimeout(() => {
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch (e: unknown) {
|
||||
|
|
|
|||
|
|
@ -155,13 +155,9 @@ export class GenerationJobManager {
|
|||
if (this.isPending(key)) throw new GenerationJobAlreadyRunningError(key);
|
||||
const wait = this.waitSlot(key);
|
||||
if (wait) {
|
||||
try {
|
||||
await wait;
|
||||
} catch (err) {
|
||||
// Cancelled while queued (cancelAllWaiters) — slot was never reserved
|
||||
// for this caller, so no releaseSlot is needed.
|
||||
throw err;
|
||||
}
|
||||
// If cancelled while queued (cancelAllWaiters), the slot was never reserved
|
||||
// for this caller, so no releaseSlot is needed — propagation is enough.
|
||||
await wait;
|
||||
if (this.jobs.has(key)) {
|
||||
// Same-key racily inserted while we waited; release the slot we got.
|
||||
this.reserved--;
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ export function visibleTopProbeY(
|
|||
type ScheduleId = { readonly __brand: 'ScheduleId'; readonly raw: number | ReturnType<typeof setTimeout> };
|
||||
|
||||
function wrapId(raw: number | ReturnType<typeof setTimeout>): ScheduleId {
|
||||
return { __brand: 'ScheduleId', raw } as ScheduleId;
|
||||
return { __brand: 'ScheduleId', raw };
|
||||
}
|
||||
|
||||
function defaultSchedule(callback: FrameRequestCallback): ScheduleId {
|
||||
if (typeof requestAnimationFrame === 'function') return wrapId(requestAnimationFrame(callback));
|
||||
return wrapId(setTimeout(() => callback(Date.now()), FALLBACK_FRAME_MS));
|
||||
return wrapId(activeWindow.setTimeout(() => callback(Date.now()), FALLBACK_FRAME_MS));
|
||||
}
|
||||
|
||||
function defaultCancel(id: ScheduleId) {
|
||||
|
|
@ -42,7 +42,7 @@ function defaultCancel(id: ScheduleId) {
|
|||
cancelAnimationFrame(id.raw as number);
|
||||
return;
|
||||
}
|
||||
clearTimeout(id.raw as ReturnType<typeof setTimeout>);
|
||||
activeWindow.clearTimeout(id.raw as number);
|
||||
}
|
||||
|
||||
export function createRafThrottledHandler(
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.apiKeyEnvVar = envInput.value.trim();
|
||||
this.plugin.saveSettingsDebounced();
|
||||
});
|
||||
envDetails.createEl('div', {
|
||||
envDetails.createDiv({
|
||||
text: this.tr('settingApiKeyEnvDesc'),
|
||||
cls: 'parallel-reader-env-desc',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ async function doStreamingFetch(
|
|||
signal: AbortSignal,
|
||||
settings: PluginSettings | null | undefined,
|
||||
): Promise<string> {
|
||||
const response = await globalThis.fetch(url, {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
|
|
@ -158,7 +158,7 @@ export async function streamingFetch(
|
|||
signal?: AbortSignal,
|
||||
settings?: PluginSettings | null,
|
||||
): Promise<string> {
|
||||
if (typeof globalThis.fetch !== 'function') {
|
||||
if (typeof fetch !== 'function') {
|
||||
throw new Error('Streaming requires fetch API');
|
||||
}
|
||||
|
||||
|
|
@ -172,9 +172,9 @@ export async function streamingFetch(
|
|||
if (signal.aborted) timeoutController.abort();
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = activeWindow.setTimeout(() => {
|
||||
timeoutController.abort();
|
||||
reject(new Error(`Streaming timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
|
@ -186,7 +186,7 @@ export async function streamingFetch(
|
|||
timeoutPromise,
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
if (timeoutId !== null) activeWindow.clearTimeout(timeoutId);
|
||||
if (signal && abortListener) signal.removeEventListener('abort', abortListener);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
src/view.ts
24
src/view.ts
|
|
@ -110,7 +110,7 @@ export class ParallelReaderView extends ItemView {
|
|||
container.empty();
|
||||
const header = container.createDiv({ cls: 'parallel-reader-header' });
|
||||
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
|
||||
headerRow.createEl('div', { text: file.basename, cls: 'parallel-reader-title' });
|
||||
headerRow.createDiv({ text: file.basename, cls: 'parallel-reader-title' });
|
||||
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
|
||||
addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => {
|
||||
this.plugin.cancelGenerationForFile(file);
|
||||
|
|
@ -120,7 +120,7 @@ export class ParallelReaderView extends ItemView {
|
|||
cls: 'parallel-reader-state parallel-reader-loading parallel-reader-streaming-preview',
|
||||
});
|
||||
state.createDiv({ cls: 'parallel-reader-spinner' });
|
||||
const titleEl = state.createEl('div', { cls: 'parallel-reader-state-title' });
|
||||
const titleEl = state.createDiv({ cls: 'parallel-reader-state-title' });
|
||||
titleEl.createSpan({ text: this.plugin.t('loadingGenerating') + ' ' });
|
||||
titleEl.createSpan({ cls: 'parallel-reader-stream-counter', text: `${text.length} chars` });
|
||||
const pre = state.createEl('pre', { cls: 'parallel-reader-stream-text' });
|
||||
|
|
@ -174,7 +174,7 @@ export class ParallelReaderView extends ItemView {
|
|||
private renderHeader(container: Element) {
|
||||
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' });
|
||||
headerRow.createDiv({ 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)) {
|
||||
|
|
@ -209,14 +209,14 @@ export class ParallelReaderView extends ItemView {
|
|||
private renderLoadingState(container: Element) {
|
||||
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' });
|
||||
state.createDiv({ text: this.loadingMessage, cls: 'parallel-reader-state-title' });
|
||||
state.createDiv({ text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
|
||||
}
|
||||
|
||||
private renderErrorState(container: Element) {
|
||||
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', {
|
||||
state.createDiv({ text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
|
||||
state.createDiv({
|
||||
text: this.errorMessage,
|
||||
cls: 'parallel-reader-state-subtitle parallel-reader-selectable',
|
||||
});
|
||||
|
|
@ -254,14 +254,14 @@ export class ParallelReaderView extends ItemView {
|
|||
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' });
|
||||
const title = card.createDiv({ 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') });
|
||||
title.createSpan({ text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') });
|
||||
}
|
||||
|
||||
if (s.gist) {
|
||||
const gistEl = card.createEl('div', { cls: 'parallel-reader-gist' });
|
||||
const gistEl = card.createDiv({ cls: 'parallel-reader-gist' });
|
||||
MarkdownRenderer.render(this.app, s.gist, gistEl, sourcePath, this).catch(() => {
|
||||
gistEl.setText(s.gist);
|
||||
});
|
||||
|
|
@ -269,13 +269,13 @@ export class ParallelReaderView extends ItemView {
|
|||
|
||||
const bs = s.bullets || [];
|
||||
if (bs.length > 0) {
|
||||
const bulletsEl = card.createEl('div', { cls: 'parallel-reader-bullets-md' });
|
||||
const bulletsEl = card.createDiv({ 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.createDiv({ cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') });
|
||||
}
|
||||
|
||||
card.addEventListener('click', (e) => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ const fs = require('fs');
|
|||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Polyfill Obsidian's `activeWindow` global for Node test runtime.
|
||||
if (typeof globalThis.activeWindow === 'undefined') {
|
||||
globalThis.activeWindow = globalThis;
|
||||
}
|
||||
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallel-reader-tests-'));
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ const os = require('os');
|
|||
const path = require('path');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
// Polyfill Obsidian's `activeWindow` global for Node test runtime.
|
||||
if (typeof globalThis.activeWindow === 'undefined') {
|
||||
globalThis.activeWindow = globalThis;
|
||||
}
|
||||
|
||||
// Load obsidian mock first — hooks Module._load so that require('obsidian')
|
||||
// returns the mock for both us and the esbuild-bundled test-exports module.
|
||||
const { getRequestUrlMock, setRequestUrlMock } = require('./obsidian-mock');
|
||||
|
|
|
|||
Loading…
Reference in a new issue