murashit_codex-panel/src/settings/tab.obsidian.tsx

327 lines
13 KiB
TypeScript

import { type App, Notice, type Plugin, PluginSettingTab } from "obsidian";
import { DEFAULT_CODEX_PATH } from "../constants";
import type { ReasoningEffort } from "../domain/catalog/metadata";
import { listenDomEvent } from "../shared/dom/events.dom";
import { renderUiRoot, unmountUiRoot } from "../shared/dom/preact-root.dom";
import { SettingsDynamicSectionsController } from "./dynamic-sections-controller";
import type { CodexPanelSettingTabHost } from "./host";
import type { CodexPanelSettings } from "./model";
import {
DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE,
DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE,
DEFAULT_ATTACHMENT_FOLDER,
DEFAULT_CLIP_FILENAME_TEMPLATE,
DEFAULT_CLIP_FOLDER,
} from "./model";
import type { SettingsSectionsState } from "./section-state";
import { SettingsTabShell } from "./tab-shell";
const SETTINGS_INTRO_TEXT = "Codex Panel stores panel preferences only. Runtime settings still come from Codex.";
export class CodexPanelSettingTab extends PluginSettingTab {
private readonly dynamicSections: SettingsDynamicSectionsController;
private lastSavedSettings: CodexPanelSettings;
private archivedDeleteConfirmThreadId: string | null = null;
private disposeOutsidePointer: (() => void) | null = null;
private readonly cancelArchivedDeleteConfirmOnOutsidePointer = (event: PointerEvent): void => {
if (!this.archivedDeleteConfirmThreadId) return;
const target = event.target;
const viewWindow = this.containerEl.ownerDocument.defaultView;
if (viewWindow && target instanceof viewWindow.Element) {
const deleteConfirm = target.closest(".codex-panel-settings__archived-row--delete-confirming");
if (deleteConfirm && this.containerEl.contains(deleteConfirm)) return;
}
this.archivedDeleteConfirmThreadId = null;
this.renderSettingsShell();
};
constructor(
app: App,
owner: Plugin,
private readonly plugin: CodexPanelSettingTabHost,
) {
super(app, owner);
this.lastSavedSettings = { ...plugin.settings };
this.dynamicSections = new SettingsDynamicSectionsController(plugin, {
display: () => {
this.renderSettingsShell();
},
notify: (message) => {
new Notice(message);
},
});
}
display(): void {
this.dynamicSections.activate();
this.renderSettingsTab({ autoLoadDynamicSections: true });
}
override hide(): void {
this.disposeOutsidePointer?.();
this.disposeOutsidePointer = null;
this.archivedDeleteConfirmThreadId = null;
this.dynamicSections.dispose();
unmountUiRoot(this.containerEl);
super.hide();
}
private renderSettingsTab(options: { autoLoadDynamicSections: boolean }): void {
const { containerEl } = this;
containerEl.addClass("codex-panel-settings");
this.disposeOutsidePointer?.();
this.disposeOutsidePointer = listenDomEvent(containerEl, "pointerdown", this.cancelArchivedDeleteConfirmOnOutsidePointer);
this.renderSettingsShell();
if (options.autoLoadDynamicSections) this.maybeAutoLoadDynamicSections();
}
private renderSettingsShell(): void {
renderUiRoot(
this.containerEl,
<SettingsTabShell
introText={SETTINGS_INTRO_TEXT}
dynamicSectionsLoading={this.dynamicSections.isLoading()}
panel={{
codexPath: this.plugin.settings.codexPath,
showToolbar: this.plugin.settings.showToolbar,
sendShortcut: this.plugin.settings.sendShortcut,
scrollThreadFromComposerEdges: this.plugin.settings.scrollThreadFromComposerEdges,
referenceActiveNoteOnSend: this.plugin.settings.referenceActiveNoteOnSend,
attachmentFolder: this.plugin.settings.attachmentFolder,
clipFolder: this.plugin.settings.clipFolder,
clipFilenameTemplate: this.plugin.settings.clipFilenameTemplate,
clipTags: this.plugin.settings.clipTags,
}}
sections={this.settingsSectionsState()}
actions={{
refreshDynamicSections: () => {
void this.dynamicSections.refreshDynamicSections();
},
setCodexPath: (value) => {
void this.setCodexPath(value);
},
setShowToolbar: (value) => {
void this.setShowToolbar(value);
},
setSendShortcut: (value) => {
void this.setSendShortcut(value);
},
setScrollThreadFromComposerEdges: (value) => {
void this.setScrollThreadFromComposerEdges(value);
},
setReferenceActiveNoteOnSend: (value) => {
void this.setReferenceActiveNoteOnSend(value);
},
setAttachmentFolder: (value) => {
void this.setAttachmentFolder(value);
},
setClipFolder: (value) => {
void this.setClipFolder(value);
},
setClipFilenameTemplate: (value) => {
void this.setClipFilenameTemplate(value);
},
setClipTags: (value) => {
void this.setClipTags(value);
},
}}
/>,
);
}
private settingsSectionsState(): SettingsSectionsState {
const dynamicSections = this.dynamicSections.snapshot();
return {
helper: {
threadNamingModel: this.plugin.settings.threadNamingModel,
threadNamingEffort: this.plugin.settings.threadNamingEffort,
rewriteSelectionModel: this.plugin.settings.rewriteSelectionModel,
rewriteSelectionEffort: this.plugin.settings.rewriteSelectionEffort,
models: this.dynamicSections.modelMetadata(),
modelLoadFailed: dynamicSections.modelsLifecycle.kind === "failed",
modelStatus: dynamicSections.modelsLifecycle.status,
onThreadNamingModelChange: (value) => void this.setThreadNamingModel(value),
onThreadNamingEffortChange: (value) => void this.setThreadNamingEffort(value),
onRewriteSelectionModelChange: (value) => void this.setRewriteSelectionModel(value),
onRewriteSelectionEffortChange: (value) => void this.setRewriteSelectionEffort(value),
},
archived: {
exportEnabled: this.plugin.settings.archiveExportEnabled,
exportFolderTemplate: this.plugin.settings.archiveExportFolderTemplate,
exportFilenameTemplate: this.plugin.settings.archiveExportFilenameTemplate,
exportTags: this.plugin.settings.archiveExportTags,
threads: dynamicSections.archivedThreads,
contentAvailable:
dynamicSections.archivedThreadsLifecycle.kind === "loaded" ||
(dynamicSections.archivedThreadsLifecycle.kind === "loading" && dynamicSections.archivedThreadsLoaded),
loaded: dynamicSections.archivedThreadsLifecycle.kind === "loaded",
loading: dynamicSections.archivedThreadsLifecycle.kind === "loading",
status: dynamicSections.archivedThreadsLifecycle.status,
deleteConfirmThreadId: this.archivedDeleteConfirmThreadId,
onExportEnabledChange: (enabled) => void this.setArchiveExportEnabled(enabled),
onExportFolderTemplateChange: (value) => void this.setArchiveExportFolderTemplate(value),
onExportFilenameTemplateChange: (value) => void this.setArchiveExportFilenameTemplate(value),
onExportTagsChange: (value) => void this.setArchiveExportTags(value),
onRestore: (threadId) => {
this.archivedDeleteConfirmThreadId = null;
this.renderSettingsShell();
void this.dynamicSections.restoreArchivedThread(threadId);
},
onStartDelete: (threadId) => {
this.archivedDeleteConfirmThreadId = threadId;
this.renderSettingsShell();
},
onDelete: (threadId) => {
this.archivedDeleteConfirmThreadId = null;
this.renderSettingsShell();
void this.dynamicSections.deleteArchivedThread(threadId);
},
},
hooks: {
hooks: dynamicSections.hooks,
warnings: dynamicSections.hookWarnings,
errors: dynamicSections.hookErrors,
contentAvailable:
dynamicSections.hooksLifecycle.kind === "loaded" ||
(dynamicSections.hooksLifecycle.kind === "loading" && dynamicSections.hooksLoaded),
loaded: dynamicSections.hooksLifecycle.kind === "loaded",
loading: dynamicSections.hooksLifecycle.kind === "loading",
status: dynamicSections.hooksLifecycle.status,
onTrust: (hook) => void this.dynamicSections.trustHook(hook),
onToggleEnabled: (hook, enabled) => void this.dynamicSections.setHookEnabled(hook, enabled),
},
};
}
private maybeAutoLoadDynamicSections(): void {
this.dynamicSections.maybeAutoLoadDynamicSections();
}
private async setCodexPath(value: string): Promise<void> {
const codexPath = value.trim() || DEFAULT_CODEX_PATH;
if (codexPath === this.plugin.settings.codexPath) return;
this.plugin.settings.codexPath = codexPath;
if (!(await this.persistSettings())) return;
this.dynamicSections.resetDynamicSectionContext();
this.plugin.dynamicData.notifyContextChanged();
this.plugin.refreshOpenViews();
this.renderSettingsShell();
}
private async setShowToolbar(value: boolean): Promise<void> {
this.plugin.settings.showToolbar = value;
if (!(await this.persistSettings())) return;
this.plugin.refreshOpenViews();
this.renderSettingsShell();
}
private async setSendShortcut(value: "enter" | "mod-enter"): Promise<void> {
this.plugin.settings.sendShortcut = value;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setScrollThreadFromComposerEdges(value: boolean): Promise<void> {
this.plugin.settings.scrollThreadFromComposerEdges = value;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setReferenceActiveNoteOnSend(value: boolean): Promise<void> {
this.plugin.settings.referenceActiveNoteOnSend = value;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setAttachmentFolder(value: string): Promise<void> {
this.plugin.settings.attachmentFolder = value.trim() || DEFAULT_ATTACHMENT_FOLDER;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setClipFolder(value: string): Promise<void> {
this.plugin.settings.clipFolder = value.trim() || DEFAULT_CLIP_FOLDER;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setClipFilenameTemplate(value: string): Promise<void> {
this.plugin.settings.clipFilenameTemplate = value.trim() || DEFAULT_CLIP_FILENAME_TEMPLATE;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setClipTags(value: string): Promise<void> {
this.plugin.settings.clipTags = value.trim();
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setArchiveExportEnabled(enabled: boolean): Promise<void> {
this.plugin.settings.archiveExportEnabled = enabled;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setArchiveExportFolderTemplate(value: string): Promise<void> {
this.plugin.settings.archiveExportFolderTemplate = value.trim() || DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setArchiveExportFilenameTemplate(value: string): Promise<void> {
this.plugin.settings.archiveExportFilenameTemplate = value.trim() || DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE;
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setArchiveExportTags(value: string): Promise<void> {
this.plugin.settings.archiveExportTags = value.trim();
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setThreadNamingModel(value: string | null): Promise<void> {
this.plugin.settings.threadNamingModel = value;
if (!this.dynamicSections.namingEffortSupported(this.plugin.settings.threadNamingEffort)) {
this.plugin.settings.threadNamingEffort = null;
}
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setThreadNamingEffort(value: ReasoningEffort | null): Promise<void> {
this.plugin.settings.threadNamingEffort = value;
if (!(await this.persistSettings())) return;
}
private async setRewriteSelectionModel(value: string | null): Promise<void> {
this.plugin.settings.rewriteSelectionModel = value;
if (!this.dynamicSections.rewriteSelectionEffortSupported(this.plugin.settings.rewriteSelectionEffort)) {
this.plugin.settings.rewriteSelectionEffort = null;
}
if (!(await this.persistSettings())) return;
this.renderSettingsShell();
}
private async setRewriteSelectionEffort(value: ReasoningEffort | null): Promise<void> {
this.plugin.settings.rewriteSelectionEffort = value;
await this.persistSettings();
}
private async persistSettings(): Promise<boolean> {
try {
await this.plugin.saveSettings();
this.lastSavedSettings = { ...this.plugin.settings };
return true;
} catch (error) {
Object.assign(this.plugin.settings, this.lastSavedSettings);
new Notice(`Failed to save Codex Panel settings: ${error instanceof Error ? error.message : String(error)}`);
this.renderSettingsShell();
return false;
}
}
}