aitingtingya_mv-obcc/main.ts
2026-07-07 11:44:31 +08:00

1594 lines
50 KiB
TypeScript

import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
editorInfoField,
FuzzySuggestModal,
Keymap,
loadPrism,
Modal,
Notice,
Plugin,
Setting,
type App,
type PaneType,
type TFolder,
type WorkspaceLeaf,
} from "obsidian";
import { StateEffect, type EditorState } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { isSelectedPageType } from "./src/activity-tracking";
import { BridgeServer } from "./src/bridge-server";
import {
applyManagedTerminalHooks,
restoreManagedTerminalHooks,
} from "./src/claude-hooks";
import {
applyManagedBaseUrl,
localClaudeSettingsPath,
restoreManagedBaseUrl,
} from "./src/claude-settings";
import {
DEFAULT_SETTINGS,
DIFF_VIEW_TYPE,
TERMINAL_VIEW_TYPE,
WINDOWS_MCP_REGISTRATION_VERSION,
} from "./src/constants";
import {
latestSelectionForContext,
rememberLatestSelection,
} from "./src/context-cache";
import { ObsidianDiffView } from "./src/diff-view";
import { TerminalView } from "./src/terminal/terminal-view";
import { normalizeTerminalThemeSettings } from "./src/terminal/terminal-themes";
import {
cleanStaleObsidianLocks,
removeLockFile,
writeLockFile,
} from "./src/lock-file";
import { migrateLlm } from "./src/llm-migrate";
import { migrateInlineCompletion } from "./src/inline-completion/inline-completion-migrate";
import { SourceAssistFeature } from "./src/source-assist/source-assist-feature";
import {
CUSTOM_MARKDOWN_PLAIN_VISUALS_CLASS,
customMarkdownPlainVisualsEnabled,
normalizeSourceAssistSettings,
sourceAssistMarkdownExtensions,
} from "./src/source-assist/source-assist-settings";
import { sourceHighlightThemeStyleAttribute } from "./src/source-assist/highlight-themes";
import {
atMentionedParams,
currentSelection,
getVaultRoot,
selectionChangedParams,
} from "./src/selection";
import {
availableCustomMarkdownFilePath,
customMarkdownFileCommandDefinitions,
customMarkdownHighlightRangesForSource,
MvSenceAiIdeSettingTab,
syncCustomMarkdownExtensionRegistry,
type MarkdownExtensionRegistry,
type PrismLike,
} from "./src/settings-tab";
import {
ToolRegistry,
} from "./src/tool-registry";
import {
IDE_TOOL_DEFINITIONS,
isMcpToolEnabled,
mcpToolDefinitions,
} from "./src/tool-definitions";
import {
ensureMcpRegistration,
removeMcpRegistration,
} from "./src/mcp-registration";
import {
migrateManualUpstream,
resolveAnthropicBaseUrl,
} from "./src/upstream-resolver";
import {
schedulePostLayoutStartup,
type PostLayoutStartupHandle,
} from "./src/post-layout-startup";
import {
applyBottomTerminalSplitRatio,
activeWorkspaceLeaf,
currentWorkspaceContext,
getOpenWorkspaceTabs,
} from "./src/workspace-context";
import { SelectionHighlightController } from "./src/selection-highlights";
import { TerminalSessionTracker } from "./src/terminal-session-tracker";
import { LlmFeature } from "./src/llm-feature";
import { InlineCompletionFeature } from "./src/inline-completion/inline-completion-feature";
import {
ExternalFileOpenerFeature,
externalFileAllowedExtensions,
normalizeExternalFileOpenerExtensionMode,
} from "./src/external-file-opener";
import {
ExternalFileOpenerSystem,
type DefaultOpenerStatus,
} from "./src/external-file-opener-system";
import {
CodexIdeProvider,
type CodexIdeContextSnapshot,
} from "./src/codex-ide-provider";
import {
ensureCodexMcpRegistration,
removeCodexMcpRegistration,
ensureCodexShellAlias,
removeCodexShellAlias,
} from "./src/codex-mcp-registration";
import type {
BridgeClientContext,
BridgeSettings,
JsonRpcRequest,
JsonRpcResponse,
ResolvedUpstream,
SelectionState,
} from "./src/types";
type NewLeafSpecifier = PaneType | boolean;
interface NativeOpenDialog {
showOpenDialog(options: {
properties?: string[];
filters?: Array<{ name: string; extensions: string[] }>;
}): Promise<{ canceled: boolean; filePaths: string[] }>;
}
const refreshCustomMarkdownHighlightEffect = StateEffect.define<void>();
class CustomMarkdownExtensionModal extends FuzzySuggestModal<string> {
constructor(
app: App,
private readonly extensions: string[],
private readonly onChooseExtension: (extension: string) => void,
) {
super(app);
this.setPlaceholder("选择要新建的文件后缀");
this.emptyStateText = "没有可用的自定义 Markdown 后缀";
this.setInstructions([
{ command: "↵", purpose: "新建对应后缀文件" },
{ command: "esc", purpose: "取消" },
]);
}
getItems(): string[] {
return this.extensions;
}
getItemText(extension: string): string {
return `.${extension}`;
}
onChooseItem(extension: string, _evt: MouseEvent | KeyboardEvent): void {
this.onChooseExtension(extension);
}
}
class ExternalFilePathModal extends Modal {
private pathValue = "";
constructor(
app: App,
private readonly allowedExtensions: string[],
private readonly onSubmitPath: (filePath: string) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h3", { text: "通过路径打开电脑上的文件" });
contentEl.createEl("p", {
text: `支持后缀:${this.allowedExtensions.map((ext) => `.${ext}`).join("、")}`,
cls: "setting-item-description",
});
const setting = new Setting(contentEl)
.setName("文件路径")
.addText((text) => {
text
.setPlaceholder("/absolute/path/file.md")
.setValue(this.pathValue)
.onChange((value) => {
this.pathValue = value;
});
});
const input = setting.controlEl.querySelector("input");
input?.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
this.submit();
}
});
new Setting(contentEl)
.addButton((button) =>
button.setButtonText("打开").setCta().onClick(() => this.submit()),
)
.addButton((button) =>
button.setButtonText("取消").onClick(() => this.close()),
);
input?.focus();
}
private submit(): void {
const value = this.pathValue.trim();
if (!value) {
new Notice("请输入外部文件绝对路径。");
return;
}
this.onSubmitPath(value);
this.close();
}
}
function customMarkdownHighlightRefreshRequested(update: ViewUpdate): boolean {
return update.transactions.some((transaction) =>
transaction.effects.some((effect) =>
effect.is(refreshCustomMarkdownHighlightEffect),
),
);
}
export default class MvSenceAiIdePlugin extends Plugin {
settings: BridgeSettings = { ...DEFAULT_SETTINGS };
port = 0;
mcpStatus = "尚未检查";
codexMcpStatus = "Codex MCP 未启用";
defaultFileOpenerStatus = "尚未检查";
claudeIdeError: string | null = null;
codexIdeError: string | null = null;
private server: BridgeServer | null = null;
private bridgeAuthToken: string | null = null;
private bridgeHasClaudeLock = false;
private readonly latestSelections = new Map<string, SelectionState>();
private latestWebLeaf: WorkspaceLeaf | null = null;
private readonly lastContexts = new Map<string, SelectionState>();
private readonly previousBroadcasts = new Map<string, string>();
private broadcastTimer: number | null = null;
private broadcastGeneration = 0;
private toolRegistry: ToolRegistry | null = null;
private terminalTracker: TerminalSessionTracker | null = null;
private selectionHighlighter: SelectionHighlightController | null = null;
private llmFeature: LlmFeature | null = null;
private inlineCompletion: InlineCompletionFeature | null = null;
private sourceAssist: SourceAssistFeature | null = null;
private externalFileOpener: ExternalFileOpenerFeature | null = null;
private readonly externalFileOpenerSystem = new ExternalFileOpenerSystem();
private codexIdeProvider: CodexIdeProvider | null = null;
private mcpRegistrationTimer: number | null = null;
private mcpRegistrationInFlight: Promise<void> | null = null;
private codexMcpRegistrationTimer: number | null = null;
private codexMcpRegistrationInFlight: Promise<void> | null = null;
private postLayoutStartup: PostLayoutStartupHandle | null = null;
private registeredCustomMarkdownExtensions = new Set<string>();
private ownedCustomMarkdownExtensions = new Set<string>();
private readonly registeredCustomMarkdownCommandIds = new Set<string>();
private customMarkdownPrism: PrismLike | null = null;
private customMarkdownPrismLoadStarted = false;
private readonly customMarkdownHighlightEditorViews = new Set<EditorView>();
private unloaded = false;
async onload(): Promise<void> {
this.unloaded = false;
const rawLoaded = (await this.loadData()) as
| (Partial<BridgeSettings> & { codex?: unknown })
| null;
const { codex: _legacyCodex, ...loaded } = rawLoaded ?? {};
this.settings = normalizeTerminalThemeSettings({
...DEFAULT_SETTINGS,
...loaded,
activityTracking: {
...DEFAULT_SETTINGS.activityTracking,
...(loaded.activityTracking ?? {}),
},
toolToggles: {
...DEFAULT_SETTINGS.toolToggles,
...(loaded.toolToggles ?? {}),
},
toolContextLimits: {
...DEFAULT_SETTINGS.toolContextLimits,
...(loaded.toolContextLimits ?? {}),
},
ideIntegrations: {
claudeCode:
loaded.ideIntegrations?.claudeCode ??
DEFAULT_SETTINGS.ideIntegrations.claudeCode,
codex:
loaded.ideIntegrations?.codex ??
DEFAULT_SETTINGS.ideIntegrations.codex,
},
llm: migrateLlm(loaded.llm),
inlineCompletion: migrateInlineCompletion(loaded.inlineCompletion),
sourceAssist: normalizeSourceAssistSettings(loaded.sourceAssist),
externalFileOpener: {
...DEFAULT_SETTINGS.externalFileOpener,
...(loaded.externalFileOpener ?? {}),
extensionMode: normalizeExternalFileOpenerExtensionMode(
loaded.externalFileOpener?.extensionMode,
),
mappings: {
...(loaded.externalFileOpener?.mappings ?? {}),
},
},
});
if (!this.settings.externalFileOpener.openerToken) {
this.settings.externalFileOpener.openerToken = randomUUID();
}
if (
process.platform === "win32" &&
this.settings.windowsMcpRegistrationVersion !==
WINDOWS_MCP_REGISTRATION_VERSION
) {
this.settings.mcpAuthToken = randomUUID();
this.settings.registeredMcpUrl = null;
this.settings.windowsMcpRegistrationVersion =
WINDOWS_MCP_REGISTRATION_VERSION;
} else if (!this.settings.mcpAuthToken) {
this.settings.mcpAuthToken = randomUUID();
}
this.settings = migrateManualUpstream(getVaultRoot(this.app), this.settings);
this.registerView(DIFF_VIEW_TYPE, (leaf) => new ObsidianDiffView(leaf));
this.registerView(TERMINAL_VIEW_TYPE, (leaf) => new TerminalView(leaf, this));
this.register(() => this.unregisterCustomMarkdownExtensions());
this.syncCustomMarkdownExtensions();
this.addSettingTab(new MvSenceAiIdeSettingTab(this.app, this));
this.terminalTracker = new TerminalSessionTracker(this.app);
this.selectionHighlighter = new SelectionHighlightController(
this.app,
this.settings.preserveSelectionHighlights,
);
this.inlineCompletion = new InlineCompletionFeature(this);
this.sourceAssist = new SourceAssistFeature(this);
this.externalFileOpener = new ExternalFileOpenerFeature({
app: this.app,
getSettings: () => this.settings,
getVaultRoot: () => getVaultRoot(this.app),
saveSettings: () => this.saveData(this.settings),
});
this.toolRegistry = new ToolRegistry(
this.app,
(context) => this.latestSelectionFor(context),
() => this.latestWebLeaf,
() => this.settings.toolContextLimits.readCurrentWebPage,
);
const socketPath = path.join(
this.codexRuntimeDir(),
"codex-ipc",
`ipc-${typeof process.getuid === "function" ? process.getuid() : 0}.sock`,
);
this.codexIdeProvider = new CodexIdeProvider({
getSnapshot: () => this.codexIdeContextSnapshot(),
socketPath,
onLog: (message) => console.error("[mv-senceai-ide]", message),
});
this.registerEvent(
this.app.workspace.on("active-leaf-change", () => {
this.terminalTracker?.scan();
this.selectionHighlighter?.sync(true);
this.scheduleBroadcast();
}),
);
this.registerEvent(
this.app.workspace.on("layout-change", () => {
this.terminalTracker?.scan();
this.selectionHighlighter?.sync();
this.scheduleBroadcast();
}),
);
this.registerDomEvent(
this.app.workspace.containerEl.ownerDocument,
"selectionchange",
() => this.scheduleBroadcast(),
);
this.registerDomEvent(activeWindow, "focus", () => {
this.previousBroadcasts.clear();
this.terminalTracker?.scan();
this.scheduleBroadcast();
});
this.registerInterval(
activeWindow.setInterval(() => {
this.terminalTracker?.scan();
this.selectionHighlighter?.sync();
this.llmFeature?.tick();
this.inlineCompletion?.tick();
if (
this.settings.activityTracking.supportAllActivePages ||
this.app.workspace.activeLeaf?.view.getViewType() === "webviewer"
) {
this.scheduleBroadcast();
}
}, 500),
);
this.registerEditorExtension(
this.selectionHighlighter.markdownExtension(),
);
this.registerEditorExtension(
this.inlineCompletion.markdownExtension(),
);
await this.sourceAssist.load();
this.registerEditorExtension(this.sourceAssist.extensions);
this.registerEditorExtension(this.customMarkdownPlainVisualsExtension());
this.registerEditorExtension(this.customMarkdownHighlightThemeExtension());
this.registerEditorExtension(this.customMarkdownHighlightExtension());
this.registerEditorExtension(
EditorView.updateListener.of((update) => {
if (update.selectionSet || update.docChanged) this.scheduleBroadcast();
}),
);
void this.loadCustomMarkdownPrism();
this.addCommand({
id: "send-selection-to-claude-code",
name: "Send current selection to Claude Code",
editorCallback: () => {
const state = currentSelection(this.app);
if (state) {
this.server?.broadcast({
jsonrpc: "2.0",
method: "at_mentioned",
params: atMentionedParams(state),
});
}
},
});
this.addRibbonIcon("terminal", "打开系统终端", () => {
this.activateTerminalView();
});
this.addRibbonIcon("file-plus", "新建非 MD 源码文件", (evt) => {
this.activateCustomMarkdownFileCreation(Keymap.isModEvent(evt));
});
this.addCommand({
id: "open-system-terminal",
name: "Open System Terminal (打开系统终端)",
callback: () => this.activateTerminalView(),
});
this.addCommand({
id: "new-custom-markdown-file",
name: "新建非 MD 源码文件",
callback: () => this.activateCustomMarkdownFileCreation(false),
});
this.addCommand({
id: "open-external-file",
name: "打开电脑上的文件",
callback: () => void this.openExternalFileViaDialog(),
});
this.addCommand({
id: "open-external-file-by-path",
name: "通过路径打开电脑上的文件",
callback: () => this.openExternalFileByPath(),
});
this.addCommand({
id: "prune-external-file-links",
name: "清理外部文件链接",
callback: () => void this.pruneExternalFileLinks(),
});
this.llmFeature = new LlmFeature(this);
this.llmFeature.registerCommands();
this.llmFeature.registerMenus();
this.schedulePostLayoutStartup();
this.terminalTracker.scan();
this.selectionHighlighter.sync(true);
this.scheduleBroadcast();
}
onunload(): void {
if (this.broadcastTimer !== null) {
activeWindow.clearTimeout(this.broadcastTimer);
this.broadcastTimer = null;
}
this.unloaded = true;
this.postLayoutStartup?.cancel();
this.postLayoutStartup = null;
this.clearScheduledMcpRegistration();
this.clearScheduledCodexMcpRegistration();
this.selectionHighlighter?.destroy();
this.selectionHighlighter = null;
this.llmFeature?.dispose();
this.llmFeature = null;
this.inlineCompletion?.dispose();
this.inlineCompletion = null;
this.customMarkdownHighlightEditorViews.clear();
const leaves = this.app.workspace.getLeavesOfType(TERMINAL_VIEW_TYPE);
for (const leaf of leaves) {
const view = leaf.view;
if (view instanceof TerminalView) {
try {
(view as any).stopShell();
} catch (_) {}
}
}
void removeCodexShellAlias();
void this.finishUnload();
}
async activateTerminalView() {
const { workspace } = this.app;
const position = this.settings.terminalOpenPosition || "right";
let leaf: any;
let createdBottomSplit = false;
if (position === "left") {
const hasExisting = workspace.getLeavesOfType(TERMINAL_VIEW_TYPE).length > 0;
leaf = workspace.getLeftLeaf(hasExisting);
} else if (position === "right") {
const hasExisting = workspace.getLeavesOfType(TERMINAL_VIEW_TYPE).length > 0;
leaf = workspace.getRightLeaf(hasExisting);
} else if (position === "bottom") {
const mainAreaLeaves = workspace.getLeavesOfType(TERMINAL_VIEW_TYPE).filter(l => l.getRoot() === workspace.rootSplit);
const targetLeaf = mainAreaLeaves[0];
if (targetLeaf) {
workspace.setActiveLeaf(targetLeaf, { focus: true });
leaf = workspace.getLeaf("tab");
} else {
leaf = workspace.getLeaf("split", "horizontal");
createdBottomSplit = true;
}
} else {
leaf = workspace.getLeaf(true);
}
if (leaf) {
await leaf.setViewState({
type: TERMINAL_VIEW_TYPE,
active: true,
});
await workspace.revealLeaf(leaf);
if (createdBottomSplit) {
applyBottomTerminalSplitRatio(leaf, workspace);
}
setTimeout(() => {
const view = leaf.view;
if (view instanceof TerminalView) {
view.focusTerminal();
}
}, 100);
}
}
refreshTerminalThemes(): void {
for (const leaf of this.app.workspace.getLeavesOfType(TERMINAL_VIEW_TYPE)) {
const view = leaf.view;
if (view instanceof TerminalView) {
view.refreshTheme();
}
}
}
async saveAndApplySettings(): Promise<void> {
await this.saveData(this.settings);
this.syncCustomMarkdownExtensions();
await this.sourceAssist?.settingsChanged();
await this.syncLocalServices(true, false);
await this.syncCodexIdeProvider();
this.scheduleCodexMcpRegistrationIfReady();
}
async saveSourceAssistSettings(): Promise<void> {
await this.saveData(this.settings);
this.syncCustomMarkdownExtensions();
await this.sourceAssist?.settingsChanged();
}
private customMarkdownExtensionRegistry(): MarkdownExtensionRegistry | null {
return (this.app as unknown as { viewRegistry?: MarkdownExtensionRegistry }).viewRegistry ?? null;
}
private syncCustomMarkdownExtensions(): void {
const state = syncCustomMarkdownExtensionRegistry(
this.customMarkdownExtensionRegistry(),
this.ownedCustomMarkdownExtensions,
sourceAssistMarkdownExtensions(this.settings.sourceAssist).join(","),
(message, error) => {
console.warn(`[mv-senceai-ide] ${message}`, error ?? "");
},
);
this.registeredCustomMarkdownExtensions = new Set(state.active);
this.ownedCustomMarkdownExtensions = new Set(state.owned);
this.syncCustomMarkdownFileCommands();
this.refreshCustomMarkdownHighlights();
}
private syncCustomMarkdownFileCommands(): void {
const definitions = customMarkdownFileCommandDefinitions(
this.registeredCustomMarkdownExtensions,
);
const nextCommandIds = new Set(definitions.map((definition) => definition.id));
for (const id of Array.from(this.registeredCustomMarkdownCommandIds)) {
if (!nextCommandIds.has(id)) {
this.removeCommand(id);
this.registeredCustomMarkdownCommandIds.delete(id);
}
}
for (const definition of definitions) {
if (this.registeredCustomMarkdownCommandIds.has(definition.id)) continue;
this.addCommand({
id: definition.id,
name: definition.name,
callback: () => this.createCustomMarkdownFile(definition.extension, false),
});
this.registeredCustomMarkdownCommandIds.add(definition.id);
}
}
private customMarkdownCreationExtensions(): string[] {
return Array.from(this.registeredCustomMarkdownExtensions);
}
private activateCustomMarkdownFileCreation(
newLeaf: NewLeafSpecifier = false,
): void {
const extensions = this.customMarkdownCreationExtensions();
if (extensions.length === 0) {
new Notice("请先在“源码编写辅助”中添加源码类型。");
return;
}
if (extensions.length === 1) {
void this.createCustomMarkdownFile(extensions[0]!, newLeaf);
return;
}
new CustomMarkdownExtensionModal(this.app, extensions, (extension) => {
void this.createCustomMarkdownFile(extension, newLeaf);
}).open();
}
private async createCustomMarkdownFile(
extension: string,
newLeaf: NewLeafSpecifier = false,
): Promise<void> {
try {
const sourcePath = this.app.workspace.getActiveFile()?.path ?? "";
const parent = this.app.fileManager.getNewFileParent(
sourcePath,
`Untitled.${extension}`,
);
const filePath = this.availableCustomMarkdownFilePath(parent, extension);
const file = await this.app.vault.create(filePath, "");
await this.app.workspace.getLeaf(newLeaf).openFile(file, {
active: true,
state: { mode: "source" },
eState: { rename: "all" },
});
} catch (error) {
console.error(
"[mv-senceai-ide] Failed to create custom Markdown file",
error,
);
const message = error instanceof Error ? error.message : String(error);
new Notice(`创建 .${extension} 文件失败:${message}`);
}
}
private externalFileAllowedExtensions(): string[] {
return externalFileAllowedExtensions(this.settings);
}
private async openExternalFileViaDialog(): Promise<void> {
const dialog = this.nativeOpenDialog();
if (!dialog) {
new Notice("当前 Obsidian 环境无法打开系统文件选择器,请改用路径打开。");
this.openExternalFileByPath();
return;
}
const extensions = this.externalFileAllowedExtensions();
const result = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [
{ name: "SenceAI supported files", extensions },
{ name: "All files", extensions: ["*"] },
],
});
if (result.canceled || result.filePaths.length === 0) return;
await this.openExternalFile(result.filePaths[0]!);
}
private openExternalFileByPath(): void {
new ExternalFilePathModal(
this.app,
this.externalFileAllowedExtensions(),
(filePath) => {
void this.openExternalFile(filePath);
},
).open();
}
private async openExternalFile(
filePath: string,
makeFrontmost = true,
): Promise<void> {
const result = await this.externalFileOpener?.openExternalFile(filePath, {
makeFrontmost,
});
if (!result) {
new Notice("外部文件打开器尚未初始化。");
return;
}
if (!result.success) {
new Notice(`打开外部文件失败:${result.message ?? "未知错误"}`, 8000);
}
}
private async pruneExternalFileLinks(): Promise<void> {
const removed = await this.externalFileOpener?.pruneBrokenMappings();
new Notice(
removed && removed > 0
? `已清理 ${removed} 个失效外部文件链接。`
: "没有需要清理的外部文件链接。",
);
}
private nativeOpenDialog(): NativeOpenDialog | null {
try {
const requireFn = (window as unknown as {
require?: (moduleName: string) => unknown;
}).require;
const electron = requireFn?.("electron") as
| {
remote?: { dialog?: NativeOpenDialog };
dialog?: NativeOpenDialog;
}
| undefined;
return electron?.remote?.dialog ?? electron?.dialog ?? null;
} catch {
return null;
}
}
private availableCustomMarkdownFilePath(
parent: TFolder,
extension: string,
): string {
return availableCustomMarkdownFilePath(parent.path, extension, (filePath) =>
this.app.vault.getAbstractFileByPath(filePath) !== null,
);
}
private unregisterCustomMarkdownExtensions(): void {
for (const id of Array.from(this.registeredCustomMarkdownCommandIds)) {
this.removeCommand(id);
this.registeredCustomMarkdownCommandIds.delete(id);
}
const extensions = Array.from(this.ownedCustomMarkdownExtensions);
if (extensions.length === 0) {
this.registeredCustomMarkdownExtensions.clear();
this.refreshCustomMarkdownHighlights();
return;
}
try {
this.customMarkdownExtensionRegistry()?.unregisterExtensions?.(extensions);
} catch (error) {
console.warn("[mv-senceai-ide] Failed to unregister custom Markdown extensions.", error);
} finally {
this.registeredCustomMarkdownExtensions.clear();
this.ownedCustomMarkdownExtensions.clear();
this.refreshCustomMarkdownHighlights();
}
}
private customMarkdownHighlightExtension() {
const plugin = this;
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(private readonly view: EditorView) {
plugin.customMarkdownHighlightEditorViews.add(view);
this.decorations = plugin.customMarkdownHighlightDecorations(view);
}
update(update: ViewUpdate): void {
const shouldRefresh =
update.docChanged ||
plugin.customMarkdownEditorExtension(update.startState) !==
plugin.customMarkdownEditorExtension(update.state) ||
customMarkdownHighlightRefreshRequested(update);
if (shouldRefresh) {
this.decorations = plugin.customMarkdownHighlightDecorations(
update.view,
);
}
}
destroy(): void {
plugin.customMarkdownHighlightEditorViews.delete(this.view);
}
},
{
decorations: (pluginValue) => pluginValue.decorations,
},
);
}
private customMarkdownPlainVisualsExtension() {
return EditorView.editorAttributes.of((view) => {
const extension = this.customMarkdownEditorExtension(view.state);
return customMarkdownPlainVisualsEnabled(
this.registeredCustomMarkdownExtensions,
extension,
)
? { class: CUSTOM_MARKDOWN_PLAIN_VISUALS_CLASS }
: null;
});
}
private customMarkdownHighlightThemeExtension() {
return EditorView.editorAttributes.of((view) => {
const extension = this.customMarkdownEditorExtension(view.state);
const style = sourceHighlightThemeStyleAttribute(
this.settings.sourceAssist,
this.registeredCustomMarkdownExtensions,
extension,
);
if (!style && !this.registeredCustomMarkdownExtensions.has(extension)) {
return null;
}
return {
class: "mv-senceai-source-highlight-themed",
...(style ? { style } : {}),
};
});
}
private customMarkdownHighlightDecorations(view: EditorView): DecorationSet {
const ranges = customMarkdownHighlightRangesForSource(
this.customMarkdownPrism,
this.registeredCustomMarkdownExtensions,
this.customMarkdownEditorExtension(view.state),
view.state.doc.toString(),
(message, error) => {
console.warn(`[mv-senceai-ide] ${message}`, error ?? "");
},
);
if (ranges.length === 0) return Decoration.none;
const docLength = view.state.doc.length;
const decorations = ranges
.filter((range) => range.from < range.to && range.to <= docLength)
.map((range) =>
Decoration.mark({ class: range.classes }).range(range.from, range.to),
);
return decorations.length > 0
? Decoration.set(decorations, true)
: Decoration.none;
}
private customMarkdownEditorExtension(state: EditorState): string {
return (
state
.field(editorInfoField, false)
?.file?.extension?.toLowerCase() ?? ""
);
}
private async loadCustomMarkdownPrism(): Promise<void> {
if (this.customMarkdownPrismLoadStarted) return;
this.customMarkdownPrismLoadStarted = true;
try {
this.customMarkdownPrism = (await loadPrism()) as PrismLike;
this.refreshCustomMarkdownHighlights();
} catch (error) {
console.warn("[mv-senceai-ide] Failed to load Prism.", error);
}
}
private refreshCustomMarkdownHighlights(): void {
for (const view of Array.from(this.customMarkdownHighlightEditorViews)) {
try {
view.dispatch({
effects: refreshCustomMarkdownHighlightEffect.of(undefined),
});
} catch (error) {
console.warn(
"[mv-senceai-ide] Failed to refresh custom Markdown highlighting.",
error,
);
}
}
}
refreshLlmFeature(): void {
this.llmFeature?.settingsChanged();
}
refreshInlineCompletion(): void {
this.inlineCompletion?.settingsChanged();
}
async setSelectionHighlightsEnabled(enabled: boolean): Promise<void> {
this.settings.preserveSelectionHighlights = enabled;
this.selectionHighlighter?.setEnabled(enabled);
await this.saveData(this.settings);
}
async restartBridge(): Promise<void> {
await this.stopBridge();
await this.syncLocalServices(true);
this.previousBroadcasts.clear();
this.scheduleBroadcast();
}
async restoreClaudeSettings(): Promise<void> {
const filePath = localClaudeSettingsPath(getVaultRoot(this.app));
this.settings = restoreManagedBaseUrl(filePath, this.settings);
restoreManagedTerminalHooks(filePath);
await this.saveData(this.settings);
}
private async finishUnload(): Promise<void> {
try {
await this.codexIdeProvider?.stop();
await this.restoreClaudeSettings();
await this.closeDiffs();
await this.stopBridge();
} catch (error) {
console.error("[mv-senceai-ide] unload cleanup failed", error);
}
}
resolvedUpstream(): ResolvedUpstream {
return resolveAnthropicBaseUrl(getVaultRoot(this.app), this.settings);
}
async retryMcpRegistration(): Promise<void> {
this.clearScheduledMcpRegistration();
await this.runMcpRegistration(true);
}
async cleanMcpRegistration(): Promise<void> {
const result = await removeMcpRegistration(
this.settings.claudeExecutable,
getVaultRoot(this.app),
);
this.mcpStatus = result.ok ? result.message : `清理失败:${result.message}`;
if (result.ok) this.settings.registeredMcpUrl = null;
await this.saveData(this.settings);
}
checkDefaultFileOpener(): DefaultOpenerStatus {
const status = this.externalFileOpenerSystem.check(getVaultRoot(this.app));
this.defaultFileOpenerStatus = status.message;
return status;
}
async installDefaultFileOpener(): Promise<void> {
if (!this.settings.externalFileOpener.enabled) {
this.settings.externalFileOpener.enabled = true;
await this.saveAndApplySettings();
}
const result = await this.externalFileOpenerSystem.install({
vaultRoot: getVaultRoot(this.app),
vaultName: this.vaultName(),
extensionMode: this.settings.externalFileOpener.extensionMode,
extensions: this.externalFileAllowedExtensions(),
});
this.defaultFileOpenerStatus = result.message;
if (result.ok) {
this.syncExternalFileOpenerRuntime();
}
new Notice(result.message, result.ok ? 4000 : 8000);
}
async cleanupDefaultFileOpener(): Promise<void> {
const result = await this.externalFileOpenerSystem.cleanup(getVaultRoot(this.app));
this.defaultFileOpenerStatus = result.message;
new Notice(result.message);
}
private syncExternalFileOpenerRuntime(): void {
const vaultRoot = getVaultRoot(this.app);
if (!this.settings.externalFileOpener.enabled || !this.port) {
this.externalFileOpenerSystem.removeRuntime(vaultRoot);
return;
}
this.externalFileOpenerSystem.writeRuntime({
vaultRoot,
vaultName: this.vaultName(),
port: this.port,
token: this.settings.externalFileOpener.openerToken,
});
}
private vaultName(): string {
const vault = this.app.vault as unknown as { getName?: () => string };
return vault.getName?.() || path.basename(getVaultRoot(this.app));
}
private shouldRunLocalServer(): boolean {
return (
this.settings.externalFileOpener.enabled ||
this.settings.ideIntegrations.claudeCode ||
(this.settings.ideIntegrations.codex && this.settings.mcpEnabled)
);
}
private async syncLocalServices(
notifyClaude = false,
scheduleCodexMcp = true,
): Promise<void> {
this.claudeIdeError = null;
if (this.shouldRunLocalServer() && !this.server) {
try {
await this.startBridge();
} catch (error) {
this.claudeIdeError = error instanceof Error ? error.message : String(error);
console.error("[mv-senceai-ide] Claude IDE bridge start failed", error);
}
} else if (!this.shouldRunLocalServer() && this.server) {
await this.stopBridge();
}
await this.syncClaudeIntegration(notifyClaude);
if (scheduleCodexMcp) {
this.scheduleCodexMcpRegistration();
} else {
this.codexMcpStatus =
this.settings.ideIntegrations.codex && this.settings.mcpEnabled
? "Codex MCP 等待启动后初始化"
: "Codex MCP 未启用";
}
this.syncExternalFileOpenerRuntime();
}
private async syncClaudeIntegration(notify = false): Promise<void> {
if (!this.settings.ideIntegrations.claudeCode || !this.server || !this.port) {
this.clearScheduledMcpRegistration();
if (this.bridgeHasClaudeLock && this.port) removeLockFile(this.port);
this.bridgeHasClaudeLock = false;
if (!this.settings.ideIntegrations.claudeCode) {
this.mcpStatus = "Claude Code IDE 已关闭";
await this.restoreClaudeSettings();
}
return;
}
cleanStaleObsidianLocks();
if (!this.bridgeHasClaudeLock) {
writeLockFile(
this.port,
getVaultRoot(this.app),
this.bridgeAuthToken ?? randomUUID(),
);
this.bridgeHasClaudeLock = true;
}
await this.applyClaudeSettingsBestEffort(notify);
if (!this.unloaded) await this.saveData(this.settings);
this.scheduleMcpRegistration();
}
private async startBridge(): Promise<void> {
const vaultRoot = getVaultRoot(this.app);
const authToken = randomUUID();
this.bridgeAuthToken = authToken;
this.server = new BridgeServer({
authToken,
mcpAuthToken: this.settings.mcpAuthToken,
vaultRoot,
settings: () => this.settings,
upstreamBaseUrl: () => this.resolvedUpstream().url,
externalFileOpenerToken: () =>
this.settings.externalFileOpener.openerToken,
onExternalFileOpen: async (request) => {
const result = await this.externalFileOpener?.openExternalFile(
request.path,
{ makeFrontmost: request.makeFrontmost },
);
return result ?? {
success: false,
externalPath: request.path,
vaultPath: null,
message: "外部文件打开器尚未初始化。",
};
},
onMessage: (request, context) =>
this.handleRequest(request, "ide", context),
onMcpMessage: (request, context) =>
this.handleRequest(request, "mcp", context),
onClientContextChanged: () => {
this.terminalTracker?.scan();
this.scheduleBroadcast();
},
onLog: (message) => console.error("[mv-senceai-ide]", message),
});
this.port = await this.server.start();
console.log(`[mv-senceai-ide] listening on 127.0.0.1:${this.port}`);
}
private async stopBridge(): Promise<void> {
const port = this.port;
this.externalFileOpenerSystem.removeRuntime(getVaultRoot(this.app));
this.port = 0;
this.bridgeAuthToken = null;
await this.server?.stop();
this.server = null;
if (port && this.bridgeHasClaudeLock) removeLockFile(port);
this.bridgeHasClaudeLock = false;
}
private async applyClaudeSettings(): Promise<void> {
const filePath = localClaudeSettingsPath(getVaultRoot(this.app));
if (this.settings.activityTracking.supportAllActivePages) {
applyManagedTerminalHooks(filePath);
} else {
restoreManagedTerminalHooks(filePath);
}
if (
this.settings.upstreamMode === "compatibility" &&
this.settings.autoManageClaudeSettings &&
this.resolvedUpstream().url &&
this.port
) {
this.settings = applyManagedBaseUrl(
filePath,
`http://127.0.0.1:${this.port}`,
this.settings,
);
} else {
this.settings = restoreManagedBaseUrl(filePath, this.settings);
}
}
private async applyClaudeSettingsBestEffort(notify = false): Promise<void> {
try {
await this.applyClaudeSettings();
} catch (error) {
console.warn("[mv-senceai-ide] Claude settings sync failed", error);
if (notify) {
new Notice("Claude 设置同步失败,但插件已继续运行。详情见控制台。");
}
}
}
private async handleRequest(
request: JsonRpcRequest,
channel: "ide" | "mcp",
context?: BridgeClientContext,
): Promise<JsonRpcResponse | null> {
const id = request.id ?? null;
switch (request.method) {
case "initialize":
if (context) this.previousBroadcasts.delete(context.clientId);
this.scheduleBroadcast();
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion:
(request.params?.protocolVersion as string | undefined) ?? "2025-03-26",
capabilities: { tools: {} },
serverInfo: {
name: channel === "mcp" ? "mv-senceai-ide-tools" : "mv-senceai-ide",
version: this.manifest.version,
},
},
};
case "tools/list":
if (context) this.previousBroadcasts.delete(context.clientId);
this.scheduleBroadcast();
return {
jsonrpc: "2.0",
id,
result: {
tools:
channel === "mcp"
? mcpToolDefinitions(this.settings)
: IDE_TOOL_DEFINITIONS,
},
};
case "tools/call": {
const name = String(request.params?.name ?? "");
if (channel === "mcp" && !isMcpToolEnabled(name, this.settings)) {
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Tool disabled or not found: ${name}` },
};
}
const args =
request.params?.arguments &&
typeof request.params.arguments === "object" &&
!Array.isArray(request.params.arguments)
? (request.params.arguments as Record<string, unknown>)
: {};
const toolResult = await this.toolRegistry?.call(name, args, context);
return toolResult
? { jsonrpc: "2.0", id, result: toolResult }
: {
jsonrpc: "2.0",
id,
error: { code: -32601, message: `Tool not found: ${name}` },
};
}
default:
return {
jsonrpc: "2.0",
id,
error: { code: -32601, message: "Method not found" },
};
}
}
private scheduleBroadcast(): void {
if (this.broadcastTimer !== null) activeWindow.clearTimeout(this.broadcastTimer);
this.broadcastTimer = activeWindow.setTimeout(() => {
this.broadcastTimer = null;
void this.broadcastSelection();
}, 100);
}
private clearScheduledMcpRegistration(): void {
if (this.mcpRegistrationTimer === null) return;
activeWindow.clearTimeout(this.mcpRegistrationTimer);
this.mcpRegistrationTimer = null;
}
private clearScheduledCodexMcpRegistration(): void {
if (this.codexMcpRegistrationTimer === null) return;
activeWindow.clearTimeout(this.codexMcpRegistrationTimer);
this.codexMcpRegistrationTimer = null;
}
private scheduleMcpRegistration(force = false): void {
this.clearScheduledMcpRegistration();
if (!this.port) return;
const url = this.currentMcpUrl();
if (
!force &&
this.settings.mcpEnabled &&
this.settings.registeredMcpUrl === url
) {
this.mcpStatus = "MCP 已连接";
return;
}
this.mcpStatus = this.settings.mcpEnabled
? "MCP 后台检查中"
: "MCP 已关闭";
this.mcpRegistrationTimer = activeWindow.setTimeout(() => {
this.mcpRegistrationTimer = null;
void this.runMcpRegistration(force);
}, 0);
}
private async runMcpRegistration(force = false): Promise<void> {
if (this.mcpRegistrationInFlight) {
if (!force) {
await this.mcpRegistrationInFlight;
return;
}
await this.mcpRegistrationInFlight;
}
const task = this.performMcpRegistration(force);
this.mcpRegistrationInFlight = task;
try {
await task;
} finally {
if (this.mcpRegistrationInFlight === task) {
this.mcpRegistrationInFlight = null;
}
}
}
private async performMcpRegistration(force: boolean): Promise<void> {
try {
await this.syncMcpRegistration(force);
if (!this.unloaded) await this.saveData(this.settings);
} catch (error) {
console.warn("[mv-senceai-ide] MCP registration failed", error);
this.mcpStatus = `注册失败:${
error instanceof Error ? error.message : String(error)
}`;
if (!this.unloaded) await this.saveData(this.settings);
}
}
private scheduleCodexMcpRegistration(): void {
this.clearScheduledCodexMcpRegistration();
this.codexMcpStatus =
this.settings.ideIntegrations.codex && this.settings.mcpEnabled
? "Codex MCP 后台检查中"
: "Codex MCP 未启用";
this.codexMcpRegistrationTimer = activeWindow.setTimeout(() => {
this.codexMcpRegistrationTimer = null;
void this.runCodexMcpRegistration();
}, 0);
}
private async runCodexMcpRegistration(): Promise<void> {
if (this.codexMcpRegistrationInFlight) {
await this.codexMcpRegistrationInFlight;
return;
}
const task = this.syncCodexMcpRegistration();
this.codexMcpRegistrationInFlight = task;
try {
await task;
} finally {
if (this.codexMcpRegistrationInFlight === task) {
this.codexMcpRegistrationInFlight = null;
}
}
}
private async syncCodexMcpRegistration(): Promise<void> {
if (!this.settings.ideIntegrations.codex || !this.settings.mcpEnabled) {
const result = await removeCodexMcpRegistration();
this.codexMcpStatus = result.ok
? "Codex MCP 未启用"
: `Codex MCP 清理失败:${result.message}`;
return;
}
const url = this.currentMcpUrl();
if (!url) {
this.codexMcpStatus = "Codex MCP 等待本地服务";
return;
}
const result = await ensureCodexMcpRegistration(
url,
this.settings.mcpAuthToken,
);
this.codexMcpStatus = result.ok
? result.message
: `Codex MCP 配置失败:${result.message}`;
}
private currentMcpUrl(): string | null {
return this.port ? `http://127.0.0.1:${this.port}/mcp` : null;
}
private codexRuntimeDir(): string {
return path.join(
getVaultRoot(this.app),
this.app.vault.configDir,
"plugins",
this.manifest.id,
"tmp",
);
}
private schedulePostLayoutStartup(): void {
this.postLayoutStartup?.cancel();
this.postLayoutStartup = schedulePostLayoutStartup({
onLayoutReady: (callback) => this.app.workspace.onLayoutReady(callback),
setTimeout: (callback, delayMs) => activeWindow.setTimeout(callback, delayMs),
clearTimeout: (timerId) => activeWindow.clearTimeout(timerId),
delayMs: 2000,
isUnloaded: () => this.unloaded,
run: () => this.runPostLayoutStartup(),
onError: (error) => {
console.error("[mv-senceai-ide] post-layout startup failed", error);
},
});
}
private async runPostLayoutStartup(): Promise<void> {
if (this.unloaded) return;
this.cleanupCodexRuntimeCacheBestEffort();
await this.syncCodexIdeProvider();
if (this.unloaded) return;
await this.syncLocalServices(false, false);
if (this.unloaded) return;
this.scheduleCodexMcpRegistrationIfReady();
}
private scheduleCodexMcpRegistrationIfReady(): void {
if (this.settings.ideIntegrations.codex && this.codexIdeError) {
this.codexMcpStatus = "Codex MCP 等待启动后初始化";
return;
}
this.scheduleCodexMcpRegistration();
}
private cleanupCodexRuntimeCacheBestEffort(): void {
try {
fs.rmSync(path.join(this.codexRuntimeDir(), "node-compile-cache"), {
recursive: true,
force: true,
});
} catch (error) {
console.warn("[mv-senceai-ide] Codex runtime cache cleanup failed", error);
}
}
private async syncCodexIdeProvider(): Promise<void> {
if (!this.codexIdeProvider) return;
this.codexIdeError = null;
if (!this.settings.ideIntegrations.codex) {
await this.codexIdeProvider.stop();
await removeCodexShellAlias();
return;
}
try {
await this.codexIdeProvider.start();
await ensureCodexShellAlias(
this.codexRuntimeDir(),
this.settings.codexExecutable || "codex",
);
} catch (error) {
this.codexIdeError = error instanceof Error ? error.message : String(error);
console.error("[mv-senceai-ide] Codex IDE provider failed", error);
await removeCodexShellAlias();
}
}
private async broadcastSelection(): Promise<void> {
const generation = ++this.broadcastGeneration;
const leaf = activeWorkspaceLeaf(this.app);
const activeState =
(await currentWorkspaceContext(this.app, leaf)) ??
currentSelection(this.app);
if (generation !== this.broadcastGeneration) return;
this.terminalTracker?.scan();
if (
leaf?.view.getViewType() === "webviewer" &&
activeState?.resourceType === "web"
) {
this.latestWebLeaf = leaf;
}
const clients = this.server?.ideClients() ?? [];
if (clients.length === 0) {
const state = this.resolveTrackedState(undefined, leaf, activeState);
if (state) this.rememberState("global", state);
return;
}
for (const client of clients) {
const state = this.resolveTrackedState(client, leaf, activeState);
if (!state) continue;
this.rememberState(this.contextKey(client), state);
this.sendSelection(client, state);
}
}
private sendSelection(
client: BridgeClientContext,
state: SelectionState,
): void {
const signature = JSON.stringify({
filePath: state.filePath,
title: state.title,
viewType: state.viewType,
url: state.url,
page: state.page,
cursor: state.cursor,
selection: state.selection,
});
if (signature === this.previousBroadcasts.get(client.clientId)) return;
this.previousBroadcasts.set(client.clientId, signature);
this.server?.sendToClient(client.clientId, {
jsonrpc: "2.0",
method: "selection_changed",
params: selectionChangedParams(state),
});
}
private contextKey(context?: BridgeClientContext): string {
return context?.sessionId ?? context?.clientId ?? "global";
}
private rememberState(key: string, state: SelectionState): void {
this.lastContexts.set(key, state);
this.lastContexts.set("global", state);
rememberLatestSelection(this.latestSelections, key, state);
}
private fallbackContext(context?: BridgeClientContext): SelectionState | null {
const key = this.contextKey(context);
return (
this.lastContexts.get(key) ??
(context ? null : this.lastContexts.get("global") ?? null)
);
}
private latestSelectionFor(context?: BridgeClientContext): SelectionState | null {
return latestSelectionForContext(this.latestSelections, context);
}
private async codexIdeContextSnapshot(): Promise<CodexIdeContextSnapshot> {
const leaf = activeWorkspaceLeaf(this.app);
const activeState =
(await currentWorkspaceContext(this.app, leaf)) ??
currentSelection(this.app);
const current = this.resolveTrackedState(undefined, leaf, activeState);
if (current) this.rememberState("global", current);
return {
vaultRoot: getVaultRoot(this.app),
current,
openEditors: getOpenWorkspaceTabs(this.app).tabs,
};
}
private resolveTrackedState(
context: BridgeClientContext | undefined,
leaf: WorkspaceLeaf | null,
activeState: SelectionState | null,
): SelectionState | null {
const tracking = this.settings.activityTracking;
if (!tracking.supportAllActivePages) {
return activeState && isSelectedPageType(activeState, tracking)
? activeState
: this.fallbackContext(context);
}
if (this.terminalTracker?.isTerminalLeaf(leaf)) {
const ownLeaf = this.terminalTracker.leafForSession(context?.sessionId);
if (!ownLeaf || ownLeaf === leaf) return this.fallbackContext(context);
}
return activeState ?? this.fallbackContext(context);
}
private async closeDiffs(): Promise<void> {
await this.toolRegistry?.call("closeAllDiffTabs", {});
this.app.workspace.detachLeavesOfType(DIFF_VIEW_TYPE);
}
private async syncMcpRegistration(force = false): Promise<void> {
if (!this.settings.ideIntegrations.claudeCode) {
this.mcpStatus = "Claude Code IDE 已关闭";
return;
}
if (!this.port) return;
if (!this.settings.mcpEnabled) {
if (force || this.settings.registeredMcpUrl) {
await this.cleanMcpRegistration();
} else {
this.mcpStatus = "已关闭";
}
return;
}
const url = this.currentMcpUrl();
if (!url) return;
if (!force && this.settings.registeredMcpUrl === url) {
this.mcpStatus = "MCP 已连接";
return;
}
const result = await ensureMcpRegistration(
this.settings.claudeExecutable,
url,
this.settings.mcpAuthToken,
getVaultRoot(this.app),
);
this.mcpStatus = result.ok ? result.message : `注册失败:${result.message}`;
if (result.ok) {
this.settings.registeredMcpUrl = url;
if (!this.settings.claudeExecutable && result.executable) {
this.settings.claudeExecutable = result.executable;
}
}
}
}