murashit_codex-panel/src/settings/dynamic-sections.ts
2026-05-23 09:08:28 +09:00

230 lines
8.7 KiB
TypeScript

import { Setting } from "obsidian";
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
import type { Thread } from "../generated/app-server/v2/Thread";
import { archivedThreadDisplayTitle, fullThreadTitle } from "../threads/model";
import { shortThreadId } from "../utils";
export interface ArchivedThreadSectionState {
exportEnabled: boolean;
exportFolderTemplate: string;
exportFilenameTemplate: string;
exportTags: string;
threads: Thread[];
loaded: boolean;
loading: boolean;
status: string;
onExportEnabledChange(enabled: boolean): void;
onExportFolderTemplateChange(value: string): void;
onExportFilenameTemplateChange(value: string): void;
onExportTagsChange(value: string): void;
onRestore(threadId: string): void;
}
export interface HookSectionState {
hooks: HookMetadata[];
warnings: string[];
errors: string[];
loaded: boolean;
loading: boolean;
status: string;
onTrust(hook: HookMetadata): void;
onToggleEnabled(hook: HookMetadata, enabled: boolean): void;
}
export function renderHookSection(containerEl: HTMLElement, state: HookSectionState): void {
const section = containerEl.createDiv({ cls: "codex-panel-settings__dynamic-section codex-panel-settings__hook-section" });
new Setting(section)
.setClass("codex-panel-settings__dynamic-section-heading")
.setHeading()
.setName("Hook status")
.setDesc("Review discovered hooks, trust changes, and turn hooks on or off.");
if (state.loading) {
section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: "Loading hooks..." });
} else if (state.loaded) {
renderHooks(section, state);
} else if (state.status) {
section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: state.status });
}
}
export function renderArchivedThreadSection(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
const section = containerEl.createDiv({
cls: "codex-panel-settings__dynamic-section codex-panel-settings__archived-section",
});
new Setting(section)
.setClass("codex-panel-settings__dynamic-section-heading")
.setHeading()
.setName("Archive actions")
.setDesc("Save threads to notes before archiving, or restore archived threads.");
renderArchiveExportSettings(section, state);
if (state.loading) {
section.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: "Loading archived threads...",
});
} else if (state.loaded && state.threads.length === 0) {
section.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: "No archived threads.",
});
} else if (state.loaded) {
renderArchivedThreadList(section, state);
} else if (state.status) {
section.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
text: state.status,
});
}
}
function renderArchiveExportSettings(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
new Setting(containerEl)
.setName("Save before archiving")
.setDesc(
"Save a markdown note before archiving. If saving fails, the thread stays active. Frontmatter includes title, thread_id, created, and optional tags.",
)
.addToggle((toggle) => {
toggle.setValue(state.exportEnabled).onChange((value) => {
state.onExportEnabledChange(value);
});
});
new Setting(containerEl)
.setName("Save folder")
.setDesc("Vault-relative folder for saved thread notes. The folder is created when needed.")
.addText((text) => {
text
.setPlaceholder("Codex archives")
.setValue(state.exportFolderTemplate)
.onChange((value) => {
state.onExportFolderTemplateChange(value);
});
});
new Setting(containerEl)
.setName("Save filename")
.setDesc("Filename template. Variables: {{date}}, {{time}}, {{title}}, {{id}}, {{shortId}}. Existing files get a numeric suffix.")
.addText((text) => {
text
.setPlaceholder("{{date}} {{time}} {{title}} {{shortId}}.md")
.setValue(state.exportFilenameTemplate)
.onChange((value) => {
state.onExportFilenameTemplateChange(value);
});
});
new Setting(containerEl)
.setName("Save tags")
.setDesc("Comma-separated fixed tags for saved notes. Leave empty to omit tags.")
.addText((text) => {
text
.setPlaceholder("Codex, archive")
.setValue(state.exportTags)
.onChange((value) => {
state.onExportTagsChange(value);
});
});
}
function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: `Restore archived threads to chat history. Loaded ${String(state.threads.length)} archived thread${
state.threads.length === 1 ? "" : "s"
} from Codex app server.`,
});
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" });
for (const thread of state.threads) {
const title = archivedThreadDisplayTitle(thread);
const setting = new Setting(list)
.setClass("codex-panel-settings__dynamic-row")
.setName(title)
.setDesc(`Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`)
.addExtraButton((button) => {
button.setIcon("rotate-ccw").onClick(() => {
state.onRestore(thread.id);
});
button.extraSettingsEl.addClass("codex-panel-settings__archived-restore");
button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`);
});
setting.settingEl.addClass("codex-panel-settings__archived-row");
setting.settingEl.setAttr("title", fullThreadTitle(thread));
}
}
function renderHooks(containerEl: HTMLElement, state: HookSectionState): void {
if (state.hooks.length === 0) {
containerEl.createEl("p", { cls: "setting-item-description", text: "No hooks found for this vault root." });
} else {
containerEl.createEl("p", {
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
text: `Loaded ${String(state.hooks.length)} hook${state.hooks.length === 1 ? "" : "s"} from Codex app server.`,
});
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list" });
for (const hook of state.hooks) {
renderHookRow(list, hook, state);
}
}
for (const warning of state.warnings) {
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-warning", text: warning });
}
for (const error of state.errors) {
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-error", text: error });
}
}
function renderHookRow(list: HTMLElement, hook: HookMetadata, state: HookSectionState): void {
const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
const hookName = firstNonEmptyString(hook.statusMessage, hook.command, hook.matcher, hook.eventName);
const setting = new Setting(list)
.setClass("codex-panel-settings__dynamic-row")
.setName(hookName)
.setDesc(`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`)
.addButton((button) => {
button
.setButtonText("Trust")
.setDisabled(state.loading || !canTrust)
.onClick(() => {
state.onTrust(hook);
});
})
.addButton((button) => {
button
.setButtonText(hook.enabled ? "Disable" : "Enable")
.setDisabled(state.loading || hook.isManaged)
.onClick(() => {
state.onToggleEnabled(hook, !hook.enabled);
});
});
setting.settingEl.addClass("codex-panel-settings__hook-row");
setting.settingEl.setAttr("title", hook.command ?? hook.key);
setting.descEl.createDiv({
cls: "codex-panel-settings__hook-hash",
text: hook.currentHash,
attr: { title: hook.key },
});
}
function firstNonEmptyString(...values: (string | null | undefined)[]): string {
return (
values.find((value): value is string => typeof value === "string" && value.length > 0) ??
values.find((value): value is string => typeof value === "string") ??
""
);
}
function formatThreadDate(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown";
return new Date(timestamp * 1000).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}