diff --git a/src/gui/AIAssistantSettingsModal.ts b/src/gui/AIAssistantSettingsModal.ts index 319e37fd..f582839e 100644 --- a/src/gui/AIAssistantSettingsModal.ts +++ b/src/gui/AIAssistantSettingsModal.ts @@ -1,6 +1,6 @@ import type { App } from "obsidian"; import { Modal, Setting, TextAreaComponent } from "obsidian"; -import type { QuickAddSettings } from "src/quickAddSettingsTab"; +import type { QuickAddSettings } from "src/settings"; import { FormatSyntaxSuggester } from "./suggesters/formatSyntaxSuggester"; import QuickAdd from "src/main"; import { FormatDisplayFormatter } from "src/formatters/formatDisplayFormatter"; diff --git a/src/gui/GenericInputPrompt/GenericInputPrompt.ts b/src/gui/GenericInputPrompt/GenericInputPrompt.ts index 60fbe1ed..016cabcb 100644 --- a/src/gui/GenericInputPrompt/GenericInputPrompt.ts +++ b/src/gui/GenericInputPrompt/GenericInputPrompt.ts @@ -2,6 +2,7 @@ import type { App } from "obsidian"; import { ButtonComponent, Modal, TextComponent } from "obsidian"; import { FileSuggester } from "../suggesters/fileSuggester"; import { TagSuggester } from "../suggesters/tagSuggester"; +import { InputPromptDraftHandler } from "../../utils/InputPromptDraftHandler"; export default class GenericInputPrompt extends Modal { public waitForClose: Promise; @@ -12,6 +13,7 @@ export default class GenericInputPrompt extends Modal { private inputComponent: TextComponent; protected input: string; private readonly placeholder: string; + private readonly draftHandler: InputPromptDraftHandler; private fileSuggester: FileSuggester; private tagSuggester: TagSuggester; @@ -57,7 +59,13 @@ export default class GenericInputPrompt extends Modal { ) { super(app); this.placeholder = placeholder ?? ""; - this.input = value ?? ""; + this.draftHandler = new InputPromptDraftHandler({ + kind: "single", + header: this.header, + placeholder: this.placeholder, + linkSourcePath: this.linkSourcePath, + }); + this.input = this.draftHandler.hydrate(value ?? ""); this.waitForClose = new Promise((resolve, reject) => { this.resolvePromise = resolve; @@ -103,7 +111,7 @@ export default class GenericInputPrompt extends Modal { textComponent .setPlaceholder(placeholder ?? "") .setValue(value ?? "") - .onChange((value) => (this.input = value)) + .onChange((value) => this.onInputChanged(value)) .inputEl.addEventListener("keydown", this.submitEnterCallback); return textComponent; @@ -152,6 +160,7 @@ export default class GenericInputPrompt extends Modal { }; private submit() { + this.input = this.inputComponent?.inputEl?.value ?? this.input; this.didSubmit = true; this.close(); @@ -166,6 +175,21 @@ export default class GenericInputPrompt extends Modal { else this.resolvePromise(this.input); } + protected onInputChanged(value: string) { + this.draftHandler.markChanged(); + this.input = value; + } + + private syncInputFromEl() { + if (this.inputComponent?.inputEl) { + this.input = this.inputComponent.inputEl.value; + } + } + + private persistDraft() { + this.draftHandler.persist(this.input, this.didSubmit); + } + private removeInputListener() { this.inputComponent.inputEl.removeEventListener( "keydown", @@ -181,8 +205,12 @@ export default class GenericInputPrompt extends Modal { } onClose() { - super.onClose(); + if (!this.didSubmit) { + this.syncInputFromEl(); + } + this.persistDraft(); this.resolveInput(); this.removeInputListener(); + super.onClose(); } } diff --git a/src/gui/GenericWideInputPrompt/GenericWideInputPrompt.ts b/src/gui/GenericWideInputPrompt/GenericWideInputPrompt.ts index 9ed252bd..49481c8c 100644 --- a/src/gui/GenericWideInputPrompt/GenericWideInputPrompt.ts +++ b/src/gui/GenericWideInputPrompt/GenericWideInputPrompt.ts @@ -2,6 +2,7 @@ import type { App } from "obsidian"; import { ButtonComponent, Modal, TextAreaComponent } from "obsidian"; import { FileSuggester } from "../suggesters/fileSuggester"; import { TagSuggester } from "../suggesters/tagSuggester"; +import { InputPromptDraftHandler } from "../../utils/InputPromptDraftHandler"; export default class GenericWideInputPrompt extends Modal { public waitForClose: Promise; @@ -12,6 +13,7 @@ export default class GenericWideInputPrompt extends Modal { private inputComponent: TextAreaComponent; private input: string; private readonly placeholder: string; + private readonly draftHandler: InputPromptDraftHandler; private fileSuggester: FileSuggester; private tagSuggester: TagSuggester; @@ -57,7 +59,13 @@ export default class GenericWideInputPrompt extends Modal { ) { super(app); this.placeholder = placeholder ?? ""; - this.input = value ?? ""; + this.draftHandler = new InputPromptDraftHandler({ + kind: "multi", + header: this.header, + placeholder: this.placeholder, + linkSourcePath: this.linkSourcePath, + }); + this.input = this.draftHandler.hydrate(value ?? ""); this.waitForClose = new Promise((resolve, reject) => { this.resolvePromise = resolve; @@ -101,7 +109,7 @@ export default class GenericWideInputPrompt extends Modal { textComponent .setPlaceholder(placeholder ?? "") .setValue(value ?? "") - .onChange((value) => (this.input = value)) + .onChange((value) => this.onInputChanged(value)) .inputEl.addEventListener("keydown", this.submitEnterCallback); return textComponent; @@ -155,6 +163,7 @@ export default class GenericWideInputPrompt extends Modal { private submit() { if (this.didSubmit) return; + this.input = this.inputComponent?.inputEl?.value ?? this.input; this.didSubmit = true; this.input = this.escapeBackslashes(this.input); @@ -170,6 +179,21 @@ export default class GenericWideInputPrompt extends Modal { else this.resolvePromise(this.input); } + private onInputChanged(value: string) { + this.draftHandler.markChanged(); + this.input = value; + } + + private syncInputFromEl() { + if (this.inputComponent?.inputEl) { + this.input = this.inputComponent.inputEl.value; + } + } + + private persistDraft() { + this.draftHandler.persist(this.input, this.didSubmit); + } + private removeInputListener() { this.inputComponent.inputEl.removeEventListener( "keydown", @@ -185,8 +209,12 @@ export default class GenericWideInputPrompt extends Modal { } onClose() { - super.onClose(); + if (!this.didSubmit) { + this.syncInputFromEl(); + } + this.persistDraft(); this.resolveInput(); this.removeInputListener(); + super.onClose(); } } diff --git a/src/gui/VDateInputPrompt/VDateInputPrompt.ts b/src/gui/VDateInputPrompt/VDateInputPrompt.ts index 1fc19128..22b7901f 100644 --- a/src/gui/VDateInputPrompt/VDateInputPrompt.ts +++ b/src/gui/VDateInputPrompt/VDateInputPrompt.ts @@ -69,8 +69,8 @@ export default class VDateInputPrompt extends GenericInputPrompt { .setPlaceholder(placeholder ?? "") .setValue(value ?? "") .onChange((newValue) => { + this.onInputChanged(newValue); this.currentInput = newValue; - this.input = newValue; // Keep parent's input in sync this.updatePreviewDebounced(); }) .inputEl.addEventListener("keydown", this.submitEnterCallback); @@ -168,4 +168,4 @@ export default class VDateInputPrompt extends GenericInputPrompt { super.onClose(); } -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index 572c9446..8b46800a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,9 @@ /** biome-ignore-all assist/source/organizeImports: Import order is critical to prevent circular dependencies - ChoiceExecutor must load before dependent classes */ import type { TFile } from "obsidian"; import { Plugin } from "obsidian"; -import { DEFAULT_SETTINGS, QuickAddSettingsTab } from "./quickAddSettingsTab"; -import type { QuickAddSettings } from "./quickAddSettingsTab"; +import { QuickAddSettingsTab } from "./quickAddSettingsTab"; +import { DEFAULT_SETTINGS } from "./settings"; +import type { QuickAddSettings } from "./settings"; import { log } from "./logger/logManager"; import { ConsoleErrorLogger } from "./logger/consoleErrorLogger"; import { GuiLogger } from "./logger/guiLogger"; @@ -323,4 +324,3 @@ export default class QuickAdd extends Plugin { updateModal.open(); } } - diff --git a/src/migrations/Migrations.ts b/src/migrations/Migrations.ts index be90d911..d173b3d2 100644 --- a/src/migrations/Migrations.ts +++ b/src/migrations/Migrations.ts @@ -1,5 +1,5 @@ import type QuickAdd from "src/main"; -import type { QuickAddSettings } from "src/quickAddSettingsTab"; +import type { QuickAddSettings } from "src/settings"; export type Migration = { description: string; diff --git a/src/quickAddSettingsTab.ts b/src/quickAddSettingsTab.ts index ce0a737c..7e83d158 100644 --- a/src/quickAddSettingsTab.ts +++ b/src/quickAddSettingsTab.ts @@ -12,89 +12,10 @@ import ChoiceView from "./gui/choiceList/ChoiceView.svelte"; import { GenericTextSuggester } from "./gui/suggesters/genericTextSuggester"; import GlobalVariablesView from "./gui/GlobalVariables/GlobalVariablesView.svelte"; import { settingsStore } from "./settingsStore"; -import type { Model } from "./ai/Provider"; -import { DefaultProviders, type AIProvider } from "./ai/Provider"; import { ExportPackageModal } from "./gui/PackageManager/ExportPackageModal"; import { ImportPackageModal } from "./gui/PackageManager/ImportPackageModal"; - -export interface QuickAddSettings { - choices: IChoice[]; - inputPrompt: "multi-line" | "single-line"; - devMode: boolean; - templateFolderPath: string; - announceUpdates: "all" | "major" | "none"; - version: string; - globalVariables: Record; - /** - * Enables the one-page input flow that pre-collects variables - * and renders a single dynamic GUI before executing a choice. - */ - onePageInputEnabled: boolean; - /** - * If this is true, then the plugin is not to contact external services (e.g. OpenAI, etc.) via plugin features. - * Users _can_ still use User Scripts to do so by executing arbitrary JavaScript, but that is not something the plugin controls. - */ - disableOnlineFeatures: boolean; - enableRibbonIcon: boolean; - showCaptureNotification: boolean; - showInputCancellationNotification: boolean; - enableTemplatePropertyTypes: boolean; - ai: { - defaultModel: Model["name"] | "Ask me"; - defaultSystemPrompt: string; - promptTemplatesFolderPath: string; - showAssistant: boolean; - providers: AIProvider[]; - }; - migrations: { - migrateToMacroIDFromEmbeddedMacro: boolean; - useQuickAddTemplateFolder: boolean; - incrementFileNameSettingMoveToDefaultBehavior: boolean; - mutualExclusionInsertAfterAndWriteToBottomOfFile: boolean; - setVersionAfterUpdateModalRelease: boolean; - addDefaultAIProviders: boolean; - removeMacroIndirection: boolean; - migrateFileOpeningSettings: boolean; - setProviderModelDiscoveryMode: boolean; - }; -} - -export const DEFAULT_SETTINGS: QuickAddSettings = { - choices: [], - inputPrompt: "single-line", - devMode: false, - templateFolderPath: "", - announceUpdates: "major", - version: "0.0.0", - globalVariables: {}, - onePageInputEnabled: false, - disableOnlineFeatures: true, - enableRibbonIcon: false, - showCaptureNotification: true, - showInputCancellationNotification: false, - enableTemplatePropertyTypes: false, - ai: { - defaultModel: "Ask me", - defaultSystemPrompt: `As an AI assistant within Obsidian, your primary goal is to help users manage their ideas and knowledge more effectively. Format your responses using Markdown syntax. Please use the [[Obsidian]] link format. You can write aliases for the links by writing [[Obsidian|the alias after the pipe symbol]]. To use mathematical notation, use LaTeX syntax. LaTeX syntax for larger equations should be on separate lines, surrounded with double dollar signs ($$). You can also inline math expressions by wrapping it in $ symbols. For example, use $$w_{ij}^{\text{new}}:=w_{ij}^{\text{current}}+\eta\cdot\delta_j\cdot x_{ij}$$ on a separate line, but you can write "($\eta$ = learning rate, $\delta_j$ = error term, $x_{ij}$ = input)" inline.`, - promptTemplatesFolderPath: "", - showAssistant: true, - providers: DefaultProviders, - }, - migrations: { - /** - * @deprecated kept for backward compatibility; always true, ignored. - */ - migrateToMacroIDFromEmbeddedMacro: true, - useQuickAddTemplateFolder: false, - incrementFileNameSettingMoveToDefaultBehavior: false, - mutualExclusionInsertAfterAndWriteToBottomOfFile: false, - setVersionAfterUpdateModalRelease: false, - addDefaultAIProviders: false, - removeMacroIndirection: false, - migrateFileOpeningSettings: false, - setProviderModelDiscoveryMode: false, - }, -}; +import { InputPromptDraftStore } from "./utils/InputPromptDraftStore"; +import type { QuickAddSettings } from "./settings"; type SettingGroupLike = { addSetting(cb: (setting: Setting) => void): void; @@ -129,6 +50,7 @@ export class QuickAddSettingsTab extends PluginSettingTab { const inputGroup = this.createSettingGroup("Input"); this.addUseMultiLineInputPromptSetting(inputGroup); + this.addPersistInputPromptDraftsSetting(inputGroup); this.addOnePageInputSetting(inputGroup); const templatesGroup = this.createSettingGroup("Templates & Properties"); @@ -423,6 +345,26 @@ export class QuickAddSettingsTab extends PluginSettingTab { }); } + private addPersistInputPromptDraftsSetting(group: SettingGroupLike) { + group.addSetting((setting) => { + setting + .setName("Persist Input Prompt Drafts") + .setDesc( + "Keep drafts when closing input prompts so they can be restored on reopen. Drafts are stored only for this session.", + ) + .addToggle((toggle) => + toggle + .setValue(settingsStore.getState().persistInputPromptDrafts) + .onChange((value) => { + settingsStore.setState({ persistInputPromptDrafts: value }); + if (!value) { + InputPromptDraftStore.getInstance().clearAll(); + } + }), + ); + }); + } + private addTemplateFolderPathSetting(group: SettingGroupLike) { group.addSetting((setting) => { setting.setName("Template Folder Path"); diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 00000000..c78a6de1 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,84 @@ +import type { Model } from "./ai/Provider"; +import { DefaultProviders, type AIProvider } from "./ai/Provider"; +import type IChoice from "./types/choices/IChoice"; + +export interface QuickAddSettings { + choices: IChoice[]; + inputPrompt: "multi-line" | "single-line"; + persistInputPromptDrafts: boolean; + devMode: boolean; + templateFolderPath: string; + announceUpdates: "all" | "major" | "none"; + version: string; + globalVariables: Record; + /** + * Enables the one-page input flow that pre-collects variables + * and renders a single dynamic GUI before executing a choice. + */ + onePageInputEnabled: boolean; + /** + * If this is true, then the plugin is not to contact external services (e.g. OpenAI, etc.) via plugin features. + * Users _can_ still use User Scripts to do so by executing arbitrary JavaScript, but that is not something the plugin controls. + */ + disableOnlineFeatures: boolean; + enableRibbonIcon: boolean; + showCaptureNotification: boolean; + showInputCancellationNotification: boolean; + enableTemplatePropertyTypes: boolean; + ai: { + defaultModel: Model["name"] | "Ask me"; + defaultSystemPrompt: string; + promptTemplatesFolderPath: string; + showAssistant: boolean; + providers: AIProvider[]; + }; + migrations: { + migrateToMacroIDFromEmbeddedMacro: boolean; + useQuickAddTemplateFolder: boolean; + incrementFileNameSettingMoveToDefaultBehavior: boolean; + mutualExclusionInsertAfterAndWriteToBottomOfFile: boolean; + setVersionAfterUpdateModalRelease: boolean; + addDefaultAIProviders: boolean; + removeMacroIndirection: boolean; + migrateFileOpeningSettings: boolean; + setProviderModelDiscoveryMode: boolean; + }; +} + +export const DEFAULT_SETTINGS: QuickAddSettings = { + choices: [], + inputPrompt: "single-line", + persistInputPromptDrafts: true, + devMode: false, + templateFolderPath: "", + announceUpdates: "major", + version: "0.0.0", + globalVariables: {}, + onePageInputEnabled: false, + disableOnlineFeatures: true, + enableRibbonIcon: false, + showCaptureNotification: true, + showInputCancellationNotification: false, + enableTemplatePropertyTypes: false, + ai: { + defaultModel: "Ask me", + defaultSystemPrompt: `As an AI assistant within Obsidian, your primary goal is to help users manage their ideas and knowledge more effectively. Format your responses using Markdown syntax. Please use the [[Obsidian]] link format. You can write aliases for the links by writing [[Obsidian|the alias after the pipe symbol]]. To use mathematical notation, use LaTeX syntax. LaTeX syntax for larger equations should be on separate lines, surrounded with double dollar signs ($$). You can also inline math expressions by wrapping it in $ symbols. For example, use $$w_{ij}^{\text{new}}:=w_{ij}^{\text{current}}+\eta\cdot\delta_j\cdot x_{ij}$$ on a separate line, but you can write "($\eta$ = learning rate, $\delta_j$ = error term, $x_{ij}$ = input)" inline.`, + promptTemplatesFolderPath: "", + showAssistant: true, + providers: DefaultProviders, + }, + migrations: { + /** + * @deprecated kept for backward compatibility; always true, ignored. + */ + migrateToMacroIDFromEmbeddedMacro: true, + useQuickAddTemplateFolder: false, + incrementFileNameSettingMoveToDefaultBehavior: false, + mutualExclusionInsertAfterAndWriteToBottomOfFile: false, + setVersionAfterUpdateModalRelease: false, + addDefaultAIProviders: false, + removeMacroIndirection: false, + migrateFileOpeningSettings: false, + setProviderModelDiscoveryMode: false, + }, +}; diff --git a/src/settingsStore.ts b/src/settingsStore.ts index 217f661f..67b86ef5 100644 --- a/src/settingsStore.ts +++ b/src/settingsStore.ts @@ -1,6 +1,6 @@ import { createStore } from "zustand/vanilla"; -import type { QuickAddSettings } from "./quickAddSettingsTab"; -import { DEFAULT_SETTINGS } from "./quickAddSettingsTab"; +import type { QuickAddSettings } from "./settings"; +import { DEFAULT_SETTINGS } from "./settings"; import { deepClone } from "./utils/deepClone"; type SettingsState = QuickAddSettings; diff --git a/src/utils/InputPromptDraftHandler.ts b/src/utils/InputPromptDraftHandler.ts new file mode 100644 index 00000000..6d87c2af --- /dev/null +++ b/src/utils/InputPromptDraftHandler.ts @@ -0,0 +1,49 @@ +import { settingsStore } from "../settingsStore"; +import { + InputPromptDraftStore, + type InputPromptDraftKey, +} from "./InputPromptDraftStore"; + +export class InputPromptDraftHandler { + private readonly store = InputPromptDraftStore.getInstance(); + private readonly draftKey: string; + private initialValue = ""; + private didChange = false; + private readonly shouldPersist: () => boolean; + + constructor(key: InputPromptDraftKey, shouldPersist?: () => boolean) { + this.draftKey = this.store.makeKey(key); + this.shouldPersist = shouldPersist ?? + (() => settingsStore.getState().persistInputPromptDrafts); + } + + hydrate(initialValue: string): string { + this.initialValue = initialValue; + if (!this.shouldPersist()) return initialValue; + + const draft = this.store.get(this.draftKey); + return draft ?? initialValue; + } + + markChanged(): void { + this.didChange = true; + } + + persist(value: string, didSubmit: boolean): void { + if (!this.shouldPersist()) return; + + if (didSubmit) { + this.store.clear(this.draftKey); + return; + } + + if (!this.didChange || value === this.initialValue) return; + + if (!value.trim()) { + this.store.clear(this.draftKey); + return; + } + + this.store.set(this.draftKey, value); + } +} diff --git a/src/utils/InputPromptDraftStore.ts b/src/utils/InputPromptDraftStore.ts new file mode 100644 index 00000000..93e799da --- /dev/null +++ b/src/utils/InputPromptDraftStore.ts @@ -0,0 +1,76 @@ +export type InputPromptDraftKind = "single" | "multi"; + +export interface InputPromptDraftKey { + kind: InputPromptDraftKind; + header: string; + placeholder?: string; + linkSourcePath?: string; +} + +interface DraftEntry { + value: string; + timestamp: number; +} + +export class InputPromptDraftStore { + private static instance: InputPromptDraftStore; + private drafts: Map = new Map(); + private readonly MAX_ENTRIES = 100; + + static getInstance(): InputPromptDraftStore { + if (!InputPromptDraftStore.instance) { + InputPromptDraftStore.instance = new InputPromptDraftStore(); + } + return InputPromptDraftStore.instance; + } + + private constructor() { + // Session-only store + } + + makeKey(key: InputPromptDraftKey): string { + return JSON.stringify({ + v: 1, + kind: key.kind, + header: key.header, + placeholder: key.placeholder ?? "", + linkSourcePath: key.linkSourcePath ?? "", + }); + } + + get(key: string): string | undefined { + const entry = this.drafts.get(key); + if (!entry) return undefined; + entry.timestamp = Date.now(); + return entry.value; + } + + set(key: string, value: string): void { + if (this.drafts.size >= this.MAX_ENTRIES && !this.drafts.has(key)) { + this.evictOldest(1); + } + + this.drafts.set(key, { + value, + timestamp: Date.now(), + }); + } + + clear(key: string): void { + this.drafts.delete(key); + } + + clearAll(): void { + this.drafts.clear(); + } + + private evictOldest(count: number): void { + const entries = Array.from(this.drafts.entries()) + .sort(([, a], [, b]) => a.timestamp - b.timestamp) + .slice(0, count); + + for (const [key] of entries) { + this.drafts.delete(key); + } + } +}