添加对 CodeMirror 的支持,更新相关依赖版本,并在样式中增加 JSON 视图的样式。新增 JSON 视图类型并实现打开配置文件的功能,更新国际化文本以支持配置文件操作。

This commit is contained in:
duanfuxiang
2025-07-19 06:49:59 +08:00
parent 0f04b3c413
commit 3ca234c1a2
12 changed files with 491 additions and 36 deletions

168
src/BaseFileView.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { EditorState, Extension } from "@codemirror/state";
import { EditorView, ViewUpdate } from "@codemirror/view";
import { TFile, TextFileView, WorkspaceLeaf } from "obsidian";
import InfioPlugin from './main';
export default abstract class BaseView extends TextFileView {
public plugin: InfioPlugin;
protected cmEditor: EditorView;
protected editorEl: HTMLElement;
protected state: { filePath?: string } | null = null;
protected isEditorLoaded: boolean = false;
protected currentFilePath: string | null = null;
protected constructor(leaf: WorkspaceLeaf, plugin: InfioPlugin) {
super(leaf);
this.plugin = plugin;
}
onload(): void {
super.onload();
this.editorEl = this.contentEl.createDiv("datafile-source-view mod-cm6");
this.cmEditor = new EditorView({
state: this.createDefaultEditorState(),
parent: this.editorEl,
});
this.app.workspace.trigger("codemirror", this.cmEditor);
this.isEditorLoaded = true;
// Load file content if state contains filePath and editor is now loaded
if (this.state?.filePath) {
this.loadFileFromPath(this.state.filePath);
}
}
async setState(state: { filePath?: string }): Promise<void> {
this.state = state;
// If filePath is provided and editor is loaded, load the file immediately
if (state.filePath && this.isEditorLoaded) {
await this.loadFileFromPath(state.filePath);
}
}
getState(): { filePath?: string } {
return { filePath: this.currentFilePath };
}
private async loadFileFromPath(filePath: string): Promise<void> {
// Store the current file path for saving
this.currentFilePath = filePath;
// Try to get the file from vault first (for regular files)
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file && file instanceof TFile) {
// Regular file in vault
this.file = file;
await this.onLoadFile(file);
} else {
// File not in vault (hidden directory), read directly from filesystem
console.log('File not in vault, reading directly from filesystem');
await this.loadFileFromFilesystem(filePath);
}
}
private async loadFileFromFilesystem(filePath: string): Promise<void> {
try {
// Use vault adapter to read file directly from filesystem
const content = await this.app.vault.adapter.read(filePath);
this.setViewData(content, true);
} catch (error) {
console.error('Failed to load file from filesystem:', error);
// If file doesn't exist, create it with empty content
this.setViewData('{}', true);
}
}
async onLoadFile(file: TFile): Promise<void> {
try {
const content = await this.app.vault.read(file);
this.setViewData(content, true);
} catch (error) {
console.error('Failed to load file content:', error);
}
}
getViewData(): string {
return this.cmEditor.state.doc.toString();
}
setViewData(data: string, clear: boolean): void {
if (clear) {
this.cmEditor.dispatch({ changes: { from: 0, to: this.cmEditor.state.doc.length, insert: data } });
} else {
this.cmEditor.dispatch({ changes: { from: 0, to: this.cmEditor.state.doc.length, insert: data } });
}
}
clear(): void {
this.setViewData('', true);
}
async save(clear?: boolean): Promise<void> {
const content = this.getViewData();
if (this.file) {
// Regular file in vault
await this.app.vault.modify(this.file, content);
} else if (this.currentFilePath) {
// File in hidden directory, save directly to filesystem
await this.app.vault.adapter.write(this.currentFilePath, content);
}
if (clear) {
this.clear();
}
}
// gets the title of the document
getDisplayText(): string {
if (this.file) {
return this.file.basename;
}
if (this.currentFilePath) {
return this.currentFilePath.split('/').pop() || "JSON File";
}
if (this.state?.filePath) {
return this.state.filePath.split('/').pop() || "JSON File";
}
return "NOFILE";
}
onClose(): Promise<void> {
return super.onClose();
}
async reload(): Promise<void> {
await this.save(false);
const data = this.getViewData();
this.cmEditor.setState(this.createDefaultEditorState());
this.setViewData(data, false);
}
protected onEditorUpdate(update: ViewUpdate): void {
if (update.docChanged) {
this.requestSave();
}
}
abstract getViewType(): string;
protected abstract getEditorExtensions(): Extension[];
private createDefaultEditorState(): EditorState {
return EditorState.create({
extensions: [...this.getCommonEditorExtensions(), ...this.getEditorExtensions()]
});
}
private getCommonEditorExtensions(): Extension[] {
const extensions: Extension[] = [];
extensions.push(EditorView.lineWrapping);
return extensions;
}
}

32
src/JsonFileView.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { json } from "@codemirror/lang-json";
import { Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { WorkspaceLeaf } from "obsidian";
import BaseView from "./BaseFileView";
import { JSON_VIEW_TYPE } from './constants';
import InfioPlugin from './main';
import { getIndentByTabExtension } from "./utils/indentation-provider";
export default class JsonView extends BaseView {
constructor(leaf: WorkspaceLeaf, plugin: InfioPlugin) {
super(leaf, plugin);
}
getViewType(): string {
return JSON_VIEW_TYPE;
}
protected getEditorExtensions(): Extension[] {
const extensions = [
basicSetup,
getIndentByTabExtension(),
json(),
EditorView.updateListener.of(this.onEditorUpdate.bind(this))
];
return extensions;
}
}

View File

@@ -1,4 +1,4 @@
import { AlertTriangle, ChevronDown, ChevronRight, FileText, Folder, Power, RotateCcw, Trash2, Wrench } from 'lucide-react'
import { AlertTriangle, ChevronDown, ChevronRight, ExternalLink, FileText, Folder, Power, RotateCcw, Trash2, Wrench } from 'lucide-react'
import { Notice } from 'obsidian'
import React, { useEffect, useState } from 'react'
@@ -108,6 +108,17 @@ const McpHubView = () => {
}
}
const handleOpenConfigFile = async () => {
const hub = await getMcpHub();
if (hub) {
try {
await hub.openMcpSettingsFile();
} catch (error) {
console.error('Failed to open config file:', error)
}
}
}
const toggleServerExpansion = (serverKey: string) => {
setExpandedServers(prev => ({ ...prev, [serverKey]: !prev[serverKey] }));
if (!expandedServers[serverKey] && !activeServerDetailTab[serverKey]) {
@@ -196,7 +207,15 @@ const McpHubView = () => {
<div className="infio-mcp-hub-container">
{/* Header Section */}
<div className="infio-mcp-hub-header">
<h2 className="infio-mcp-hub-title">{t('mcpHub.title')}</h2>
<h3 className="infio-mcp-hub-title">{t('mcpHub.title')}</h3>
<div className="infio-mcp-hub-actions">
<button
onClick={fetchServers}
className="obsidian-insight-refresh-btn"
>
<RotateCcw size={16} />
</button>
</div>
</div>
{/* MCP Settings */}
@@ -218,6 +237,15 @@ const McpHubView = () => {
</a>
</p>
</div>
{/* Configuration File Access */}
<button
onClick={handleOpenConfigFile}
className="infio-mcp-config-button"
>
<ExternalLink size={16} />
<span>{t('mcpHub.openConfigFile')}</span>
</button>
</div>
{/* Create New Server Section */}
@@ -448,6 +476,57 @@ const McpHubView = () => {
line-height: 1.4;
}
.infio-mcp-hub-actions {
display: flex;
gap: var(--size-2-2);
}
.obsidian-insight-refresh-btn {
display: flex;
align-items: center;
justify-content: center;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
color: var(--text-muted);
padding: 0 !important;
margin: 0 !important;
width: 24px !important;
height: 24px !important;
&:hover {
background-color: var(--background-modifier-hover) !important;
}
}
.obsidian-insight-refresh-btn:hover:not(:disabled) {
background-color: var(--interactive-hover);
}
.infio-mcp-config-button {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--interactive-normal);
color: var(--text-normal);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.infio-mcp-config-button:hover {
background-color: var(--interactive-hover);
border-color: var(--interactive-accent);
}
.infio-mcp-config-button:active {
transform: translateY(1px);
}
/* Search Section */
.infio-mcp-search-section {
margin-bottom: 16px;

View File

@@ -3,22 +3,10 @@ import { LLMModel } from './types/llm/model'
export const CHAT_VIEW_TYPE = 'infio-chat-view'
export const APPLY_VIEW_TYPE = 'infio-apply-view'
export const PREVIEW_VIEW_TYPE = 'infio-preview-view'
export const JSON_VIEW_TYPE = 'infio-json-view'
export const DEFAULT_MODELS: LLMModel[] = []
// export const PROVIDERS: ApiProvider[] = [
// 'Infio',
// 'OpenRouter',
// 'SiliconFlow',
// 'Anthropic',
// 'Deepseek',
// 'OpenAI',
// 'Google',
// 'Groq',
// 'Ollama',
// 'OpenAICompatible',
// ]
export const SUPPORT_EMBEDDING_SIMENTION: number[] = [
384,
512,

View File

@@ -1,7 +1,5 @@
// Obsidian
import { App, EventRef, Notice, TFile, normalizePath } from 'obsidian';
// Node built-in
import { App, EventRef, Notice, TFile, normalizePath } from 'obsidian';
import * as path from "path";
// SDK / External Libraries
@@ -23,7 +21,7 @@ import { EnvironmentVariables, shellEnvSync } from 'shell-env';
import { z } from "zod"; // Keep zod
// Internal/Project imports
import { INFIO_BASE_URL } from '../../constants'
import { INFIO_BASE_URL, JSON_VIEW_TYPE } from '../../constants';
import { t } from "../../lang/helpers";
import InfioPlugin from "../../main";
// Assuming path is correct and will be resolved, if not, this will remain an error.
@@ -331,24 +329,16 @@ export class McpHub {
async ensureMcpFileExists(): Promise<void> {
const mcpFolderPath = ".infio_json_db/mcp"
if (!await this.app.vault.adapter.exists(normalizePath(mcpFolderPath))) {
await this.app.vault.createFolder(mcpFolderPath);
await this.app.vault.createFolder(normalizePath(mcpFolderPath));
}
this.mcpSettingsFilePath = normalizePath(path.join(mcpFolderPath, "settings.json"))
const fileExists = await this.app.vault.adapter.exists(this.mcpSettingsFilePath);
const fileExists = await this.app.vault.adapter.exists(normalizePath(this.mcpSettingsFilePath));
if (!fileExists) {
await this.app.vault.adapter.write(
this.mcpSettingsFilePath,
await this.app.vault.create(
normalizePath(this.mcpSettingsFilePath),
JSON.stringify({ mcpServers: {} }, null, 2)
);
}
// this.globalMcpFilePath = normalizePath(path.join(mcpFolderPath, "global.json"))
// const fileExists1 = await this.app.vault.adapter.exists(this.globalMcpFilePath);
// if (!fileExists1) {
// await this.app.vault.adapter.write(
// this.globalMcpFilePath,
// JSON.stringify({ mcpServers: {} }, null, 2)
// );
// }
}
async getMcpSettingsFilePath(): Promise<string> {
@@ -363,6 +353,61 @@ export class McpHub {
}));
}
/**
* Opens the MCP settings file in Obsidian
*/
async openMcpSettingsFile(): Promise<void> {
try {
await this.ensureMcpFileExists();
const filePath = this.mcpSettingsFilePath;
console.log('Attempting to open MCP settings file:', filePath);
// 检查文件是否已经打开
let existingLeaf: any = null;
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view.getViewType() === JSON_VIEW_TYPE) {
// 检查视图状态中的文件路径
const viewState = leaf.view.getState();
if (viewState && viewState.filePath === filePath) {
existingLeaf = leaf;
return false; // 停止遍历
}
}
});
if (existingLeaf) {
// 如果文件已经打开,重新加载最新内容并激活 leaf
await existingLeaf.setViewState({
type: JSON_VIEW_TYPE,
active: true,
state: { filePath } // 重新设置状态以触发重新加载
});
this.app.workspace.setActiveLeaf(existingLeaf);
this.app.workspace.revealLeaf(existingLeaf);
console.log('MCP settings file is already open, reloading content and activating existing view:', filePath);
} else {
// 如果文件没有打开,创建新的 leaf
const leaf = this.app.workspace.getLeaf(true);
if (leaf) {
await leaf.setViewState({
type: JSON_VIEW_TYPE,
active: true,
state: { filePath } // 传递文件路径到视图
});
this.app.workspace.revealLeaf(leaf);
console.log('Successfully opened MCP settings file in JSON view:', filePath);
} else {
console.error('Failed to get workspace leaf for JSON view');
}
}
} catch (error) {
console.error('Failed to open MCP settings file:', error);
}
}
// Combined and simplified initializeMcpServers, only for global scope
private async initializeGlobalMcpServers(): Promise<void> {
try {

View File

@@ -529,6 +529,13 @@ export default {
parameters: "Parameters",
toolNoDescription: "No description",
useMcpToolFrom: "Use MCP tool from",
configurationFile: "Configuration File",
configurationFileDescription: "Directly edit the MCP servers configuration file to add, modify, or remove servers.",
openConfigFile: "Open Configuration File",
configFileOpened: "Configuration file opened in Obsidian",
configFileNotFound: "Configuration file not found",
failedToOpenConfig: "Failed to open configuration file",
openedWithSystemApp: "Configuration file opened with system default application",
},
semanticSearch: {
title: "Semantic Index",

View File

@@ -525,10 +525,17 @@ export default {
errors: "错误",
noTools: "没有可用工具",
noResources: "没有可用资源",
noErrors: "没有错误记录",
parameters: "参数",
toolNoDescription: "无描述",
useMcpToolFrom: "使用来自以下的 MCP 工具:",
noErrors: "没有错误记录",
parameters: "参数",
toolNoDescription: "无描述",
useMcpToolFrom: "使用来自以下的 MCP 工具:",
configurationFile: "配置文件",
configurationFileDescription: "直接编辑 MCP 服务器配置文件来添加、修改或删除服务器。",
openConfigFile: "打开配置文件",
configFileOpened: "配置文件已在 Obsidian 中打开",
configFileNotFound: "配置文件未找到",
failedToOpenConfig: "打开配置文件失败",
openedWithSystemApp: "配置文件已使用系统默认应用程序打开",
}
},
semanticSearch: {

View File

@@ -6,7 +6,7 @@ import { Editor, MarkdownView, Modal, Notice, Plugin, TFile } from 'obsidian'
import { ApplyView } from './ApplyView'
import { ChatView } from './ChatView'
import { ChatProps } from './components/chat-view/ChatView'
import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE, PREVIEW_VIEW_TYPE } from './constants'
import { APPLY_VIEW_TYPE, CHAT_VIEW_TYPE, JSON_VIEW_TYPE, PREVIEW_VIEW_TYPE } from './constants'
import { getDiffStrategy } from "./core/diff/DiffStrategy"
import { InlineEdit } from './core/edit/inline-edit-processor'
import { McpHub } from './core/mcp/McpHub'
@@ -16,6 +16,7 @@ import { DBManager } from './database/database-manager'
import { migrateToJsonDatabase } from './database/json/migrateToJsonDatabase'
import { EmbeddingManager } from './embedworker/EmbeddingManager'
import EventListener from "./event-listener"
import JsonView from './JsonFileView'
import { t } from './lang/helpers'
import { PreviewView } from './PreviewView'
import CompletionKeyWatcher from "./render-plugin/completion-key-watcher"
@@ -88,6 +89,7 @@ export default class InfioPlugin extends Plugin {
this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this))
this.registerView(APPLY_VIEW_TYPE, (leaf) => new ApplyView(leaf))
this.registerView(PREVIEW_VIEW_TYPE, (leaf) => new PreviewView(leaf))
this.registerView(JSON_VIEW_TYPE, (leaf) => new JsonView(leaf, this))
// register markdown processor for Inline Edit
this.inlineEdit = new InlineEdit(this, this.settings);

View File

@@ -0,0 +1,26 @@
import { indentLess, indentWithTab } from "@codemirror/commands";
import { indentUnit } from "@codemirror/language";
import { Extension } from "@codemirror/state";
import { keymap } from "@codemirror/view";
export const getIndentByTabExtension = (): Extension[] =>
[
keymap.of([indentWithTab]),
indentUnit.of(" ")
];
export const getInsertTabsExtension = (): Extension[] =>
[
keymap.of([
// {
// key: 'Tab',
// preventDefault: true,
// run: insertTab,
// },
{
key: 'Shift-Tab',
preventDefault: true,
run: indentLess,
},
])
];